Remove unused engine runtime from Space
Browse files- src/small_cuts/engine/__init__.py +0 -9
- src/small_cuts/engine/__main__.py +0 -33
- src/small_cuts/engine/app.py +0 -141
- src/small_cuts/engine/library.py +0 -356
- src/small_cuts/engine/read_gate.py +0 -102
- src/small_cuts/engine/session.py +0 -452
src/small_cuts/engine/__init__.py
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
"""Home-node narration engine — mobile-facing WebSocket session (Team Inference).
|
| 2 |
-
|
| 3 |
-
Optional install: `uv sync --extra engine`. Nothing in the existing app path
|
| 4 |
-
imports this package, so the Space/UI keep working without the extra.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from .app import build_engine_app
|
| 8 |
-
|
| 9 |
-
__all__ = ["build_engine_app"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/small_cuts/engine/__main__.py
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
"""Run the engine: `uv run python -m small_cuts.engine`."""
|
| 2 |
-
|
| 3 |
-
import contextlib
|
| 4 |
-
import os
|
| 5 |
-
import signal
|
| 6 |
-
|
| 7 |
-
import uvicorn
|
| 8 |
-
|
| 9 |
-
from .app import build_engine_app
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def main() -> None:
|
| 13 |
-
from small_cuts import narrator
|
| 14 |
-
|
| 15 |
-
def shutdown(signum, frame): # noqa: ARG001
|
| 16 |
-
with contextlib.suppress(Exception):
|
| 17 |
-
backend = narrator.get_backend()
|
| 18 |
-
close = getattr(backend, "close", None)
|
| 19 |
-
if close is not None:
|
| 20 |
-
close()
|
| 21 |
-
raise SystemExit(0)
|
| 22 |
-
|
| 23 |
-
signal.signal(signal.SIGTERM, shutdown)
|
| 24 |
-
uvicorn.run(
|
| 25 |
-
build_engine_app(),
|
| 26 |
-
host=os.environ.get("SMALL_CUTS_ENGINE_HOST", "0.0.0.0"),
|
| 27 |
-
port=int(os.environ.get("SMALL_CUTS_ENGINE_PORT", "8077")),
|
| 28 |
-
ws_max_size=int(os.environ.get("SMALL_CUTS_ENGINE_WS_MAX_SIZE", str(64 * 1024 * 1024))),
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
if __name__ == "__main__":
|
| 33 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/small_cuts/engine/app.py
DELETED
|
@@ -1,141 +0,0 @@
|
|
| 1 |
-
"""App factory for the home-node narration engine (Team Inference).
|
| 2 |
-
|
| 3 |
-
One WebSocket per wearing session (`/v1/session`), plus the viewer-facing
|
| 4 |
-
side of docs/contracts: the scene library REST API (D6) and the live SSE
|
| 5 |
-
scene stream with Last-Event-ID resume (D7).
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
from __future__ import annotations
|
| 9 |
-
|
| 10 |
-
import asyncio
|
| 11 |
-
import json
|
| 12 |
-
from collections.abc import AsyncIterator
|
| 13 |
-
from typing import Annotated, Any, Literal
|
| 14 |
-
|
| 15 |
-
from fastapi import FastAPI, HTTPException, Query, Request, WebSocket
|
| 16 |
-
from fastapi.responses import FileResponse, StreamingResponse
|
| 17 |
-
from pydantic import BaseModel
|
| 18 |
-
|
| 19 |
-
from .library import SceneLibrary
|
| 20 |
-
from .session import EngineState, SceneSink, SessionRunner
|
| 21 |
-
|
| 22 |
-
SSE_HEARTBEAT_S = 15.0
|
| 23 |
-
|
| 24 |
-
Visibility = Literal["private", "shared", "public"]
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
class VisibilityPatch(BaseModel):
|
| 28 |
-
visibility: Visibility
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def _sse_event(event: dict[str, Any]) -> str:
|
| 32 |
-
data = f"data: {json.dumps(event)}\n\n"
|
| 33 |
-
seq = event.get("seq")
|
| 34 |
-
# No seq -> ephemeral event (error frames): no id line, so it never becomes
|
| 35 |
-
# a Last-Event-ID resume cursor and is never expected in replay.
|
| 36 |
-
return data if seq is None else f"id: {seq}\n{data}"
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def _last_event_id(raw: str | None) -> int | None:
|
| 40 |
-
"""SSE resume cursor; absent or unparsable means a fresh connect, live only."""
|
| 41 |
-
if raw is None:
|
| 42 |
-
return None
|
| 43 |
-
try:
|
| 44 |
-
return int(raw)
|
| 45 |
-
except ValueError:
|
| 46 |
-
return None
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
async def scene_event_stream(
|
| 50 |
-
library: SceneLibrary, last_event_id: int | None, heartbeat_s: float
|
| 51 |
-
) -> AsyncIterator[str]:
|
| 52 |
-
"""SSE body: replay seq > Last-Event-ID, then live events; pings while idle.
|
| 53 |
-
|
| 54 |
-
Live events without a seq (pipeline errors) pass straight through —
|
| 55 |
-
they are ephemeral and never part of replay.
|
| 56 |
-
"""
|
| 57 |
-
queue = library.subscribe()
|
| 58 |
-
try:
|
| 59 |
-
last_seq = -1
|
| 60 |
-
if last_event_id is not None:
|
| 61 |
-
last_seq = last_event_id
|
| 62 |
-
for scene in library.scenes_since(last_event_id):
|
| 63 |
-
last_seq = scene["seq"]
|
| 64 |
-
yield _sse_event(scene)
|
| 65 |
-
while True:
|
| 66 |
-
try:
|
| 67 |
-
event = await asyncio.wait_for(queue.get(), timeout=heartbeat_s)
|
| 68 |
-
except asyncio.TimeoutError:
|
| 69 |
-
yield ": ping\n\n"
|
| 70 |
-
continue
|
| 71 |
-
# Invariant: the library publishes each seq exactly once, so live
|
| 72 |
-
# events never need dedupe against each other. `last_seq` stays
|
| 73 |
-
# frozen at the replay boundary — it only filters scenes that were
|
| 74 |
-
# both replayed and queued (stored before replay, published after
|
| 75 |
-
# subscribe). It must NOT advance here: store() commits seq under
|
| 76 |
-
# the lock in a worker thread but publishes later on the loop, so
|
| 77 |
-
# concurrent sessions can publish out of seq order, and a moving
|
| 78 |
-
# cursor would drop the lower seq forever.
|
| 79 |
-
seq = event.get("seq")
|
| 80 |
-
if seq is not None and seq <= last_seq:
|
| 81 |
-
continue
|
| 82 |
-
yield _sse_event(event)
|
| 83 |
-
finally:
|
| 84 |
-
library.unsubscribe(queue)
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
def build_engine_app(
|
| 88 |
-
scene_sink: SceneSink | None = None,
|
| 89 |
-
library: SceneLibrary | None = None,
|
| 90 |
-
sse_heartbeat_s: float = SSE_HEARTBEAT_S,
|
| 91 |
-
) -> FastAPI:
|
| 92 |
-
"""Engine app: session socket + scene library + SSE stream, per docs/contracts.
|
| 93 |
-
|
| 94 |
-
By default scenes are persisted to a `SceneLibrary` (root from
|
| 95 |
-
`SMALL_CUTS_LIBRARY_DIR`); pass `library` to inject one, or `scene_sink`
|
| 96 |
-
to replace the sink entirely (tests).
|
| 97 |
-
"""
|
| 98 |
-
lib = library if library is not None else SceneLibrary()
|
| 99 |
-
sink = scene_sink if scene_sink is not None else lib
|
| 100 |
-
# Errors fan out to the viewer stream too (D9): the timeline shows failures.
|
| 101 |
-
state = EngineState(sink=sink, error_sink=lib.publish_event)
|
| 102 |
-
app = FastAPI(title="small-cuts engine")
|
| 103 |
-
app.state.library = lib
|
| 104 |
-
|
| 105 |
-
@app.websocket("/v1/session")
|
| 106 |
-
async def session(websocket: WebSocket) -> None:
|
| 107 |
-
await websocket.accept()
|
| 108 |
-
await SessionRunner(websocket, state).run()
|
| 109 |
-
|
| 110 |
-
@app.get("/v1/scenes")
|
| 111 |
-
def list_scenes(
|
| 112 |
-
session: str | None = None,
|
| 113 |
-
visibility: Visibility | None = None,
|
| 114 |
-
limit: Annotated[int, Query(ge=1, le=1000)] = 100,
|
| 115 |
-
) -> dict[str, list[dict[str, Any]]]:
|
| 116 |
-
return {"scenes": lib.list_scenes(session_id=session, visibility=visibility, limit=limit)}
|
| 117 |
-
|
| 118 |
-
@app.get("/v1/scenes/stream")
|
| 119 |
-
async def stream_scenes(request: Request) -> StreamingResponse:
|
| 120 |
-
resume_from = _last_event_id(request.headers.get("last-event-id"))
|
| 121 |
-
return StreamingResponse(
|
| 122 |
-
scene_event_stream(lib, resume_from, sse_heartbeat_s),
|
| 123 |
-
media_type="text/event-stream",
|
| 124 |
-
headers={"Cache-Control": "no-cache"},
|
| 125 |
-
)
|
| 126 |
-
|
| 127 |
-
@app.patch("/v1/scenes/{scene_id}")
|
| 128 |
-
def set_visibility(scene_id: str, patch: VisibilityPatch) -> dict[str, Any]:
|
| 129 |
-
scene = lib.set_visibility(scene_id, patch.visibility)
|
| 130 |
-
if scene is None:
|
| 131 |
-
raise HTTPException(status_code=404, detail=f"unknown scene {scene_id}")
|
| 132 |
-
return scene
|
| 133 |
-
|
| 134 |
-
@app.get("/media/{scene_id}/{filename}")
|
| 135 |
-
def media(scene_id: str, filename: str) -> FileResponse:
|
| 136 |
-
path = lib.media_path(scene_id, filename)
|
| 137 |
-
if path is None: # unknown name, traversal attempt, or missing file
|
| 138 |
-
raise HTTPException(status_code=404, detail="no such media")
|
| 139 |
-
return FileResponse(path)
|
| 140 |
-
|
| 141 |
-
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/small_cuts/engine/library.py
DELETED
|
@@ -1,356 +0,0 @@
|
|
| 1 |
-
"""Engine-side scene library: filesystem media + sqlite index (D6).
|
| 2 |
-
|
| 3 |
-
The real `SceneSink`. Every successful narration is persisted — frame JPEG,
|
| 4 |
-
title card, voice WAV, one sqlite row — and fanned out to live SSE
|
| 5 |
-
subscribers (D7). Blocking writes run in a worker thread via
|
| 6 |
-
`asyncio.to_thread`; the publish happens back on the event loop. Stored
|
| 7 |
-
entries and live events share the same NarratedScene shape, per
|
| 8 |
-
docs/contracts/narrated-scene.schema.json.
|
| 9 |
-
"""
|
| 10 |
-
|
| 11 |
-
from __future__ import annotations
|
| 12 |
-
|
| 13 |
-
import asyncio
|
| 14 |
-
import contextlib
|
| 15 |
-
import json
|
| 16 |
-
import os
|
| 17 |
-
import sqlite3
|
| 18 |
-
import sys
|
| 19 |
-
import threading
|
| 20 |
-
from pathlib import Path
|
| 21 |
-
from typing import Any
|
| 22 |
-
|
| 23 |
-
from PIL import Image
|
| 24 |
-
|
| 25 |
-
from small_cuts import narrator, tts
|
| 26 |
-
from small_cuts.frames import pick_key_frame
|
| 27 |
-
from small_cuts.title_card import derive_title, render_title_card
|
| 28 |
-
|
| 29 |
-
from .session import CONTRACT_VERSION, _wav_bytes
|
| 30 |
-
|
| 31 |
-
DEFAULT_ROOT = "~/.small-cuts/library"
|
| 32 |
-
OWNER = "carlos" # v1 engines are single-user; the field is reserved for multi-user
|
| 33 |
-
VISIBILITIES = ("private", "shared", "public")
|
| 34 |
-
MEDIA_FILES = ("frame.jpg", "card.webp", "voice.wav", "clip.mp4")
|
| 35 |
-
CLIP_MP4_FPS = 12
|
| 36 |
-
CLIP_BLEND_STEPS = 1
|
| 37 |
-
H264_MIN_DIMENSION = 2
|
| 38 |
-
POSTER_JPEG_QUALITY = 90
|
| 39 |
-
RGB_MODE = "RGB"
|
| 40 |
-
VIDEO_PIXEL_FORMAT = "yuv420p"
|
| 41 |
-
PRIMARY_VIDEO_CODEC = "libx264"
|
| 42 |
-
FALLBACK_VIDEO_CODEC = "h264"
|
| 43 |
-
|
| 44 |
-
_SCHEMA = """\
|
| 45 |
-
CREATE TABLE IF NOT EXISTS scenes (
|
| 46 |
-
scene_id TEXT PRIMARY KEY,
|
| 47 |
-
seq INTEGER NOT NULL UNIQUE,
|
| 48 |
-
moment_id TEXT NOT NULL,
|
| 49 |
-
session_id TEXT NOT NULL,
|
| 50 |
-
captured_at TEXT NOT NULL,
|
| 51 |
-
created_at TEXT NOT NULL,
|
| 52 |
-
style_key TEXT NOT NULL,
|
| 53 |
-
title TEXT NOT NULL,
|
| 54 |
-
narration TEXT NOT NULL,
|
| 55 |
-
visibility TEXT NOT NULL DEFAULT 'private',
|
| 56 |
-
owner TEXT NOT NULL,
|
| 57 |
-
engine TEXT NOT NULL
|
| 58 |
-
)"""
|
| 59 |
-
|
| 60 |
-
_INSERT = """\
|
| 61 |
-
INSERT INTO scenes (scene_id, seq, moment_id, session_id, captured_at, created_at,
|
| 62 |
-
style_key, title, narration, visibility, owner, engine)
|
| 63 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
class SceneLibrary:
|
| 67 |
-
"""Scene store + in-process pub/sub. The instance itself is the async SceneSink.
|
| 68 |
-
|
| 69 |
-
Layout: `<root>/library.sqlite3` + `<root>/media/<scene_id>/{frame.jpg,
|
| 70 |
-
card.webp, voice.wav}`. One sqlite connection, guarded by a lock: the
|
| 71 |
-
sink writes from worker threads, queries come from request handlers.
|
| 72 |
-
"""
|
| 73 |
-
|
| 74 |
-
def __init__(self, root: str | Path | None = None) -> None:
|
| 75 |
-
base = root or os.environ.get("SMALL_CUTS_LIBRARY_DIR") or DEFAULT_ROOT
|
| 76 |
-
self.root = Path(base).expanduser().resolve()
|
| 77 |
-
self.media_dir = self.root / "media"
|
| 78 |
-
self.media_dir.mkdir(parents=True, exist_ok=True)
|
| 79 |
-
self._lock = threading.Lock() # guards the connection and seq allocation
|
| 80 |
-
self._db = sqlite3.connect(self.root / "library.sqlite3", check_same_thread=False)
|
| 81 |
-
self._db.row_factory = sqlite3.Row
|
| 82 |
-
with self._lock, self._db:
|
| 83 |
-
# WAL + busy_timeout: viewer reads don't block sink writes, and a
|
| 84 |
-
# briefly locked database waits instead of raising immediately.
|
| 85 |
-
self._db.execute("PRAGMA journal_mode=WAL")
|
| 86 |
-
self._db.execute("PRAGMA busy_timeout=5000")
|
| 87 |
-
self._db.execute(_SCHEMA)
|
| 88 |
-
self._subscribers: list[asyncio.Queue[dict[str, Any]]] = []
|
| 89 |
-
|
| 90 |
-
# -- the sink -----------------------------------------------------------------
|
| 91 |
-
|
| 92 |
-
async def __call__(self, scene: dict[str, Any]) -> None:
|
| 93 |
-
"""SceneSink entry point: persist off the event loop, then publish.
|
| 94 |
-
|
| 95 |
-
A failed store (disk full, sqlite error) must not be silent data loss:
|
| 96 |
-
the mobile client already received its SceneAudio, so log to stderr and
|
| 97 |
-
fan an error ControlFrame to the viewer stream — the timeline stays
|
| 98 |
-
honest. `_hand_to_sink`'s suppression remains the last-resort backstop.
|
| 99 |
-
"""
|
| 100 |
-
try:
|
| 101 |
-
narrated = await asyncio.to_thread(self.store, scene)
|
| 102 |
-
except Exception as exc:
|
| 103 |
-
print(
|
| 104 |
-
f"small_cuts.engine: library write failed for scene {scene['scene_id']}: {exc!r}",
|
| 105 |
-
file=sys.stderr,
|
| 106 |
-
)
|
| 107 |
-
self.publish_event(
|
| 108 |
-
{
|
| 109 |
-
"contract_version": CONTRACT_VERSION,
|
| 110 |
-
"kind": "error",
|
| 111 |
-
"moment_id": scene["moment_id"],
|
| 112 |
-
"error": {
|
| 113 |
-
"stage": "storage",
|
| 114 |
-
"code": "library_write_failed",
|
| 115 |
-
"message": str(exc)[:300],
|
| 116 |
-
"retryable": False,
|
| 117 |
-
},
|
| 118 |
-
}
|
| 119 |
-
)
|
| 120 |
-
return
|
| 121 |
-
self.publish_event(narrated)
|
| 122 |
-
|
| 123 |
-
def publish_event(self, payload: dict[str, Any]) -> None:
|
| 124 |
-
"""Fan any event (stored scene or ControlFrame error) to live subscribers.
|
| 125 |
-
|
| 126 |
-
Events without a seq (errors) are EPHEMERAL: not persisted, not in Last-Event-ID replay.
|
| 127 |
-
"""
|
| 128 |
-
for queue in list(self._subscribers):
|
| 129 |
-
queue.put_nowait(payload)
|
| 130 |
-
|
| 131 |
-
def store(self, scene: dict[str, Any]) -> dict[str, Any]:
|
| 132 |
-
"""Persist media + index row (blocking); returns the stored NarratedScene."""
|
| 133 |
-
scene_id: str = scene["scene_id"]
|
| 134 |
-
narration: str = scene["narration"]
|
| 135 |
-
style_key: str = scene["style_key"]
|
| 136 |
-
title = _stored_title(scene.get("title"), narration)
|
| 137 |
-
|
| 138 |
-
scene_dir = self.media_dir / scene_id
|
| 139 |
-
scene_dir.mkdir(parents=True, exist_ok=True)
|
| 140 |
-
clip_frames = scene.get("clip_frames") or []
|
| 141 |
-
poster = pick_key_frame(clip_frames) if clip_frames else scene["image"]
|
| 142 |
-
poster.convert(RGB_MODE).save(scene_dir / "frame.jpg", "JPEG", quality=POSTER_JPEG_QUALITY)
|
| 143 |
-
if len(clip_frames) >= 2:
|
| 144 |
-
try:
|
| 145 |
-
_write_clip_mp4(scene_dir / "clip.mp4", clip_frames)
|
| 146 |
-
except Exception as exc:
|
| 147 |
-
print(
|
| 148 |
-
f"small_cuts.engine: clip write failed for scene {scene_id}: {exc!r}",
|
| 149 |
-
file=sys.stderr,
|
| 150 |
-
)
|
| 151 |
-
render_title_card(title, style_key).save(scene_dir / "card.webp", "WEBP")
|
| 152 |
-
(scene_dir / "voice.wav").write_bytes(_wav_bytes(scene["audio"], scene["sample_rate"]))
|
| 153 |
-
|
| 154 |
-
narrator_backend = narrator.get_backend()
|
| 155 |
-
tts_backend = tts.get_tts_backend()
|
| 156 |
-
engine = {
|
| 157 |
-
"narrator_model": narrator_backend.model_id,
|
| 158 |
-
"narrator_backend": narrator_backend.name,
|
| 159 |
-
"tts_model": tts_backend.model_id,
|
| 160 |
-
"latency_ms": scene["latency_ms"],
|
| 161 |
-
}
|
| 162 |
-
with self._lock, self._db:
|
| 163 |
-
# max+1 under the lock: monotonic across the process AND across restarts.
|
| 164 |
-
seq = self._db.execute("SELECT COALESCE(MAX(seq), -1) + 1 FROM scenes").fetchone()[0]
|
| 165 |
-
self._db.execute(
|
| 166 |
-
_INSERT,
|
| 167 |
-
(
|
| 168 |
-
scene_id,
|
| 169 |
-
seq,
|
| 170 |
-
scene["moment_id"],
|
| 171 |
-
scene["session_id"],
|
| 172 |
-
scene["captured_at"],
|
| 173 |
-
scene["created_at"],
|
| 174 |
-
style_key,
|
| 175 |
-
title,
|
| 176 |
-
narration,
|
| 177 |
-
"private",
|
| 178 |
-
OWNER,
|
| 179 |
-
json.dumps(engine),
|
| 180 |
-
),
|
| 181 |
-
)
|
| 182 |
-
stored = self.get(scene_id)
|
| 183 |
-
assert stored is not None # the row was just inserted
|
| 184 |
-
return stored
|
| 185 |
-
|
| 186 |
-
# -- queries ---------------------------------------------------------------------
|
| 187 |
-
|
| 188 |
-
def to_narrated_scene(self, row: sqlite3.Row) -> dict[str, Any]:
|
| 189 |
-
"""Contract-valid NarratedScene (1.1.0) for one stored row."""
|
| 190 |
-
scene_id = row["scene_id"]
|
| 191 |
-
media = {
|
| 192 |
-
"frame_url": f"/media/{scene_id}/frame.jpg",
|
| 193 |
-
"card_url": f"/media/{scene_id}/card.webp",
|
| 194 |
-
"audio_url": f"/media/{scene_id}/voice.wav",
|
| 195 |
-
}
|
| 196 |
-
if (self.media_dir / scene_id / "clip.mp4").is_file():
|
| 197 |
-
media["clip_url"] = f"/media/{scene_id}/clip.mp4"
|
| 198 |
-
return {
|
| 199 |
-
"contract_version": CONTRACT_VERSION,
|
| 200 |
-
"scene_id": scene_id,
|
| 201 |
-
"moment_id": row["moment_id"],
|
| 202 |
-
"session_id": row["session_id"],
|
| 203 |
-
"captured_at": row["captured_at"],
|
| 204 |
-
"created_at": row["created_at"],
|
| 205 |
-
"style_key": row["style_key"],
|
| 206 |
-
"title": row["title"],
|
| 207 |
-
"narration": row["narration"],
|
| 208 |
-
"visibility": row["visibility"],
|
| 209 |
-
"seq": row["seq"],
|
| 210 |
-
"owner": row["owner"],
|
| 211 |
-
"media": media,
|
| 212 |
-
"engine": json.loads(row["engine"]),
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
def list_scenes(
|
| 216 |
-
self,
|
| 217 |
-
session_id: str | None = None,
|
| 218 |
-
visibility: str | None = None,
|
| 219 |
-
limit: int = 100,
|
| 220 |
-
) -> list[dict[str, Any]]:
|
| 221 |
-
"""Newest bounded window, returned in scene chronology for the viewer."""
|
| 222 |
-
clauses, params = [], []
|
| 223 |
-
if session_id is not None:
|
| 224 |
-
clauses.append("session_id = ?")
|
| 225 |
-
params.append(session_id)
|
| 226 |
-
if visibility is not None:
|
| 227 |
-
clauses.append("visibility = ?")
|
| 228 |
-
params.append(visibility)
|
| 229 |
-
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
| 230 |
-
query = (
|
| 231 |
-
"SELECT * FROM ("
|
| 232 |
-
f"SELECT * FROM scenes{where} ORDER BY seq DESC LIMIT ?"
|
| 233 |
-
") ORDER BY captured_at, seq"
|
| 234 |
-
)
|
| 235 |
-
with self._lock:
|
| 236 |
-
rows = self._db.execute(query, (*params, limit)).fetchall()
|
| 237 |
-
return [self.to_narrated_scene(row) for row in rows]
|
| 238 |
-
|
| 239 |
-
def get(self, scene_id: str) -> dict[str, Any] | None:
|
| 240 |
-
with self._lock:
|
| 241 |
-
row = self._db.execute(
|
| 242 |
-
"SELECT * FROM scenes WHERE scene_id = ?", (scene_id,)
|
| 243 |
-
).fetchone()
|
| 244 |
-
return self.to_narrated_scene(row) if row is not None else None
|
| 245 |
-
|
| 246 |
-
def set_visibility(self, scene_id: str, visibility: str) -> dict[str, Any] | None:
|
| 247 |
-
"""The viewer's only write (D7). Returns the updated scene, or None if unknown."""
|
| 248 |
-
if visibility not in VISIBILITIES:
|
| 249 |
-
raise ValueError(f"Unknown visibility {visibility!r}; expected one of {VISIBILITIES}")
|
| 250 |
-
with self._lock, self._db:
|
| 251 |
-
updated = self._db.execute(
|
| 252 |
-
"UPDATE scenes SET visibility = ? WHERE scene_id = ?", (visibility, scene_id)
|
| 253 |
-
).rowcount
|
| 254 |
-
return self.get(scene_id) if updated else None
|
| 255 |
-
|
| 256 |
-
def scenes_since(self, seq: int) -> list[dict[str, Any]]:
|
| 257 |
-
"""Scenes with seq > `seq`, ordered by seq — the SSE Last-Event-ID replay."""
|
| 258 |
-
with self._lock:
|
| 259 |
-
rows = self._db.execute(
|
| 260 |
-
"SELECT * FROM scenes WHERE seq > ? ORDER BY seq", (seq,)
|
| 261 |
-
).fetchall()
|
| 262 |
-
return [self.to_narrated_scene(row) for row in rows]
|
| 263 |
-
|
| 264 |
-
def media_path(self, scene_id: str, filename: str) -> Path | None:
|
| 265 |
-
"""Resolve a media file, or None: unknown name, traversal, or missing file."""
|
| 266 |
-
if filename not in MEDIA_FILES:
|
| 267 |
-
return None
|
| 268 |
-
path = (self.media_dir / scene_id / filename).resolve()
|
| 269 |
-
if not path.is_relative_to(self.media_dir): # traversal via scene_id
|
| 270 |
-
return None
|
| 271 |
-
return path if path.is_file() else None
|
| 272 |
-
|
| 273 |
-
# -- pub/sub ----------------------------------------------------------------------
|
| 274 |
-
|
| 275 |
-
def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
|
| 276 |
-
"""New-scene feed for one SSE connection; pair with `unsubscribe`."""
|
| 277 |
-
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
| 278 |
-
self._subscribers.append(queue)
|
| 279 |
-
return queue
|
| 280 |
-
|
| 281 |
-
def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
|
| 282 |
-
with contextlib.suppress(ValueError):
|
| 283 |
-
self._subscribers.remove(queue)
|
| 284 |
-
|
| 285 |
-
def close(self) -> None:
|
| 286 |
-
with self._lock:
|
| 287 |
-
self._db.close()
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
def _write_clip_mp4(
|
| 291 |
-
path: Path,
|
| 292 |
-
frames: list[Image.Image],
|
| 293 |
-
fps: int = CLIP_MP4_FPS,
|
| 294 |
-
blend_steps: int = CLIP_BLEND_STEPS,
|
| 295 |
-
) -> None:
|
| 296 |
-
"""Render a small browser-playable MP4 from sampled POV frames."""
|
| 297 |
-
import av
|
| 298 |
-
|
| 299 |
-
rgb_frames = [frame.convert(RGB_MODE) for frame in frames]
|
| 300 |
-
width, height = rgb_frames[0].size
|
| 301 |
-
# H.264/yuv420p expects even dimensions. Preserve portrait aspect and only
|
| 302 |
-
# shave one pixel if needed; capture frames are already downscaled upstream.
|
| 303 |
-
width = max(H264_MIN_DIMENSION, width - (width % 2))
|
| 304 |
-
height = max(H264_MIN_DIMENSION, height - (height % 2))
|
| 305 |
-
encode_frames = _smooth_clip_frames(rgb_frames, blend_steps=blend_steps, size=(width, height))
|
| 306 |
-
|
| 307 |
-
container = av.open(str(path), "w")
|
| 308 |
-
try:
|
| 309 |
-
stream = container.add_stream(PRIMARY_VIDEO_CODEC, rate=fps)
|
| 310 |
-
except Exception:
|
| 311 |
-
stream = container.add_stream(FALLBACK_VIDEO_CODEC, rate=fps)
|
| 312 |
-
stream.width = width
|
| 313 |
-
stream.height = height
|
| 314 |
-
stream.pix_fmt = VIDEO_PIXEL_FORMAT
|
| 315 |
-
|
| 316 |
-
try:
|
| 317 |
-
for image in encode_frames:
|
| 318 |
-
frame = av.VideoFrame.from_image(image)
|
| 319 |
-
for packet in stream.encode(frame):
|
| 320 |
-
container.mux(packet)
|
| 321 |
-
for packet in stream.encode():
|
| 322 |
-
container.mux(packet)
|
| 323 |
-
finally:
|
| 324 |
-
container.close()
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
def _smooth_clip_frames(
|
| 328 |
-
frames: list[Image.Image],
|
| 329 |
-
blend_steps: int = CLIP_BLEND_STEPS,
|
| 330 |
-
size: tuple[int, int] | None = None,
|
| 331 |
-
) -> list[Image.Image]:
|
| 332 |
-
"""Insert tiny cross-dissolve frames so sampled POV clips do not hard-cut."""
|
| 333 |
-
if not frames:
|
| 334 |
-
return []
|
| 335 |
-
prepared = []
|
| 336 |
-
for image in frames:
|
| 337 |
-
image = image.convert(RGB_MODE)
|
| 338 |
-
if size is not None and image.size != size:
|
| 339 |
-
image = image.resize(size, Image.Resampling.LANCZOS)
|
| 340 |
-
prepared.append(image)
|
| 341 |
-
if blend_steps <= 0 or len(prepared) < 2:
|
| 342 |
-
return prepared
|
| 343 |
-
|
| 344 |
-
smoothed = [prepared[0]]
|
| 345 |
-
for previous, current in zip(prepared, prepared[1:], strict=False):
|
| 346 |
-
for step in range(1, blend_steps + 1):
|
| 347 |
-
alpha = step / (blend_steps + 1)
|
| 348 |
-
smoothed.append(Image.blend(previous, current, alpha))
|
| 349 |
-
smoothed.append(current)
|
| 350 |
-
return smoothed
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
def _stored_title(raw_title: object, narration: str) -> str:
|
| 354 |
-
if isinstance(raw_title, str) and raw_title.strip():
|
| 355 |
-
return derive_title(raw_title, max_len=80)
|
| 356 |
-
return derive_title(narration, max_len=80)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/small_cuts/engine/read_gate.py
DELETED
|
@@ -1,102 +0,0 @@
|
|
| 1 |
-
"""Read-only public gate for the live-demo engine surface.
|
| 2 |
-
|
| 3 |
-
The capture/write socket stays private on Tailnet. This app is the origin behind the public
|
| 4 |
-
Cloudflare Tunnel hostname and only proxies viewer/library reads through to the local engine.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
from __future__ import annotations
|
| 8 |
-
|
| 9 |
-
import os
|
| 10 |
-
from collections.abc import AsyncIterator
|
| 11 |
-
from contextlib import asynccontextmanager
|
| 12 |
-
|
| 13 |
-
import httpx
|
| 14 |
-
from fastapi import FastAPI, Request
|
| 15 |
-
from fastapi.responses import PlainTextResponse, Response, StreamingResponse
|
| 16 |
-
from starlette.background import BackgroundTask
|
| 17 |
-
|
| 18 |
-
ORIGIN_ENV = "SMALL_CUTS_ORIGIN_ENGINE_URL"
|
| 19 |
-
DEFAULT_ORIGIN = "http://127.0.0.1:8077"
|
| 20 |
-
BLOCKED_TEXT = "small-cuts public endpoint is read-only\n"
|
| 21 |
-
HOP_BY_HOP_HEADERS = {
|
| 22 |
-
"connection",
|
| 23 |
-
"keep-alive",
|
| 24 |
-
"proxy-authenticate",
|
| 25 |
-
"proxy-authorization",
|
| 26 |
-
"te",
|
| 27 |
-
"trailer",
|
| 28 |
-
"transfer-encoding",
|
| 29 |
-
"upgrade",
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def is_public_read_allowed(method: str, path: str) -> bool:
|
| 34 |
-
if method.upper() != "GET":
|
| 35 |
-
return False
|
| 36 |
-
return path in ("/v1/scenes", "/v1/scenes/stream") or path.startswith("/media/")
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def _forward_headers(headers: httpx.Headers | dict[str, str]) -> dict[str, str]:
|
| 40 |
-
return {
|
| 41 |
-
key: value
|
| 42 |
-
for key, value in headers.items()
|
| 43 |
-
if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _origin_url(origin_url: str, request: Request) -> str:
|
| 48 |
-
url = f"{origin_url.rstrip('/')}{request.url.path}"
|
| 49 |
-
return f"{url}?{request.url.query}" if request.url.query else url
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def build_read_gate_app(origin_url: str | None = None) -> FastAPI:
|
| 53 |
-
origin = (origin_url or os.environ.get(ORIGIN_ENV) or DEFAULT_ORIGIN).rstrip("/")
|
| 54 |
-
|
| 55 |
-
@asynccontextmanager
|
| 56 |
-
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
| 57 |
-
app.state.client = httpx.AsyncClient(timeout=None)
|
| 58 |
-
try:
|
| 59 |
-
yield
|
| 60 |
-
finally:
|
| 61 |
-
await app.state.client.aclose()
|
| 62 |
-
|
| 63 |
-
app = FastAPI(title="small-cuts public read gate", lifespan=lifespan)
|
| 64 |
-
|
| 65 |
-
@app.api_route(
|
| 66 |
-
"/{path:path}",
|
| 67 |
-
methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
| 68 |
-
response_model=None,
|
| 69 |
-
)
|
| 70 |
-
async def public_gate(path: str, request: Request) -> Response:
|
| 71 |
-
if not is_public_read_allowed(request.method, request.url.path):
|
| 72 |
-
return PlainTextResponse(BLOCKED_TEXT, status_code=403)
|
| 73 |
-
|
| 74 |
-
client: httpx.AsyncClient = request.app.state.client
|
| 75 |
-
upstream = await client.send(
|
| 76 |
-
client.build_request(
|
| 77 |
-
"GET",
|
| 78 |
-
_origin_url(origin, request),
|
| 79 |
-
headers=_forward_headers(request.headers),
|
| 80 |
-
),
|
| 81 |
-
stream=True,
|
| 82 |
-
)
|
| 83 |
-
|
| 84 |
-
async def body() -> AsyncIterator[bytes]:
|
| 85 |
-
async for chunk in upstream.aiter_raw():
|
| 86 |
-
yield chunk
|
| 87 |
-
|
| 88 |
-
async def close() -> None:
|
| 89 |
-
await upstream.aclose()
|
| 90 |
-
|
| 91 |
-
return StreamingResponse(
|
| 92 |
-
body(),
|
| 93 |
-
status_code=upstream.status_code,
|
| 94 |
-
headers=_forward_headers(upstream.headers),
|
| 95 |
-
media_type=upstream.headers.get("content-type"),
|
| 96 |
-
background=BackgroundTask(close),
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
-
return app
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
app = build_read_gate_app()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/small_cuts/engine/session.py
DELETED
|
@@ -1,452 +0,0 @@
|
|
| 1 |
-
"""Mobile-facing WebSocket session for the narration engine.
|
| 2 |
-
|
| 3 |
-
MomentEnvelope in, ControlFrame + SceneAudio out, per docs/contracts.
|
| 4 |
-
Backpressure is D8 (queue depth <= 1, coalesce-to-newest); freshness is D9
|
| 5 |
-
(`play_by` = created_at + 60 s). Pipeline failures become error frames —
|
| 6 |
-
the socket itself never crashes on a bad moment.
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
from __future__ import annotations
|
| 10 |
-
|
| 11 |
-
import asyncio
|
| 12 |
-
import base64
|
| 13 |
-
import contextlib
|
| 14 |
-
import inspect
|
| 15 |
-
import io
|
| 16 |
-
import json
|
| 17 |
-
import sys
|
| 18 |
-
import time
|
| 19 |
-
import uuid
|
| 20 |
-
import wave
|
| 21 |
-
from collections import OrderedDict
|
| 22 |
-
from collections.abc import Callable
|
| 23 |
-
from dataclasses import dataclass, field
|
| 24 |
-
from datetime import datetime, timedelta, timezone
|
| 25 |
-
from pathlib import Path
|
| 26 |
-
from typing import Any
|
| 27 |
-
|
| 28 |
-
import jsonschema
|
| 29 |
-
import numpy as np
|
| 30 |
-
from fastapi import WebSocket, WebSocketDisconnect
|
| 31 |
-
from PIL import Image
|
| 32 |
-
|
| 33 |
-
from small_cuts import narrator, tts
|
| 34 |
-
from small_cuts.styles import DEFAULT_STYLE_KEY
|
| 35 |
-
|
| 36 |
-
CONTRACT_VERSION = "1.1.0"
|
| 37 |
-
PLAY_BY_SECONDS = 60
|
| 38 |
-
MAX_FRAME_SIDE = 1024 # contract cap: decoded longest side <= 1024 px (moment.schema.json)
|
| 39 |
-
SEEN_MOMENTS_CAP = 4096 # a day of moments is far less
|
| 40 |
-
_CONTRACTS = Path(__file__).resolve().parents[3] / "docs" / "contracts"
|
| 41 |
-
|
| 42 |
-
SceneSink = Callable[[dict[str, Any]], Any]
|
| 43 |
-
"""Receives every successful scene; Task 2 plugs the library/SSE fan-out here."""
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def _validator(name: str) -> jsonschema.Draft202012Validator:
|
| 47 |
-
return jsonschema.Draft202012Validator(json.loads((_CONTRACTS / name).read_text()))
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
_MOMENT = _validator("moment.schema.json")
|
| 51 |
-
_SCENE_AUDIO = _validator("scene-audio.schema.json")
|
| 52 |
-
_BACKGROUND_STORAGE_TASKS: set[asyncio.Task[None]] = set()
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def _noop_sink(scene: dict[str, Any]) -> None:
|
| 56 |
-
return None
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
class MomentIdLRU:
|
| 60 |
-
"""Bounded dedupe set: insertion-ordered, oldest ids evicted past `cap`."""
|
| 61 |
-
|
| 62 |
-
def __init__(self, cap: int = SEEN_MOMENTS_CAP) -> None:
|
| 63 |
-
self._cap = cap
|
| 64 |
-
self._ids: OrderedDict[str, None] = OrderedDict()
|
| 65 |
-
|
| 66 |
-
def __contains__(self, moment_id: object) -> bool:
|
| 67 |
-
return moment_id in self._ids
|
| 68 |
-
|
| 69 |
-
def add(self, moment_id: str) -> None:
|
| 70 |
-
self._ids[moment_id] = None
|
| 71 |
-
self._ids.move_to_end(moment_id)
|
| 72 |
-
while len(self._ids) > self._cap:
|
| 73 |
-
self._ids.popitem(last=False)
|
| 74 |
-
|
| 75 |
-
def discard(self, moment_id: str) -> None:
|
| 76 |
-
self._ids.pop(moment_id, None)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
@dataclass
|
| 80 |
-
class EngineState:
|
| 81 |
-
"""Process-lifetime state shared across session sockets."""
|
| 82 |
-
|
| 83 |
-
sink: SceneSink = _noop_sink
|
| 84 |
-
error_sink: SceneSink | None = None # receives every error ControlFrame (viewer fan-out, D9)
|
| 85 |
-
seen_moment_ids: MomentIdLRU = field(default_factory=MomentIdLRU)
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
@dataclass
|
| 89 |
-
class _Queued:
|
| 90 |
-
envelope: dict[str, Any]
|
| 91 |
-
queued_at: float
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
class _ValidationFailure(Exception):
|
| 95 |
-
"""Post-admission validation failure (undecodable or over-cap frame); never retryable."""
|
| 96 |
-
|
| 97 |
-
def __init__(self, code: str, message: str) -> None:
|
| 98 |
-
super().__init__(message)
|
| 99 |
-
self.code = code
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def _log_worker_failure(task: asyncio.Task) -> None:
|
| 103 |
-
"""A drain-task bug must fail loudly, not strand moments as unretrieved exceptions."""
|
| 104 |
-
if task.cancelled():
|
| 105 |
-
return
|
| 106 |
-
exc = task.exception()
|
| 107 |
-
if exc is not None:
|
| 108 |
-
print(f"small_cuts.engine: session worker task crashed: {exc!r}", file=sys.stderr)
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
def _retain_background_storage(task: asyncio.Task[None]) -> None:
|
| 112 |
-
"""Keep shielded scene storage alive after the client WebSocket is gone."""
|
| 113 |
-
_BACKGROUND_STORAGE_TASKS.add(task)
|
| 114 |
-
task.add_done_callback(_BACKGROUND_STORAGE_TASKS.discard)
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
class SessionRunner:
|
| 118 |
-
"""One connected capture app: admission, the single queue slot, the pipeline."""
|
| 119 |
-
|
| 120 |
-
def __init__(self, ws: WebSocket, state: EngineState) -> None:
|
| 121 |
-
self._ws = ws
|
| 122 |
-
self._state = state
|
| 123 |
-
self._send_lock = asyncio.Lock()
|
| 124 |
-
self._pending: _Queued | None = None
|
| 125 |
-
self._worker: asyncio.Task | None = None
|
| 126 |
-
self._processing = False
|
| 127 |
-
self._last_status: tuple[bool, int] | None = None
|
| 128 |
-
|
| 129 |
-
async def run(self) -> None:
|
| 130 |
-
try:
|
| 131 |
-
while True:
|
| 132 |
-
message = await self._ws.receive()
|
| 133 |
-
if message["type"] == "websocket.disconnect":
|
| 134 |
-
break
|
| 135 |
-
text = message.get("text")
|
| 136 |
-
if text is None: # binary frame: not in the contract, but don't drop the socket
|
| 137 |
-
await self._send_ack(None, "rejected", "binary frames not supported")
|
| 138 |
-
continue
|
| 139 |
-
await self._admit(text)
|
| 140 |
-
except WebSocketDisconnect:
|
| 141 |
-
pass
|
| 142 |
-
finally:
|
| 143 |
-
if self._worker is not None:
|
| 144 |
-
self._worker.cancel()
|
| 145 |
-
|
| 146 |
-
# -- admission (every envelope gets exactly one ack) ----------------------
|
| 147 |
-
|
| 148 |
-
async def _admit(self, raw: str) -> None:
|
| 149 |
-
try:
|
| 150 |
-
envelope = json.loads(raw)
|
| 151 |
-
except json.JSONDecodeError as exc:
|
| 152 |
-
await self._send_ack(None, "rejected", f"invalid JSON: {exc}")
|
| 153 |
-
return
|
| 154 |
-
moment_id = envelope.get("moment_id") if isinstance(envelope, dict) else None
|
| 155 |
-
if not isinstance(moment_id, str):
|
| 156 |
-
moment_id = None
|
| 157 |
-
error = jsonschema.exceptions.best_match(_MOMENT.iter_errors(envelope))
|
| 158 |
-
if error is not None:
|
| 159 |
-
await self._send_ack(moment_id, "rejected", error.message)
|
| 160 |
-
return
|
| 161 |
-
if moment_id in self._state.seen_moment_ids:
|
| 162 |
-
await self._send_ack(moment_id, "duplicate")
|
| 163 |
-
return
|
| 164 |
-
self._state.seen_moment_ids.add(moment_id)
|
| 165 |
-
|
| 166 |
-
queued = _Queued(envelope, time.perf_counter())
|
| 167 |
-
if not self._processing:
|
| 168 |
-
self._processing = True
|
| 169 |
-
await self._send_ack(moment_id, "accepted")
|
| 170 |
-
self._worker = asyncio.create_task(self._drain(queued))
|
| 171 |
-
self._worker.add_done_callback(_log_worker_failure)
|
| 172 |
-
elif self._pending is None:
|
| 173 |
-
self._pending = queued
|
| 174 |
-
await self._send_ack(moment_id, "accepted")
|
| 175 |
-
else: # D8: replace the un-started moment; stale narration is worse than none
|
| 176 |
-
dropped = self._pending
|
| 177 |
-
self._pending = queued
|
| 178 |
-
await self._send_ack(dropped.envelope["moment_id"], "dropped_coalesced")
|
| 179 |
-
await self._send_ack(moment_id, "accepted")
|
| 180 |
-
await self._emit_status()
|
| 181 |
-
|
| 182 |
-
# -- processing ------------------------------------------------------------
|
| 183 |
-
|
| 184 |
-
async def _drain(self, queued: _Queued) -> None:
|
| 185 |
-
current: _Queued | None = queued
|
| 186 |
-
try:
|
| 187 |
-
while current is not None:
|
| 188 |
-
await self._process(current)
|
| 189 |
-
current, self._pending = self._pending, None
|
| 190 |
-
await self._emit_status()
|
| 191 |
-
finally:
|
| 192 |
-
self._processing = False
|
| 193 |
-
await self._emit_status() # skipped on cancellation: the socket is gone
|
| 194 |
-
|
| 195 |
-
async def _process(self, item: _Queued) -> None:
|
| 196 |
-
envelope = item.envelope
|
| 197 |
-
moment_id: str = envelope["moment_id"]
|
| 198 |
-
context = envelope.get("context") or {}
|
| 199 |
-
style_key = context.get("style_key") or DEFAULT_STYLE_KEY
|
| 200 |
-
started = time.perf_counter()
|
| 201 |
-
queue_ms = _ms(started - item.queued_at)
|
| 202 |
-
stage = "narration"
|
| 203 |
-
try:
|
| 204 |
-
image, narration = await asyncio.to_thread(
|
| 205 |
-
_decode_and_narrate,
|
| 206 |
-
envelope,
|
| 207 |
-
style_key,
|
| 208 |
-
context.get("user_hint", ""),
|
| 209 |
-
)
|
| 210 |
-
narration_ms = _ms(time.perf_counter() - started)
|
| 211 |
-
stage = "tts"
|
| 212 |
-
tts_started = time.perf_counter()
|
| 213 |
-
speech = await asyncio.to_thread(tts.speak, narration.text)
|
| 214 |
-
audio_b64 = base64.b64encode(_wav_bytes(speech.audio, speech.sample_rate)).decode()
|
| 215 |
-
tts_ms = _ms(time.perf_counter() - tts_started)
|
| 216 |
-
|
| 217 |
-
stage = "storage" # the outgoing SceneAudio is the engine's stored artifact
|
| 218 |
-
created_at = datetime.now(timezone.utc)
|
| 219 |
-
payload = {
|
| 220 |
-
"contract_version": CONTRACT_VERSION,
|
| 221 |
-
"scene_id": str(uuid.uuid4()),
|
| 222 |
-
"moment_id": moment_id,
|
| 223 |
-
"created_at": created_at.isoformat(),
|
| 224 |
-
"play_by": (created_at + timedelta(seconds=PLAY_BY_SECONDS)).isoformat(),
|
| 225 |
-
"format": "wav_complete",
|
| 226 |
-
"audio_b64": audio_b64,
|
| 227 |
-
"sample_rate": speech.sample_rate,
|
| 228 |
-
"narration": narration.text,
|
| 229 |
-
}
|
| 230 |
-
_SCENE_AUDIO.validate(payload) # outgoing drift becomes an error frame, never silence
|
| 231 |
-
await self._send_json(payload)
|
| 232 |
-
except _ValidationFailure as exc:
|
| 233 |
-
# The resend would fail the same way, but dedupe only what produced a scene.
|
| 234 |
-
self._state.seen_moment_ids.discard(moment_id)
|
| 235 |
-
await self._send_error(moment_id, "validation", exc, code=exc.code, retryable=False)
|
| 236 |
-
return
|
| 237 |
-
except Exception as exc:
|
| 238 |
-
# Drop the id so a client resend is genuinely re-processed (honest retryable).
|
| 239 |
-
self._state.seen_moment_ids.discard(moment_id)
|
| 240 |
-
retryable = stage in ("narration", "tts")
|
| 241 |
-
code = "scene_audio_schema_drift" if stage == "storage" else None
|
| 242 |
-
await self._send_error(moment_id, stage, exc, code=code, retryable=retryable)
|
| 243 |
-
return
|
| 244 |
-
|
| 245 |
-
storage_task = asyncio.create_task(
|
| 246 |
-
self._finish_scene_storage(
|
| 247 |
-
envelope=envelope,
|
| 248 |
-
image=image,
|
| 249 |
-
scene_audio=payload,
|
| 250 |
-
narration_text=narration.text,
|
| 251 |
-
title=narration.title,
|
| 252 |
-
speech=speech,
|
| 253 |
-
style_key=style_key,
|
| 254 |
-
queue_ms=queue_ms,
|
| 255 |
-
narration_ms=narration_ms,
|
| 256 |
-
tts_ms=tts_ms,
|
| 257 |
-
)
|
| 258 |
-
)
|
| 259 |
-
_retain_background_storage(storage_task)
|
| 260 |
-
try:
|
| 261 |
-
await asyncio.shield(storage_task)
|
| 262 |
-
except asyncio.CancelledError:
|
| 263 |
-
storage_task.add_done_callback(_log_worker_failure)
|
| 264 |
-
raise
|
| 265 |
-
|
| 266 |
-
async def _finish_scene_storage(
|
| 267 |
-
self,
|
| 268 |
-
*,
|
| 269 |
-
envelope: dict[str, Any],
|
| 270 |
-
image: Image.Image,
|
| 271 |
-
scene_audio: dict[str, Any],
|
| 272 |
-
narration_text: str,
|
| 273 |
-
title: str,
|
| 274 |
-
speech: tts.Speech,
|
| 275 |
-
style_key: str,
|
| 276 |
-
queue_ms: int,
|
| 277 |
-
narration_ms: int,
|
| 278 |
-
tts_ms: int,
|
| 279 |
-
) -> None:
|
| 280 |
-
clip_frames = await asyncio.to_thread(
|
| 281 |
-
_decode_clip_frames_for_storage, envelope, image, scene_audio["scene_id"]
|
| 282 |
-
)
|
| 283 |
-
await self._hand_to_sink(
|
| 284 |
-
self._state.sink,
|
| 285 |
-
{
|
| 286 |
-
"scene_id": scene_audio["scene_id"],
|
| 287 |
-
"moment_id": envelope["moment_id"],
|
| 288 |
-
"session_id": envelope["session_id"],
|
| 289 |
-
"captured_at": envelope["captured_at"],
|
| 290 |
-
"created_at": scene_audio["created_at"],
|
| 291 |
-
"style_key": style_key,
|
| 292 |
-
"title": title,
|
| 293 |
-
"narration": narration_text,
|
| 294 |
-
"image": image,
|
| 295 |
-
"clip_frames": clip_frames,
|
| 296 |
-
"audio": speech.audio,
|
| 297 |
-
"sample_rate": speech.sample_rate,
|
| 298 |
-
"latency_ms": {
|
| 299 |
-
"queue": queue_ms,
|
| 300 |
-
"narration": narration_ms,
|
| 301 |
-
"tts": tts_ms,
|
| 302 |
-
"total": queue_ms + narration_ms + tts_ms,
|
| 303 |
-
},
|
| 304 |
-
},
|
| 305 |
-
)
|
| 306 |
-
|
| 307 |
-
async def _hand_to_sink(self, sink: SceneSink | None, payload: dict[str, Any]) -> None:
|
| 308 |
-
if sink is None:
|
| 309 |
-
return
|
| 310 |
-
with contextlib.suppress(Exception): # a sink bug must not kill the session
|
| 311 |
-
result = sink(payload)
|
| 312 |
-
if inspect.isawaitable(result):
|
| 313 |
-
await result
|
| 314 |
-
|
| 315 |
-
# -- outbound frames ---------------------------------------------------------
|
| 316 |
-
|
| 317 |
-
async def _send_ack(self, moment_id: str | None, result: str, detail: str = "") -> None:
|
| 318 |
-
ack: dict[str, Any] = {"result": result}
|
| 319 |
-
if detail:
|
| 320 |
-
ack["detail"] = detail[:200]
|
| 321 |
-
await self._send_json(
|
| 322 |
-
{
|
| 323 |
-
"contract_version": CONTRACT_VERSION,
|
| 324 |
-
"kind": "ack",
|
| 325 |
-
"moment_id": moment_id,
|
| 326 |
-
"ack": ack,
|
| 327 |
-
}
|
| 328 |
-
)
|
| 329 |
-
|
| 330 |
-
async def _send_error(
|
| 331 |
-
self,
|
| 332 |
-
moment_id: str,
|
| 333 |
-
stage: str,
|
| 334 |
-
exc: Exception,
|
| 335 |
-
*,
|
| 336 |
-
retryable: bool,
|
| 337 |
-
code: str | None = None,
|
| 338 |
-
) -> None:
|
| 339 |
-
frame = {
|
| 340 |
-
"contract_version": CONTRACT_VERSION,
|
| 341 |
-
"kind": "error",
|
| 342 |
-
"moment_id": moment_id,
|
| 343 |
-
"error": {
|
| 344 |
-
"stage": stage,
|
| 345 |
-
"code": (code or type(exc).__name__)[:60],
|
| 346 |
-
"message": str(exc)[:300],
|
| 347 |
-
"retryable": retryable,
|
| 348 |
-
},
|
| 349 |
-
}
|
| 350 |
-
await self._send_json(frame)
|
| 351 |
-
# D9 honest timeline: the same failure fans out to the viewer stream.
|
| 352 |
-
await self._hand_to_sink(self._state.error_sink, frame)
|
| 353 |
-
|
| 354 |
-
async def _emit_status(self) -> None:
|
| 355 |
-
snapshot = (self._processing, int(self._pending is not None))
|
| 356 |
-
if snapshot == self._last_status:
|
| 357 |
-
return
|
| 358 |
-
self._last_status = snapshot
|
| 359 |
-
await self._send_json(
|
| 360 |
-
{
|
| 361 |
-
"contract_version": CONTRACT_VERSION,
|
| 362 |
-
"kind": "status",
|
| 363 |
-
"moment_id": None,
|
| 364 |
-
"status": {"busy": snapshot[0], "queue_depth": snapshot[1]},
|
| 365 |
-
}
|
| 366 |
-
)
|
| 367 |
-
|
| 368 |
-
async def _send_json(self, payload: dict[str, Any]) -> None:
|
| 369 |
-
text = json.dumps(payload) # serialization bugs must surface, not be swallowed
|
| 370 |
-
async with self._send_lock:
|
| 371 |
-
with contextlib.suppress(Exception): # client gone mid-send; run() closes out
|
| 372 |
-
await self._ws.send_text(text)
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
def _decode_and_narrate(
|
| 376 |
-
envelope: dict[str, Any], style_key: str, scene_hint: str
|
| 377 |
-
) -> tuple[Image.Image, narrator.Narration]:
|
| 378 |
-
"""Decode the selected frame + narrate it in one worker-thread hop."""
|
| 379 |
-
try:
|
| 380 |
-
selected = _decode_frame(envelope["frames"][0])
|
| 381 |
-
_validate_frame_size(selected)
|
| 382 |
-
except _ValidationFailure:
|
| 383 |
-
raise
|
| 384 |
-
except Exception as exc:
|
| 385 |
-
raise _ValidationFailure("frame_decode_failed", f"undecodable frame: {exc}") from exc
|
| 386 |
-
return (
|
| 387 |
-
selected,
|
| 388 |
-
narrator.narrate(selected, style_key=style_key, scene_hint=scene_hint),
|
| 389 |
-
)
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
def _decode_frame(frame: dict[str, Any]) -> Image.Image:
|
| 393 |
-
data = base64.b64decode(frame["jpeg_b64"])
|
| 394 |
-
image = Image.open(io.BytesIO(data))
|
| 395 |
-
image.load()
|
| 396 |
-
return image
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
def _validate_frame_size(image: Image.Image) -> None:
|
| 400 |
-
longest = max(image.size)
|
| 401 |
-
if longest > MAX_FRAME_SIDE:
|
| 402 |
-
raise _ValidationFailure(
|
| 403 |
-
"frame_exceeds_cap",
|
| 404 |
-
f"decoded longest side {longest} px exceeds the {MAX_FRAME_SIDE} px contract cap",
|
| 405 |
-
)
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
def _decode_clip_frames(envelope: dict[str, Any], selected: Image.Image) -> list[Image.Image]:
|
| 409 |
-
decoded: list[tuple[int, int, Image.Image]] = [
|
| 410 |
-
(int(envelope["frames"][0].get("ts_offset_ms", 0)), 0, selected)
|
| 411 |
-
]
|
| 412 |
-
for index, frame in enumerate(envelope["frames"][1:], start=1):
|
| 413 |
-
image = _decode_frame(frame)
|
| 414 |
-
_validate_frame_size(image)
|
| 415 |
-
decoded.append((int(frame.get("ts_offset_ms", index)), index, image))
|
| 416 |
-
return [image for _, _, image in sorted(decoded, key=lambda item: (item[0], item[1]))]
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
def _decode_clip_frames_for_storage(
|
| 420 |
-
envelope: dict[str, Any], selected: Image.Image, scene_id: str
|
| 421 |
-
) -> list[Image.Image]:
|
| 422 |
-
"""Decode viewer-only supplemental frames after SceneAudio is already sent."""
|
| 423 |
-
if len(envelope["frames"]) < 2:
|
| 424 |
-
return [selected]
|
| 425 |
-
try:
|
| 426 |
-
return _decode_clip_frames(envelope, selected)
|
| 427 |
-
except Exception as exc:
|
| 428 |
-
print(
|
| 429 |
-
f"small_cuts.engine: clip frame decode failed for scene {scene_id}: {exc!r}",
|
| 430 |
-
file=sys.stderr,
|
| 431 |
-
)
|
| 432 |
-
return [selected]
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
def _wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
|
| 436 |
-
buffer = io.BytesIO()
|
| 437 |
-
try:
|
| 438 |
-
import soundfile
|
| 439 |
-
|
| 440 |
-
soundfile.write(buffer, audio, sample_rate, format="WAV", subtype="PCM_16")
|
| 441 |
-
except ImportError:
|
| 442 |
-
pcm = (np.clip(audio, -1.0, 1.0) * 32767.0).astype("<i2")
|
| 443 |
-
with wave.open(buffer, "wb") as wav:
|
| 444 |
-
wav.setnchannels(1)
|
| 445 |
-
wav.setsampwidth(2)
|
| 446 |
-
wav.setframerate(sample_rate)
|
| 447 |
-
wav.writeframes(pcm.tobytes())
|
| 448 |
-
return buffer.getvalue()
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
def _ms(seconds: float) -> int:
|
| 452 |
-
return max(0, round(seconds * 1000))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|