macayaven commited on
Commit
899df93
·
verified ·
1 Parent(s): 0b7f54b

Remove unused engine runtime from Space

Browse files
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))