macayaven commited on
Commit
4c26ee0
·
verified ·
1 Parent(s): 2dcc2ca

Mid Cuts v2 read-only viewer (Phase 4 deploy)

Browse files
Files changed (47) hide show
  1. .gitattributes +10 -0
  2. README.md +13 -5
  3. app.py +111 -0
  4. requirements.txt +10 -0
  5. src/small_cuts/CLAUDE.md +91 -0
  6. src/small_cuts/__init__.py +9 -0
  7. src/small_cuts/_icons.py +24 -0
  8. src/small_cuts/demo_seed.py +81 -0
  9. src/small_cuts/engine/CLAUDE.md +39 -0
  10. src/small_cuts/engine/__init__.py +9 -0
  11. src/small_cuts/engine/__main__.py +33 -0
  12. src/small_cuts/engine/app.py +152 -0
  13. src/small_cuts/engine/library.py +377 -0
  14. src/small_cuts/engine/read_gate.py +108 -0
  15. src/small_cuts/engine/session.py +470 -0
  16. src/small_cuts/eval.py +162 -0
  17. src/small_cuts/frames.py +82 -0
  18. src/small_cuts/hf_relay.py +469 -0
  19. src/small_cuts/modal_upload.py +146 -0
  20. src/small_cuts/narrate_v2.py +227 -0
  21. src/small_cuts/narrator.py +491 -0
  22. src/small_cuts/observability.py +74 -0
  23. src/small_cuts/persistence.py +20 -0
  24. src/small_cuts/seed_media/desk-laptop.jpg +0 -0
  25. src/small_cuts/seed_media/desk-laptop.mp3 +3 -0
  26. src/small_cuts/seed_media/desk-laptop.mp4 +3 -0
  27. src/small_cuts/seed_media/night-drive.jpg +0 -0
  28. src/small_cuts/seed_media/night-drive.mp3 +3 -0
  29. src/small_cuts/seed_media/night-drive.mp4 +3 -0
  30. src/small_cuts/seed_media/rayuela.jpg +0 -0
  31. src/small_cuts/seed_media/rayuela.mp3 +3 -0
  32. src/small_cuts/seed_media/rayuela.mp4 +3 -0
  33. src/small_cuts/seed_media/street-parked-car.jpg +0 -0
  34. src/small_cuts/seed_media/street-parked-car.mp3 +3 -0
  35. src/small_cuts/seed_media/street-parked-car.mp4 +3 -0
  36. src/small_cuts/seed_media/the-stumble.jpg +0 -0
  37. src/small_cuts/seed_media/the-stumble.mp3 +3 -0
  38. src/small_cuts/seed_media/the-stumble.mp4 +3 -0
  39. src/small_cuts/space_hooks.py +113 -0
  40. src/small_cuts/styles.py +146 -0
  41. src/small_cuts/theme.py +73 -0
  42. src/small_cuts/title_card.py +183 -0
  43. src/small_cuts/tts.py +133 -0
  44. src/small_cuts/ui.py +126 -0
  45. src/small_cuts/upload_budget.py +322 -0
  46. src/small_cuts/upload_library.py +203 -0
  47. src/small_cuts/viewer.py +0 -0
.gitattributes CHANGED
@@ -33,3 +33,13 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ src/small_cuts/seed_media/desk-laptop.mp3 filter=lfs diff=lfs merge=lfs -text
37
+ src/small_cuts/seed_media/desk-laptop.mp4 filter=lfs diff=lfs merge=lfs -text
38
+ src/small_cuts/seed_media/night-drive.mp3 filter=lfs diff=lfs merge=lfs -text
39
+ src/small_cuts/seed_media/night-drive.mp4 filter=lfs diff=lfs merge=lfs -text
40
+ src/small_cuts/seed_media/rayuela.mp3 filter=lfs diff=lfs merge=lfs -text
41
+ src/small_cuts/seed_media/rayuela.mp4 filter=lfs diff=lfs merge=lfs -text
42
+ src/small_cuts/seed_media/street-parked-car.mp3 filter=lfs diff=lfs merge=lfs -text
43
+ src/small_cuts/seed_media/street-parked-car.mp4 filter=lfs diff=lfs merge=lfs -text
44
+ src/small_cuts/seed_media/the-stumble.mp3 filter=lfs diff=lfs merge=lfs -text
45
+ src/small_cuts/seed_media/the-stumble.mp4 filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,21 @@
1
  ---
2
  title: Mid Cuts
3
- emoji: 🦀
4
  colorFrom: gray
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Mid Cuts
3
+ emoji: 🎬
4
  colorFrom: gray
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.18.0
 
8
  app_file: app.py
9
  pinned: false
10
+ short_description: Voice-narrated video cuts — Small Cuts v2 (read-only viewer)
11
  ---
12
 
13
+ # Mid Cuts Small Cuts v2 (read-only viewer)
14
+
15
+ Whole-clip, voice-only cinematic narration (Qwen3-Omni) played with a near-native
16
+ single-clock player. Scenes are produced by the `/v2/narrate` Modal pipeline, written to a
17
+ private Hugging Face bucket, and pushed here via the relay hook (`/small-cuts/hooks/relay-scene`);
18
+ the browser refreshes once from the `/small-cuts/events` SSE event — no polling.
19
+
20
+ Read-only viewer: media is proxied **same-origin** (Range/206, Safari-seekable) so the private
21
+ bucket is never exposed cross-origin.
app.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Space entrypoint for Small Cuts.
2
+
3
+ Local dev keeps the lazy/mock defaults. On a Space this module refuses unsafe
4
+ CPU local inference and never lets startup failures crash-loop the container:
5
+
6
+ - ``import spaces`` happens before anything touches torch (ZeroGPU hijack).
7
+ - The narrator loads lazily inside the ``@spaces.GPU`` event handler.
8
+ - TTS runs inside @spaces.GPU workers too (kokoro's torch use poisons
9
+ worker forks if it ever runs in the main process).
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import warnings
15
+ from pathlib import Path
16
+
17
+ from starlette.exceptions import StarletteDeprecationWarning
18
+
19
+ ON_SPACE = bool(os.environ.get("SPACE_ID"))
20
+ if ON_SPACE:
21
+ # HF Spaces defaults Gradio SSR on; its Node proxy shadows custom FastAPI SSE routes.
22
+ os.environ["GRADIO_SSR_MODE"] = "False"
23
+
24
+ import gradio as gr # noqa: E402
25
+
26
+ ROOT = Path(__file__).resolve().parent
27
+ SRC = ROOT / "src"
28
+ if str(SRC) not in sys.path:
29
+ sys.path.insert(0, str(SRC))
30
+
31
+ warnings.filterwarnings(
32
+ "ignore",
33
+ message=r".*HTTP_422_UNPROCESSABLE_ENTITY.*HTTP_422_UNPROCESSABLE_CONTENT.*",
34
+ category=StarletteDeprecationWarning,
35
+ )
36
+
37
+ ENGINE_MODE = bool(os.environ.get("SMALL_CUTS_ENGINE_URL", "").strip())
38
+
39
+ from small_cuts.hf_relay import RELAY_BUCKET_ENV # noqa: E402
40
+
41
+ RELAY_MODE = bool(os.environ.get(RELAY_BUCKET_ENV, "").strip())
42
+ MODAL_UPLOAD_MODE = bool(os.environ.get("SMALL_CUTS_MODAL_API_URL", "").strip())
43
+ VIEWER_ONLY_MODE = ENGINE_MODE or RELAY_MODE or MODAL_UPLOAD_MODE
44
+ NEEDS_LOCAL_INFERENCE = not VIEWER_ONLY_MODE
45
+
46
+ try:
47
+ import spaces # noqa: F401 (must precede torch imports for ZeroGPU)
48
+ except ImportError: # local dev / CI: no ZeroGPU
49
+ spaces = None
50
+
51
+ if ON_SPACE and NEEDS_LOCAL_INFERENCE:
52
+ os.environ.setdefault("SMALL_CUTS_BACKEND", "transformers")
53
+ os.environ.setdefault("SMALL_CUTS_TTS_BACKEND", "kokoro")
54
+
55
+ from small_cuts.observability import capture_exception, init_sentry # noqa: E402
56
+ from small_cuts.space_hooks import install_relay_hooks # noqa: E402
57
+ from small_cuts.viewer import THEME, build_viewer_app # noqa: E402
58
+
59
+ init_sentry()
60
+
61
+ STARTUP_ERROR: str | None = None
62
+
63
+
64
+ def _allow_cpu_inference() -> bool:
65
+ return os.environ.get("SMALL_CUTS_ALLOW_CPU_INFERENCE", "").strip().lower() in (
66
+ "1",
67
+ "true",
68
+ "yes",
69
+ )
70
+
71
+
72
+ def _validate_startup_mode() -> None:
73
+ if ON_SPACE and NEEDS_LOCAL_INFERENCE and spaces is None and not _allow_cpu_inference():
74
+ raise RuntimeError(
75
+ "refusing local inference on a Space without ZeroGPU; configure relay, engine, "
76
+ "or Modal upload mode, or set SMALL_CUTS_ALLOW_CPU_INFERENCE=1 explicitly"
77
+ )
78
+
79
+
80
+ def _degraded_app(message: str) -> gr.Blocks:
81
+ with gr.Blocks(title="Small Cuts") as degraded:
82
+ gr.Markdown(
83
+ f"# Small Cuts is temporarily unavailable\n\nStartup configuration failed: `{message}`"
84
+ )
85
+ return degraded
86
+
87
+
88
+ def _build_demo() -> gr.Blocks:
89
+ _validate_startup_mode()
90
+ # In engine/relay/upload modes the Space is a public reader and upload front door, so it must
91
+ # not warm local model weights. In local-inference mode, ZeroGPU loads lazily inside the
92
+ # Gradio handler decorated with @spaces.GPU.
93
+ app = build_viewer_app()
94
+ install_relay_hooks(app.app)
95
+ return app
96
+
97
+
98
+ try:
99
+ demo = _build_demo()
100
+ except Exception as exc:
101
+ capture_exception(exc)
102
+ STARTUP_ERROR = str(exc)
103
+ demo = _degraded_app(STARTUP_ERROR)
104
+
105
+
106
+ def launch_demo():
107
+ return demo.launch(theme=THEME, _app=demo.app)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ launch_demo()
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space runtime for live-demo viewer mode. Gradio + spaces are
2
+ # platform-managed; inference and TTS run on the Mac Studio engine.
3
+ pillow>=10.0
4
+ pillow-heif>=0.18
5
+ av>=12.0
6
+ httpx>=0.27
7
+ huggingface-hub>=1.19
8
+ itsdangerous>=2.2
9
+ sentry-sdk>=2.0
10
+ soundfile>=0.12
src/small_cuts/CLAUDE.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md — `src/small_cuts/` (space / view platform)
2
+
3
+ Module-local notes for the **Gradio Space + viewer + narration/TTS/title-card** code. Global rules,
4
+ the canonical command list, and the architecture live in the **root `CLAUDE.md`** and the **KB**
5
+ (`10-projects/small-cuts/space/` and `…/architecture/`). Don't restate them here.
6
+
7
+ ## What's here
8
+ - `app.py` (repo root) — HF Space entrypoint. `viewer.py` — the streaming-platform viewer.
9
+ - `ui.py` (local dev UI) · `theme.py` (Off-Brand) · `narrator.py` / `tts.py` / `title_card.py` /
10
+ `styles.py` / `frames.py` — the narration pipeline pieces (shared with the engine).
11
+ - `demo_seed.py` + `seed_media/` — the hero library (5 real glasses cuts: mp4 + poster + Kokoro mp3).
12
+ - `_icons.py` — generated CSS icon masks (from `small_cuts_icon_set`); regenerate if the set changes.
13
+
14
+ ## Run
15
+ - See root `CLAUDE.md` → `uv run --no-sync python app.py` (bare `uv run` prunes the `tts` extra).
16
+
17
+ ## Backend selection (env)
18
+ - `SMALL_CUTS_BACKEND` = `mock` (default) | `transformers` (`Qwen/Qwen3-VL-8B-Instruct`) | `llama_cpp`.
19
+ - `SMALL_CUTS_TTS_BACKEND` = `mock` (default) | `kokoro`. `get_backend()` / `get_tts_backend()` cache
20
+ one instance per key — do **not** construct backends per call (re-loads 16 GB on the Space).
21
+
22
+ ## Viewer modes + layout (`viewer.py`)
23
+ - Decided at build time by env:
24
+ - **Pure relay mode**: `SMALL_CUTS_RELAY_BUCKET` set and upload sandbox unset. This is the current
25
+ safe development posture for a personal-profile staging Space (`macayaven/*`): viewer-only,
26
+ CPU Basic, no local model/TTS load, reads a finished-scene manifest + media from a personal HF
27
+ bucket relay.
28
+ - **Hybrid relay + upload mode**: `SMALL_CUTS_RELAY_BUCKET` and
29
+ `SMALL_CUTS_ENABLE_UPLOAD_SANDBOX=1` set. This is the target final submission posture if judges
30
+ need direct upload verification: relay stays the public library, upload calls Modal for real
31
+ narration/TTS on demand, and the submitted Space should remain CPU Basic. Prove this first through
32
+ a personal-profile Space/bucket plus the private Modal app `small-cuts-postcut`; promote to
33
+ `build-small-hackathon/small-cuts-buffer-poc` only after cold/warm timing, bucket artifact
34
+ writing, and playback smoke pass.
35
+ - **Engine mode**: `SMALL_CUTS_ENGINE_URL` set. Polls `GET /v1/scenes` from an engine/read-gate
36
+ endpoint. Keep this as a local/ops mode unless the current readiness doc explicitly switches back.
37
+ - **Upload mode**: neither relay nor engine env set. Local "try it" dropzone; useful for development
38
+ and fallback demos, not the active public relay architecture.
39
+ - **Layout (Review-3 theater):** full-width top bar (Voice-Cut brand mark + upload icon), then a
40
+ two-column **theater** — left: 9:16 stage (ratio is a hard invariant) + display-only progress bar +
41
+ control **pill** (rewind/forward = **clip-to-clip**; custom player controls = **play/pause + volume**;
42
+ like no-count toggle + flag now **inside** the pill); right: the **Library** rail (gallery). Fits one
43
+ viewport — **no main scrollbar**; a `@media (max-width:860px)` query collapses to one column with a
44
+ horizontal gallery rail on mobile. Header = auto-title for finished cuts / **"● Happening now"** for
45
+ live capture, and is the clickable **back-to-live** affordance (the button is hidden, JS-forwarded).
46
+ - **`SMALL_CUTS_SHOW_FEED`** (default off) revives the dropped narrator-chat feed (a future
47
+ "see transcription" surface for non-live clips).
48
+ - **One playback clock (`PLAYBACK_SYNC_JS`):** media is **decoupled** and the **browser owns each
49
+ element's clock** — there is **no `gr.Audio`** (it can't be the clock; wavesurfer leaves its
50
+ `<audio>` unreadable). Narration is a hidden custom `<audio id="sc-voice">` (re-rendered per scene),
51
+ authoritative when present, else the muted looping `<video>`. The gold pill / progress / captions
52
+ are painted from **native media events** (`play`/`pause`/`timeupdate`) — **no `setInterval`, no
53
+ drift correction**; the muted b-roll free-runs. Captions ride `data-sc-cues`/`data-sc-chunks`
54
+ attributes on the subtitle div. **Boots PAUSED.** (Design-of-record: the 2026-06-17 native
55
+ single-clock player rewrite, in the KB.)
56
+
57
+ ## ZeroGPU gotchas (hard-won — see KB `…/space/`)
58
+ - **HF deployment safety override (2026-06-15):** do not deploy this viewer, run upload smokes, or
59
+ write relay artifacts against `build-small-hackathon/*` during development. Use only personal
60
+ `macayaven/*` Spaces and buckets until the product is fully proven. The reserved org submission
61
+ Space is `build-small-hackathon/small-cuts-buffer-poc`, currently private/paused by Carlos, to be
62
+ renamed and made public only at final submission.
63
+ - If a personal Space is paused and restart/rebuild returns `503`, stop all HF Space operations
64
+ immediately. Do not keep polling, restarting, uploading, changing variables/secrets, creating
65
+ replacement Spaces, or fetching logs. Verify locally or directly against Modal until Carlos
66
+ approves one explicit next HF Space action.
67
+ - Current personal dev Space: `macayaven/small-cuts-dev`. It is private, on paid hardware above CPU
68
+ Basic, and has Dev Mode enabled for careful interactive work from Cursor/VS Code. Do not deploy,
69
+ restart, poll, mutate variables/secrets, open Dev Mode sessions, or smoke-test it without one
70
+ explicit approval from Carlos for that exact HF Space action.
71
+ - In relay or engine viewer-only mode, the Space must not warm Qwen/Kokoro and should stay on
72
+ `cpu-basic`; ZeroGPU is only a fallback if Modal is ruled out and the Space itself performs
73
+ narration/TTS.
74
+ - Relay refresh is push-triggered, not timer-polled. The publisher calls
75
+ `/small-cuts/hooks/relay-scene` after a successful bucket sync, and browsers refresh once from
76
+ the `/small-cuts/events` SSE event through a `gr.HTML(js_on_load=...)` custom event bridge.
77
+ Do not reintroduce `gr.Timer` relay polling or hidden-button click bridges.
78
+ - `@spaces.GPU` must mark the functions **Gradio binds** (the startup scan walks event handlers);
79
+ decorating inner helpers → worker dies `No CUDA GPUs are available`.
80
+ - **No torch forward in the main process ever** — TTS runs inside `@spaces.GPU(duration=…)` workers;
81
+ no main-process pre-warm (kokoro poisons worker forks otherwise).
82
+ - Code is hardware-agnostic (`_gpu` no-ops off-Space): dedicated-GPU swap = one
83
+ `hf spaces settings --hardware …`. Iterate: `hf upload …`, `hf spaces variables …`, `hf spaces logs --tail`.
84
+
85
+ ## Design invariant
86
+ - The Space is the **view platform + library**, not the capture path. Publishing/visibility happens
87
+ **only here**, never on the glasses (D10). Make it feel like a reference live-streaming platform.
88
+ - Wearer controls stay product-clean: `Action!` starts a take and `Cut!` finalizes it. Do not add a
89
+ third publish button. Glasses-origin cuts are published from the already-generated local scene
90
+ artifacts after `Cut!` and should show a small glasses badge in the Space; browser-uploaded judge
91
+ cuts use Modal and should not get that badge.
src/small_cuts/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Small Cuts — an omniscient cinematic narrator powered by small open models."""
2
+
3
+ from pillow_heif import register_heif_opener
4
+
5
+ # iPhones shoot HEIC by default; registering once here covers every entry
6
+ # point (Gradio app, eval harness) that opens images through PIL.
7
+ register_heif_opener()
8
+
9
+ __version__ = "0.1.0"
src/small_cuts/_icons.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ruff: noqa: E501
2
+ """Generated icon mask-image CSS (from Carlos's small_cuts_icon_set).
3
+
4
+ Each glyph is a 24x24 SVG rendered as a CSS mask so it inherits `currentColor`
5
+ via `.sc-icbtn` (see VIEWER_CSS). Regenerate from the icon set if it changes.
6
+ """
7
+
8
+ ICON_CSS = """
9
+ :root { --sc-ico-glasses-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-glasses-view%22%20fill%3D%22none%22%3E%20%3Ctitle%20id%3D%22title-glasses-view%22%3EGlasses%20view%3C%2Ftitle%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M3.8%2012.5c.3-2.4%201.6-3.7%203.6-3.7h2.1c1.1%200%201.8.8%201.8%201.8v1.9c0%202-1.5%203.7-3.6%203.7S4%2014.6%203.8%2012.5Z%22%2F%3E%20%3Cpath%20d%3D%22M20.2%2012.5c-.3-2.4-1.6-3.7-3.6-3.7h-2.1c-1.1%200-1.8.8-1.8%201.8v1.9c0%202%201.5%203.7%203.6%203.7s3.7-1.6%203.9-3.7Z%22%2F%3E%20%3Cpath%20d%3D%22M11.3%2011h1.4%22%2F%3E%20%3Cpath%20d%3D%22M4.6%209.9%203%208.7M19.4%209.9%2021%208.7%22%2F%3E%20%3Cpath%20d%3D%22M7.1%2010.4h1.8M15.1%2010.4h1.8%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); }
10
+ :root { --sc-ico-upload-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-upload-video%22%20fill%3D%22none%22%3E%20%3Ctitle%20id%3D%22title-upload-video%22%3EUpload%20video%3C%2Ftitle%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M7.2%2018.5H6.9a4.1%204.1%200%200%201-.8-8.1%205.9%205.9%200%200%201%2011.1-1.8%204.7%204.7%200%200%201%20.8%209.9h-.8%22%2F%3E%20%3Cpath%20d%3D%22M12%2018.5V9.2%22%2F%3E%20%3Cpath%20d%3D%22m8.9%2012.3%203.1-3.1%203.1%203.1%22%2F%3E%20%3Crect%20x%3D%2215.5%22%20y%3D%2215.2%22%20width%3D%224%22%20height%3D%223%22%20rx%3D%22.6%22%2F%3E%20%3Cpath%20d%3D%22m19.5%2016%201.4-.8v3l-1.4-.8%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); }
11
+ .sc-ico-glasses { -webkit-mask-image: var(--sc-ico-glasses-mask); mask-image: var(--sc-ico-glasses-mask); }
12
+ .sc-ico-upload { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-upload-video%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M7.2%2018.5H6.9a4.1%204.1%200%200%201-.8-8.1%205.9%205.9%200%200%201%2011.1-1.8%204.7%204.7%200%200%201%20.8%209.9h-.8%22%2F%3E%20%3Cpath%20d%3D%22M12%2018.5V9.2%22%2F%3E%20%3Cpath%20d%3D%22m8.9%2012.3%203.1-3.1%203.1%203.1%22%2F%3E%20%3Crect%20x%3D%2215.5%22%20y%3D%2215.2%22%20width%3D%224%22%20height%3D%223%22%20rx%3D%22.6%22%2F%3E%20%3Cpath%20d%3D%22m19.5%2016%201.4-.8v3l-1.4-.8%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-upload-video%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M7.2%2018.5H6.9a4.1%204.1%200%200%201-.8-8.1%205.9%205.9%200%200%201%2011.1-1.8%204.7%204.7%200%200%201%20.8%209.9h-.8%22%2F%3E%20%3Cpath%20d%3D%22M12%2018.5V9.2%22%2F%3E%20%3Cpath%20d%3D%22m8.9%2012.3%203.1-3.1%203.1%203.1%22%2F%3E%20%3Crect%20x%3D%2215.5%22%20y%3D%2215.2%22%20width%3D%224%22%20height%3D%223%22%20rx%3D%22.6%22%2F%3E%20%3Cpath%20d%3D%22m19.5%2016%201.4-.8v3l-1.4-.8%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); }
13
+ .sc-ico-rewind { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-rewind%22%20fill%3D%22none%22%3E%20%3Cpath%20d%3D%22M11.2%206.4v11.2c0%20.62-.7.98-1.2.62l-7.2-5.6a.78.78%200%200%201%200-1.24L10%205.78c.5-.36%201.2%200%201.2.62Z%22%20fill%3D%22currentColor%22%2F%3E%20%3Cpath%20d%3D%22M20.2%206.4v11.2c0%20.62-.7.98-1.2.62l-7.2-5.6a.78.78%200%200%201%200-1.24L19%205.78c.5-.36%201.2%200%201.2.62Z%22%20fill%3D%22currentColor%22%2F%3E%20%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-rewind%22%20fill%3D%22none%22%3E%20%3Cpath%20d%3D%22M11.2%206.4v11.2c0%20.62-.7.98-1.2.62l-7.2-5.6a.78.78%200%200%201%200-1.24L10%205.78c.5-.36%201.2%200%201.2.62Z%22%20fill%3D%22currentColor%22%2F%3E%20%3Cpath%20d%3D%22M20.2%206.4v11.2c0%20.62-.7.98-1.2.62l-7.2-5.6a.78.78%200%200%201%200-1.24L19%205.78c.5-.36%201.2%200%201.2.62Z%22%20fill%3D%22currentColor%22%2F%3E%20%3C%2Fsvg%3E"); }
14
+ .sc-ico-forward { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-forward%22%20fill%3D%22none%22%3E%20%3Cpath%20d%3D%22M12.8%206.4v11.2c0%20.62.7.98%201.2.62l7.2-5.6a.78.78%200%200%200%200-1.24L14%205.78c-.5-.36-1.2%200-1.2.62Z%22%20fill%3D%22currentColor%22%2F%3E%20%3Cpath%20d%3D%22M3.8%206.4v11.2c0%20.62.7.98%201.2.62l7.2-5.6a.78.78%200%200%200%200-1.24L5%205.78c-.5-.36-1.2%200-1.2.62Z%22%20fill%3D%22currentColor%22%2F%3E%20%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-forward%22%20fill%3D%22none%22%3E%20%3Cpath%20d%3D%22M12.8%206.4v11.2c0%20.62.7.98%201.2.62l7.2-5.6a.78.78%200%200%200%200-1.24L14%205.78c-.5-.36-1.2%200-1.2.62Z%22%20fill%3D%22currentColor%22%2F%3E%20%3Cpath%20d%3D%22M3.8%206.4v11.2c0%20.62.7.98%201.2.62l7.2-5.6a.78.78%200%200%200%200-1.24L5%205.78c-.5-.36-1.2%200-1.2.62Z%22%20fill%3D%22currentColor%22%2F%3E%20%3C%2Fsvg%3E"); }
15
+ .sc-ico-like { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-like%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M7.2%2011.2v7.3H4.8a1.4%201.4%200%200%201-1.4-1.4v-4.5a1.4%201.4%200%200%201%201.4-1.4h2.4Z%22%2F%3E%20%3Cpath%20d%3D%22M7.2%2011.2%2010.8%205c.42-.72%201.5-.48%201.58.35l.18%201.9a4.2%204.2%200%200%201-.53%202.43l-.75%201.32h5.6a2%202%200%200%201%201.95%202.45l-1%204.2a2.4%202.4%200%200%201-2.34%201.85H7.2%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-like%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M7.2%2011.2v7.3H4.8a1.4%201.4%200%200%201-1.4-1.4v-4.5a1.4%201.4%200%200%201%201.4-1.4h2.4Z%22%2F%3E%20%3Cpath%20d%3D%22M7.2%2011.2%2010.8%205c.42-.72%201.5-.48%201.58.35l.18%201.9a4.2%204.2%200%200%201-.53%202.43l-.75%201.32h5.6a2%202%200%200%201%201.95%202.45l-1%204.2a2.4%202.4%200%200%201-2.34%201.85H7.2%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); }
16
+ .sc-ico-like-filled { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-like-filled%22%20fill%3D%22none%22%3E%20%3Cpath%20d%3D%22M7.2%2011.15v7.35H4.8a1.4%201.4%200%200%201-1.4-1.4v-4.55a1.4%201.4%200%200%201%201.4-1.4h2.4Zm1.2%207.35h6.75a2.45%202.45%200%200%200%202.38-1.88l1-4.15A2.02%202.02%200%200%200%2016.57%2010h-3.64l.18-.33a4.35%204.35%200%200%200%20.55-2.52l-.18-1.87c-.16-1.66-2.32-2.1-3.16-.66L8.4%207.93v10.57Z%22%20fill%3D%22currentColor%22%2F%3E%20%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-like-filled%22%20fill%3D%22none%22%3E%20%3Cpath%20d%3D%22M7.2%2011.15v7.35H4.8a1.4%201.4%200%200%201-1.4-1.4v-4.55a1.4%201.4%200%200%201%201.4-1.4h2.4Zm1.2%207.35h6.75a2.45%202.45%200%200%200%202.38-1.88l1-4.15A2.02%202.02%200%200%200%2016.57%2010h-3.64l.18-.33a4.35%204.35%200%200%200%20.55-2.52l-.18-1.87c-.16-1.66-2.32-2.1-3.16-.66L8.4%207.93v10.57Z%22%20fill%3D%22currentColor%22%2F%3E%20%3C%2Fsvg%3E"); }
17
+ .sc-ico-flag { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-flag%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M6.5%2020V5.2%22%2F%3E%20%3Cpath%20d%3D%22M6.5%205.2c2.7-1.2%205.1.8%207.8-.4%201.2-.5%202.2-.6%203.2-.3v8.4c-1-.3-2-.2-3.2.3-2.7%201.2-5.1-.8-7.8.4%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-flag%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M6.5%2020V5.2%22%2F%3E%20%3Cpath%20d%3D%22M6.5%205.2c2.7-1.2%205.1.8%207.8-.4%201.2-.5%202.2-.6%203.2-.3v8.4c-1-.3-2-.2-3.2.3-2.7%201.2-5.1-.8-7.8.4%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); }
18
+ .sc-ico-flag-filled { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-flag-filled%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M6.5%2020V5.2%22%2F%3E%20%3Cpath%20d%3D%22M6.5%205.2c2.7-1.2%205.1.8%207.8-.4%201.2-.5%202.2-.6%203.2-.3v8.4c-1-.3-2-.2-3.2.3-2.7%201.2-5.1-.8-7.8.4Z%22%20fill%3D%22currentColor%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-flag-filled%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Cpath%20d%3D%22M6.5%2020V5.2%22%2F%3E%20%3Cpath%20d%3D%22M6.5%205.2c2.7-1.2%205.1.8%207.8-.4%201.2-.5%202.2-.6%203.2-.3v8.4c-1-.3-2-.2-3.2.3-2.7%201.2-5.1-.8-7.8.4Z%22%20fill%3D%22currentColor%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); }
19
+ .sc-ico-mark { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-small-cuts-mark%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Crect%20x%3D%225%22%20y%3D%224.5%22%20width%3D%2214%22%20height%3D%2215%22%20rx%3D%222.2%22%2F%3E%20%3Cpath%20d%3D%22M9%204.5v15M15%204.5v15%22%2F%3E%20%3Cpath%20d%3D%22M5%208.5h4M15%208.5h4M5%2015.5h4M15%2015.5h4%22%2F%3E%20%3Cpath%20d%3D%22M8.5%2016.5%2015.8%207.2%22%2F%3E%20%3Cpath%20d%3D%22M10.8%2017.2h4.7%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20role%3D%22img%22%20aria-labelledby%3D%22title-small-cuts-mark%22%20fill%3D%22none%22%3E%20%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%20%3Crect%20x%3D%225%22%20y%3D%224.5%22%20width%3D%2214%22%20height%3D%2215%22%20rx%3D%222.2%22%2F%3E%20%3Cpath%20d%3D%22M9%204.5v15M15%204.5v15%22%2F%3E%20%3Cpath%20d%3D%22M5%208.5h4M15%208.5h4M5%2015.5h4M15%2015.5h4%22%2F%3E%20%3Cpath%20d%3D%22M8.5%2016.5%2015.8%207.2%22%2F%3E%20%3Cpath%20d%3D%22M10.8%2017.2h4.7%22%2F%3E%20%3C%2Fg%3E%20%3C%2Fsvg%3E"); }
20
+ .sc-ico-play { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M8.5%206.7v10.6c0%20.72.78%201.17%201.4.8l8.36-5.3a.95.95%200%200%200%200-1.6L9.9%205.9c-.62-.39-1.4.06-1.4.8Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M8.5%206.7v10.6c0%20.72.78%201.17%201.4.8l8.36-5.3a.95.95%200%200%200%200-1.6L9.9%205.9c-.62-.39-1.4.06-1.4.8Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E"); }
21
+ .sc-ico-pause { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Crect%20x%3D%227%22%20y%3D%225.6%22%20width%3D%223.6%22%20height%3D%2212.8%22%20rx%3D%221.1%22%20fill%3D%22currentColor%22%2F%3E%3Crect%20x%3D%2213.4%22%20y%3D%225.6%22%20width%3D%223.6%22%20height%3D%2212.8%22%20rx%3D%221.1%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Crect%20x%3D%227%22%20y%3D%225.6%22%20width%3D%223.6%22%20height%3D%2212.8%22%20rx%3D%221.1%22%20fill%3D%22currentColor%22%2F%3E%3Crect%20x%3D%2213.4%22%20y%3D%225.6%22%20width%3D%223.6%22%20height%3D%2212.8%22%20rx%3D%221.1%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E"); }
22
+ .sc-ico-volume { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%3Cpath%20d%3D%22M4.5%2014.5h3.1l4.5%203.4V6.1L7.6%209.5H4.5v5Z%22%2F%3E%3Cpath%20d%3D%22M15.2%209.2a4.2%204.2%200%200%201%200%205.6%22%2F%3E%3Cpath%20d%3D%22M17.5%207a7.4%207.4%200%200%201%200%2010%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%3Cpath%20d%3D%22M4.5%2014.5h3.1l4.5%203.4V6.1L7.6%209.5H4.5v5Z%22%2F%3E%3Cpath%20d%3D%22M15.2%209.2a4.2%204.2%200%200%201%200%205.6%22%2F%3E%3Cpath%20d%3D%22M17.5%207a7.4%207.4%200%200%201%200%2010%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); }
23
+ .sc-ico-volume-muted { -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%3Cpath%20d%3D%22M4.5%2014.5h3.1l4.5%203.4V6.1L7.6%209.5H4.5v5Z%22%2F%3E%3Cpath%20d%3D%22m16%209.2%204.2%204.2M20.2%209.2%2016%2013.4%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); mask-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%3E%3Cg%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%3E%3Cpath%20d%3D%22M4.5%2014.5h3.1l4.5%203.4V6.1L7.6%209.5H4.5v5Z%22%2F%3E%3Cpath%20d%3D%22m16%209.2%204.2%204.2M20.2%209.2%2016%2013.4%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); }
24
+ """
src/small_cuts/demo_seed.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seeded 'hero' library — curated VIDEO cuts so the Space loads alive.
2
+
3
+ Real first-person glasses moments, **muted** (the generated narration is the only
4
+ audio, which also strips any incidental conversation), narrated in the one signature
5
+ deadpan voice. The live "Try it" sandbox runs the real model; this seed just gives a
6
+ first-time visitor a channel with a few finished cuts to scroll. Five short, compressed,
7
+ face-free clips so the Space boots fast and respects bystander privacy.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+
14
+ from PIL import Image
15
+
16
+ STYLE_KEY = "deadpan"
17
+ SEED_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "seed_media")
18
+
19
+ # (clip, poster, title, narration, visibility) — ordered oldest → newest.
20
+ SEED: list[tuple[str, str, str, str, str]] = [
21
+ (
22
+ "desk-laptop.mp4",
23
+ "desk-laptop.jpg",
24
+ "Debugging His Own Ambition",
25
+ "It is late — the specific, self-inflicted late of someone building a thing nobody "
26
+ "asked for. On the monitor a small game he is making flickers half-finished, its rules "
27
+ "still being argued into existence. His hands move with the patience of a man debugging "
28
+ "his own ambition, and the coffee, just off-frame, went cold around the last good idea.",
29
+ "private",
30
+ ),
31
+ (
32
+ "the-stumble.mp4",
33
+ "the-stumble.jpg",
34
+ "He Meant to Do That",
35
+ "He has stepped out of the bar for air and the small chemical comfort of the vape, and "
36
+ "the pavement, sensing an opening, tilts very slightly underfoot. He recovers, as one "
37
+ "does, with the careful dignity of a man who would prefer the record to show he meant to "
38
+ "do that. The night says nothing. It has seen steadier, and worse.",
39
+ "private",
40
+ ),
41
+ (
42
+ "street-parked-car.mp4",
43
+ "street-parked-car.jpg",
44
+ "Just Five Minutes",
45
+ "The car is parked with the easy confidence of a driver who said 'just five minutes' and "
46
+ "meant it the way everyone means it. The street is in no hurry to disagree. Somewhere "
47
+ "nearby a meter is running, patient and unread.",
48
+ "public",
49
+ ),
50
+ (
51
+ "night-drive.mp4",
52
+ "night-drive.jpg",
53
+ "Photographs Well at Night",
54
+ "The city slides past at the speed of someone who knows the way and feels no need to "
55
+ "prove it. Streetlight opens and closes on the windshield; the older facades hold their "
56
+ "glow a little longer than the new ones. He is driving through the part of town that "
57
+ "photographs well at night, which is, if one is honest about Barcelona, most of it.",
58
+ "public",
59
+ ),
60
+ (
61
+ "rayuela.mp4",
62
+ "rayuela.jpg",
63
+ "The Stone Almost Never Reaches the Sky",
64
+ "Through the wire, a hopscotch waits on the schoolyard floor, its chalk gone soft with "
65
+ "weather. At the bottom is the Earth; at the top, the Sky — and the old difficulty "
66
+ "between them, that the stone, nudged by the toe of a shoe, almost never reaches the "
67
+ "Sky. He passes on the far side of the fence now, a grown man in grown shoes, and does "
68
+ "not stop. The ingredients were always so small: a sidewalk, a stone, the tip of a shoe.",
69
+ "public",
70
+ ),
71
+ ]
72
+
73
+
74
+ def clip_path(name: str) -> str:
75
+ """Absolute path to a bundled seed clip (served via gr.set_static_paths)."""
76
+ return os.path.join(SEED_DIR, name)
77
+
78
+
79
+ def load_poster(name: str) -> Image.Image:
80
+ """Load a bundled poster frame (the still shown before the clip plays / in the shelf)."""
81
+ return Image.open(os.path.join(SEED_DIR, name)).convert("RGB")
src/small_cuts/engine/CLAUDE.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md — `src/small_cuts/engine/` (inference / home node)
2
+
3
+ Module-local notes for the **real-time home-node engine**. Global rules + canonical commands are in
4
+ the **root `CLAUDE.md`**; the pipeline design + decisions are in the **KB**
5
+ (`10-projects/small-cuts/inference/` and `…/architecture/`). Contract shapes: `docs/contracts/`.
6
+
7
+ ## What's here
8
+ - `app.py` — FastAPI factory. `session.py` — WS `/v1/session` runner (D8 coalesce-to-newest, one
9
+ ack per envelope, honest retries). `library.py` — SceneLibrary (sqlite-WAL + media files, in-proc
10
+ pub/sub, SSE replay). `__main__.py` — uvicorn entry.
11
+ - Endpoints: WS `/v1/session` · `GET /v1/scenes` · SSE `GET /v1/scenes/stream` (Last-Event-ID) ·
12
+ `PATCH /v1/scenes/{id}` (visibility — the viewer's only write) · `GET /media/{scene}/{file}`.
13
+
14
+ ## Run (needs `uv sync --extra engine`)
15
+ ```bash
16
+ SMALL_CUTS_BACKEND=llama_cpp SMALL_CUTS_TTS_BACKEND=kokoro uv run python -m small_cuts.engine # :8077
17
+ SMALL_CUTS_BACKEND=mock uv run python -m small_cuts.engine # smoke
18
+ ```
19
+ - **Warm it first:** cold first moment ≈ 17 s (llama-server spawn + model load); warm e2e ≈ 5.7–6.9 s
20
+ (≤10 s budget). Send one throwaway moment after start.
21
+
22
+ ## Env
23
+ `SMALL_CUTS_ENGINE_HOST`/`_PORT` (127.0.0.1 / 8077; set host explicitly for LAN/Tailnet) ·
24
+ `_LIBRARY_DIR` (`~/.small-cuts/library`) ·
25
+ `_GGUF_PATH`/`_MMPROJ_PATH` · `_LLAMA_SERVER` (binary) · `_LLAMA_URL` (external server, skips spawn) ·
26
+ `_MODEL_ID` · `_TEMPERATURE` (0.3).
27
+
28
+ ## llama.cpp
29
+ - `brew install llama.cpp` → `llama-server` on PATH; spawns lazily on first moment. **Keep the
30
+ `--image-max-tokens 1024` floor** (Qwen-VL grounding; portrait glasses frames OOM Metal without it).
31
+ - CI uses an in-process fake OpenAI server — no real model needed.
32
+
33
+ ## Inspect a running engine
34
+ - Loopback: `http://127.0.0.1:8077/v1/scenes` (JSON) ·
35
+ `curl -N http://127.0.0.1:8077/v1/scenes/stream` (SSE).
36
+ - LAN/Tailnet: set `SMALL_CUTS_ENGINE_HOST=0.0.0.0`, then use the host name, e.g.
37
+ `http://mac-studio:8077/v1/scenes`.
38
+ - Library files live at `~/.small-cuts/library`. Engine validates every frame against
39
+ `docs/contracts/`.
src/small_cuts/engine/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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", "127.0.0.1"),
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 ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 contextlib import asynccontextmanager
14
+ from typing import Annotated, Any, Literal
15
+
16
+ from fastapi import FastAPI, HTTPException, Query, Request, WebSocket
17
+ from fastapi.responses import FileResponse, StreamingResponse
18
+ from pydantic import BaseModel
19
+
20
+ from .library import SceneLibrary
21
+ from .session import EngineState, SceneSink, SessionRunner
22
+
23
+ SSE_HEARTBEAT_S = 15.0
24
+
25
+ Visibility = Literal["private", "shared", "public"]
26
+
27
+
28
+ class VisibilityPatch(BaseModel):
29
+ visibility: Visibility
30
+
31
+
32
+ def _sse_event(event: dict[str, Any]) -> str:
33
+ data = f"data: {json.dumps(event)}\n\n"
34
+ seq = event.get("seq")
35
+ # No seq -> ephemeral event (error frames): no id line, so it never becomes
36
+ # a Last-Event-ID resume cursor and is never expected in replay.
37
+ return data if seq is None else f"id: {seq}\n{data}"
38
+
39
+
40
+ def _last_event_id(raw: str | None) -> int | None:
41
+ """SSE resume cursor; absent or unparsable means a fresh connect, live only."""
42
+ if raw is None:
43
+ return None
44
+ try:
45
+ return int(raw)
46
+ except ValueError:
47
+ return None
48
+
49
+
50
+ async def scene_event_stream(
51
+ library: SceneLibrary, last_event_id: int | None, heartbeat_s: float
52
+ ) -> AsyncIterator[str]:
53
+ """SSE body: replay seq > Last-Event-ID, then live events; pings while idle.
54
+
55
+ Live events without a seq (pipeline errors) pass straight through —
56
+ they are ephemeral and never part of replay.
57
+ """
58
+ queue = library.subscribe()
59
+ try:
60
+ last_seq = -1
61
+ if last_event_id is not None:
62
+ last_seq = last_event_id
63
+ for scene in library.scenes_since(last_event_id):
64
+ last_seq = scene["seq"]
65
+ yield _sse_event(scene)
66
+ while True:
67
+ try:
68
+ event = await asyncio.wait_for(queue.get(), timeout=heartbeat_s)
69
+ except asyncio.TimeoutError:
70
+ yield ": ping\n\n"
71
+ continue
72
+ # Invariant: the library publishes each seq exactly once, so live
73
+ # events never need dedupe against each other. `last_seq` stays
74
+ # frozen at the replay boundary — it only filters scenes that were
75
+ # both replayed and queued (stored before replay, published after
76
+ # subscribe). It must NOT advance here: store() commits seq under
77
+ # the lock in a worker thread but publishes later on the loop, so
78
+ # concurrent sessions can publish out of seq order, and a moving
79
+ # cursor would drop the lower seq forever.
80
+ seq = event.get("seq")
81
+ if seq is not None and seq <= last_seq:
82
+ continue
83
+ yield _sse_event(event)
84
+ finally:
85
+ library.unsubscribe(queue)
86
+
87
+
88
+ def build_engine_app(
89
+ scene_sink: SceneSink | None = None,
90
+ library: SceneLibrary | None = None,
91
+ sse_heartbeat_s: float = SSE_HEARTBEAT_S,
92
+ ) -> FastAPI:
93
+ """Engine app: session socket + scene library + SSE stream, per docs/contracts.
94
+
95
+ By default scenes are persisted to a `SceneLibrary` (root from
96
+ `SMALL_CUTS_LIBRARY_DIR`); pass `library` to inject one, or `scene_sink`
97
+ to replace the sink entirely (tests).
98
+ """
99
+ lib = library if library is not None else SceneLibrary()
100
+ sink = scene_sink if scene_sink is not None else lib
101
+ # Errors fan out to the viewer stream too (D9): the timeline shows failures.
102
+ state = EngineState(sink=sink, error_sink=lib.publish_event)
103
+
104
+ @asynccontextmanager
105
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
106
+ try:
107
+ yield
108
+ finally:
109
+ close = getattr(app.state.library, "close", None)
110
+ if close is not None:
111
+ close()
112
+
113
+ app = FastAPI(title="small-cuts engine", lifespan=lifespan)
114
+ app.state.library = lib
115
+
116
+ @app.websocket("/v1/session")
117
+ async def session(websocket: WebSocket) -> None:
118
+ await websocket.accept()
119
+ await SessionRunner(websocket, state).run()
120
+
121
+ @app.get("/v1/scenes")
122
+ def list_scenes(
123
+ session: str | None = None,
124
+ visibility: Visibility | None = None,
125
+ limit: Annotated[int, Query(ge=1, le=1000)] = 100,
126
+ ) -> dict[str, list[dict[str, Any]]]:
127
+ return {"scenes": lib.list_scenes(session_id=session, visibility=visibility, limit=limit)}
128
+
129
+ @app.get("/v1/scenes/stream")
130
+ async def stream_scenes(request: Request) -> StreamingResponse:
131
+ resume_from = _last_event_id(request.headers.get("last-event-id"))
132
+ return StreamingResponse(
133
+ scene_event_stream(lib, resume_from, sse_heartbeat_s),
134
+ media_type="text/event-stream",
135
+ headers={"Cache-Control": "no-cache"},
136
+ )
137
+
138
+ @app.patch("/v1/scenes/{scene_id}")
139
+ def set_visibility(scene_id: str, patch: VisibilityPatch) -> dict[str, Any]:
140
+ scene = lib.set_visibility(scene_id, patch.visibility)
141
+ if scene is None:
142
+ raise HTTPException(status_code=404, detail=f"unknown scene {scene_id}")
143
+ return scene
144
+
145
+ @app.get("/media/{scene_id}/{filename}")
146
+ def media(scene_id: str, filename: str) -> FileResponse:
147
+ path = lib.media_path(scene_id, filename)
148
+ if path is None: # unknown name, traversal attempt, or missing file
149
+ raise HTTPException(status_code=404, detail="no such media")
150
+ return FileResponse(path)
151
+
152
+ return app
src/small_cuts/engine/library.py ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from PIL import Image
25
+
26
+ from small_cuts import narrator, tts
27
+ from small_cuts.frames import pick_key_frame
28
+ from small_cuts.title_card import TITLE_MAX_LEN, derive_title, render_title_card
29
+
30
+ from .session import CONTRACT_VERSION, _wav_bytes
31
+
32
+ DEFAULT_ROOT = "~/.small-cuts/library"
33
+ OWNER = "carlos" # v1 engines are single-user; the field is reserved for multi-user
34
+ VISIBILITIES = ("private", "shared", "public")
35
+ MEDIA_FILES = ("frame.jpg", "card.webp", "voice.wav", "clip.mp4")
36
+ STORAGE_TIMEOUT_S = 30.0
37
+ SUBSCRIBER_QUEUE_MAX = 256
38
+ CLIP_MP4_FPS = 12
39
+ CLIP_BLEND_STEPS = 1
40
+ H264_MIN_DIMENSION = 2
41
+ POSTER_JPEG_QUALITY = 90
42
+ RGB_MODE = "RGB"
43
+ VIDEO_PIXEL_FORMAT = "yuv420p"
44
+ PRIMARY_VIDEO_CODEC = "libx264"
45
+ FALLBACK_VIDEO_CODEC = "h264"
46
+
47
+ _SCHEMA = """\
48
+ CREATE TABLE IF NOT EXISTS scenes (
49
+ scene_id TEXT PRIMARY KEY,
50
+ seq INTEGER NOT NULL UNIQUE,
51
+ moment_id TEXT NOT NULL,
52
+ session_id TEXT NOT NULL,
53
+ captured_at TEXT NOT NULL,
54
+ created_at TEXT NOT NULL,
55
+ style_key TEXT NOT NULL,
56
+ title TEXT NOT NULL,
57
+ narration TEXT NOT NULL,
58
+ visibility TEXT NOT NULL DEFAULT 'private',
59
+ owner TEXT NOT NULL,
60
+ engine TEXT NOT NULL
61
+ )"""
62
+
63
+ _INSERT = """\
64
+ INSERT INTO scenes (scene_id, seq, moment_id, session_id, captured_at, created_at,
65
+ style_key, title, narration, visibility, owner, engine)
66
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
67
+
68
+
69
+ class SceneLibrary:
70
+ """Scene store + in-process pub/sub. The instance itself is the async SceneSink.
71
+
72
+ Layout: `<root>/library.sqlite3` + `<root>/media/<scene_id>/{frame.jpg,
73
+ card.webp, voice.wav}`. One sqlite connection, guarded by a lock: the
74
+ sink writes from worker threads, queries come from request handlers.
75
+ """
76
+
77
+ def __init__(self, root: str | Path | None = None) -> None:
78
+ base = root or os.environ.get("SMALL_CUTS_LIBRARY_DIR") or DEFAULT_ROOT
79
+ self.root = Path(base).expanduser().resolve()
80
+ self.media_dir = self.root / "media"
81
+ self.media_dir.mkdir(parents=True, exist_ok=True)
82
+ self._lock = threading.Lock() # guards the connection and seq allocation
83
+ self._db = sqlite3.connect(self.root / "library.sqlite3", check_same_thread=False)
84
+ self._db.row_factory = sqlite3.Row
85
+ with self._lock, self._db:
86
+ # WAL + busy_timeout: viewer reads don't block sink writes, and a
87
+ # briefly locked database waits instead of raising immediately.
88
+ self._db.execute("PRAGMA journal_mode=WAL")
89
+ self._db.execute("PRAGMA busy_timeout=5000")
90
+ self._db.execute(_SCHEMA)
91
+ self._subscribers: list[asyncio.Queue[dict[str, Any]]] = []
92
+
93
+ # -- the sink -----------------------------------------------------------------
94
+
95
+ async def __call__(self, scene: dict[str, Any]) -> None:
96
+ """SceneSink entry point: persist off the event loop, then publish.
97
+
98
+ A failed store (disk full, sqlite error) must not be silent data loss:
99
+ the mobile client already received its SceneAudio, so log to stderr and
100
+ fan an error ControlFrame to the viewer stream — the timeline stays
101
+ honest. `_hand_to_sink`'s suppression remains the last-resort backstop.
102
+ """
103
+ try:
104
+ narrated = await asyncio.wait_for(
105
+ asyncio.to_thread(self.store, scene), timeout=STORAGE_TIMEOUT_S
106
+ )
107
+ except (Exception, asyncio.TimeoutError) as exc:
108
+ print(
109
+ f"small_cuts.engine: library write failed for scene {scene['scene_id']}: {exc!r}",
110
+ file=sys.stderr,
111
+ )
112
+ self.publish_event(
113
+ {
114
+ "contract_version": CONTRACT_VERSION,
115
+ "kind": "error",
116
+ "moment_id": scene["moment_id"],
117
+ "error": {
118
+ "stage": "storage",
119
+ "code": "library_write_failed",
120
+ "message": str(exc)[:300],
121
+ "retryable": False,
122
+ },
123
+ }
124
+ )
125
+ return
126
+ self.publish_event(narrated)
127
+
128
+ def publish_event(self, payload: dict[str, Any]) -> None:
129
+ """Fan any event (stored scene or ControlFrame error) to live subscribers.
130
+
131
+ Events without a seq (errors) are EPHEMERAL: not persisted, not in Last-Event-ID replay.
132
+ """
133
+ for queue in list(self._subscribers):
134
+ try:
135
+ queue.put_nowait(payload)
136
+ except asyncio.QueueFull:
137
+ self.unsubscribe(queue)
138
+
139
+ def store(self, scene: dict[str, Any]) -> dict[str, Any]:
140
+ """Persist media + index row (blocking); returns the stored NarratedScene."""
141
+ scene_id: str = scene["scene_id"]
142
+ narration: str = scene["narration"]
143
+ style_key: str = scene["style_key"]
144
+ title = _stored_title(scene.get("title"), narration)
145
+
146
+ scene_dir = self.media_dir / scene_id
147
+ scene_dir.mkdir(parents=True, exist_ok=True)
148
+ clip_frames = scene.get("clip_frames") or []
149
+ poster = pick_key_frame(clip_frames) if clip_frames else scene["image"]
150
+ poster.convert(RGB_MODE).save(scene_dir / "frame.jpg", "JPEG", quality=POSTER_JPEG_QUALITY)
151
+ if len(clip_frames) >= 2:
152
+ try:
153
+ _write_clip_mp4(scene_dir / "clip.mp4", clip_frames)
154
+ except Exception as exc:
155
+ print(
156
+ f"small_cuts.engine: clip write failed for scene {scene_id}: {exc!r}",
157
+ file=sys.stderr,
158
+ )
159
+ render_title_card(title, style_key).save(scene_dir / "card.webp", "WEBP")
160
+ (scene_dir / "voice.wav").write_bytes(_wav_bytes(scene["audio"], scene["sample_rate"]))
161
+
162
+ narrator_backend = narrator.get_backend()
163
+ tts_backend = tts.get_tts_backend()
164
+ engine = {
165
+ "narrator_model": narrator_backend.model_id,
166
+ "narrator_backend": narrator_backend.name,
167
+ "tts_model": tts_backend.model_id,
168
+ "latency_ms": scene["latency_ms"],
169
+ }
170
+ with self._lock, self._db:
171
+ # max+1 under the lock: monotonic across the process AND across restarts.
172
+ seq = self._db.execute("SELECT COALESCE(MAX(seq), -1) + 1 FROM scenes").fetchone()[0]
173
+ self._db.execute(
174
+ _INSERT,
175
+ (
176
+ scene_id,
177
+ seq,
178
+ scene["moment_id"],
179
+ scene["session_id"],
180
+ _normalize_datetime(scene["captured_at"]),
181
+ _normalize_datetime(scene["created_at"]),
182
+ style_key,
183
+ title,
184
+ narration,
185
+ "private",
186
+ _owner(),
187
+ json.dumps(engine),
188
+ ),
189
+ )
190
+ stored = self.get(scene_id)
191
+ if stored is None:
192
+ raise RuntimeError(f"scene {scene_id} missing immediately after insert")
193
+ return stored
194
+
195
+ # -- queries ---------------------------------------------------------------------
196
+
197
+ def to_narrated_scene(self, row: sqlite3.Row) -> dict[str, Any]:
198
+ """Contract-valid NarratedScene (1.1.0) for one stored row."""
199
+ scene_id = row["scene_id"]
200
+ media = {
201
+ "frame_url": f"/media/{scene_id}/frame.jpg",
202
+ "card_url": f"/media/{scene_id}/card.webp",
203
+ "audio_url": f"/media/{scene_id}/voice.wav",
204
+ }
205
+ if (self.media_dir / scene_id / "clip.mp4").is_file():
206
+ media["clip_url"] = f"/media/{scene_id}/clip.mp4"
207
+ return {
208
+ "contract_version": CONTRACT_VERSION,
209
+ "scene_id": scene_id,
210
+ "moment_id": row["moment_id"],
211
+ "session_id": row["session_id"],
212
+ "captured_at": row["captured_at"],
213
+ "created_at": row["created_at"],
214
+ "style_key": row["style_key"],
215
+ "title": row["title"],
216
+ "narration": row["narration"],
217
+ "visibility": row["visibility"],
218
+ "seq": row["seq"],
219
+ "owner": row["owner"],
220
+ "media": media,
221
+ "engine": json.loads(row["engine"]),
222
+ }
223
+
224
+ def list_scenes(
225
+ self,
226
+ session_id: str | None = None,
227
+ visibility: str | None = None,
228
+ limit: int = 100,
229
+ ) -> list[dict[str, Any]]:
230
+ """Newest bounded window, returned in scene chronology for the viewer."""
231
+ clauses, params = [], []
232
+ if session_id is not None:
233
+ clauses.append("session_id = ?")
234
+ params.append(session_id)
235
+ if visibility is not None:
236
+ clauses.append("visibility = ?")
237
+ params.append(visibility)
238
+ where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
239
+ query = (
240
+ "SELECT * FROM ("
241
+ f"SELECT * FROM scenes{where} ORDER BY seq DESC LIMIT ?"
242
+ ") ORDER BY captured_at, seq"
243
+ )
244
+ with self._lock:
245
+ rows = self._db.execute(query, (*params, limit)).fetchall()
246
+ return [self.to_narrated_scene(row) for row in rows]
247
+
248
+ def get(self, scene_id: str) -> dict[str, Any] | None:
249
+ with self._lock:
250
+ row = self._db.execute(
251
+ "SELECT * FROM scenes WHERE scene_id = ?", (scene_id,)
252
+ ).fetchone()
253
+ return self.to_narrated_scene(row) if row is not None else None
254
+
255
+ def set_visibility(self, scene_id: str, visibility: str) -> dict[str, Any] | None:
256
+ """The viewer's only write (D7). Returns the updated scene, or None if unknown."""
257
+ if visibility not in VISIBILITIES:
258
+ raise ValueError(f"Unknown visibility {visibility!r}; expected one of {VISIBILITIES}")
259
+ with self._lock, self._db:
260
+ updated = self._db.execute(
261
+ "UPDATE scenes SET visibility = ? WHERE scene_id = ?", (visibility, scene_id)
262
+ ).rowcount
263
+ return self.get(scene_id) if updated else None
264
+
265
+ def scenes_since(self, seq: int) -> list[dict[str, Any]]:
266
+ """Scenes with seq > `seq`, ordered by seq — the SSE Last-Event-ID replay."""
267
+ with self._lock:
268
+ rows = self._db.execute(
269
+ "SELECT * FROM scenes WHERE seq > ? ORDER BY seq", (seq,)
270
+ ).fetchall()
271
+ return [self.to_narrated_scene(row) for row in rows]
272
+
273
+ def media_path(self, scene_id: str, filename: str) -> Path | None:
274
+ """Resolve a media file, or None: unknown name, traversal, or missing file."""
275
+ if filename not in MEDIA_FILES:
276
+ return None
277
+ path = (self.media_dir / scene_id / filename).resolve()
278
+ if not path.is_relative_to(self.media_dir): # traversal via scene_id
279
+ return None
280
+ return path if path.is_file() else None
281
+
282
+ # -- pub/sub ----------------------------------------------------------------------
283
+
284
+ def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
285
+ """New-scene feed for one SSE connection; pair with `unsubscribe`."""
286
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=SUBSCRIBER_QUEUE_MAX)
287
+ self._subscribers.append(queue)
288
+ return queue
289
+
290
+ def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
291
+ with contextlib.suppress(ValueError):
292
+ self._subscribers.remove(queue)
293
+
294
+ def close(self) -> None:
295
+ with self._lock:
296
+ self._db.close()
297
+
298
+
299
+ def _write_clip_mp4(
300
+ path: Path,
301
+ frames: list[Image.Image],
302
+ fps: int = CLIP_MP4_FPS,
303
+ blend_steps: int = CLIP_BLEND_STEPS,
304
+ ) -> None:
305
+ """Render a small browser-playable MP4 from sampled POV frames."""
306
+ import av
307
+
308
+ rgb_frames = [frame.convert(RGB_MODE) for frame in frames]
309
+ width, height = rgb_frames[0].size
310
+ # H.264/yuv420p expects even dimensions. Preserve portrait aspect and only
311
+ # shave one pixel if needed; capture frames are already downscaled upstream.
312
+ width = max(H264_MIN_DIMENSION, width - (width % 2))
313
+ height = max(H264_MIN_DIMENSION, height - (height % 2))
314
+ encode_frames = _smooth_clip_frames(rgb_frames, blend_steps=blend_steps, size=(width, height))
315
+
316
+ container = av.open(str(path), "w")
317
+ try:
318
+ try:
319
+ stream = container.add_stream(PRIMARY_VIDEO_CODEC, rate=fps)
320
+ except Exception:
321
+ stream = container.add_stream(FALLBACK_VIDEO_CODEC, rate=fps)
322
+ stream.width = width
323
+ stream.height = height
324
+ stream.pix_fmt = VIDEO_PIXEL_FORMAT
325
+
326
+ for image in encode_frames:
327
+ frame = av.VideoFrame.from_image(image)
328
+ for packet in stream.encode(frame):
329
+ container.mux(packet)
330
+ for packet in stream.encode():
331
+ container.mux(packet)
332
+ finally:
333
+ container.close()
334
+
335
+
336
+ def _smooth_clip_frames(
337
+ frames: list[Image.Image],
338
+ blend_steps: int = CLIP_BLEND_STEPS,
339
+ size: tuple[int, int] | None = None,
340
+ ) -> list[Image.Image]:
341
+ """Insert tiny cross-dissolve frames so sampled POV clips do not hard-cut."""
342
+ if not frames:
343
+ return []
344
+ prepared = []
345
+ for image in frames:
346
+ image = image.convert(RGB_MODE)
347
+ if size is not None and image.size != size:
348
+ image = image.resize(size, Image.Resampling.LANCZOS)
349
+ prepared.append(image)
350
+ if blend_steps <= 0 or len(prepared) < 2:
351
+ return prepared
352
+
353
+ smoothed = [prepared[0]]
354
+ for previous, current in zip(prepared, prepared[1:], strict=False):
355
+ for step in range(1, blend_steps + 1):
356
+ alpha = step / (blend_steps + 1)
357
+ smoothed.append(Image.blend(previous, current, alpha))
358
+ smoothed.append(current)
359
+ return smoothed
360
+
361
+
362
+ def _stored_title(raw_title: object, narration: str) -> str:
363
+ if isinstance(raw_title, str) and raw_title.strip():
364
+ return derive_title(raw_title, max_len=TITLE_MAX_LEN)
365
+ return derive_title(narration, max_len=TITLE_MAX_LEN)
366
+
367
+
368
+ def _owner() -> str:
369
+ return os.environ.get("SMALL_CUTS_ENGINE_OWNER", OWNER)
370
+
371
+
372
+ def _normalize_datetime(value: str) -> str:
373
+ raw = value.replace("Z", "+00:00")
374
+ parsed = datetime.fromisoformat(raw)
375
+ if parsed.tzinfo is None:
376
+ raise ValueError(f"timestamp must include timezone: {value}")
377
+ return parsed.astimezone(timezone.utc).isoformat()
src/small_cuts/engine/read_gate.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
17
+ ORIGIN_ENV = "SMALL_CUTS_ORIGIN_ENGINE_URL"
18
+ DEFAULT_ORIGIN = "http://127.0.0.1:8077"
19
+ READ_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=5.0, pool=5.0)
20
+ STREAM_TIMEOUT = httpx.Timeout(connect=5.0, read=None, write=5.0, pool=5.0)
21
+ BLOCKED_TEXT = "small-cuts public endpoint is read-only\n"
22
+ HOP_BY_HOP_HEADERS = {
23
+ "connection",
24
+ "keep-alive",
25
+ "proxy-authenticate",
26
+ "proxy-authorization",
27
+ "te",
28
+ "trailer",
29
+ "transfer-encoding",
30
+ "upgrade",
31
+ }
32
+
33
+
34
+ def is_public_read_allowed(method: str, path: str) -> bool:
35
+ if method.upper() != "GET":
36
+ return False
37
+ return path in ("/v1/scenes", "/v1/scenes/stream") or path.startswith("/media/")
38
+
39
+
40
+ def _forward_headers(headers: httpx.Headers | dict[str, str]) -> dict[str, str]:
41
+ return {
42
+ key: value
43
+ for key, value in headers.items()
44
+ if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host"
45
+ }
46
+
47
+
48
+ def _origin_url(origin_url: str, request: Request) -> str:
49
+ url = f"{origin_url.rstrip('/')}{request.url.path}"
50
+ return f"{url}?{request.url.query}" if request.url.query else url
51
+
52
+
53
+ def _timeout_for_path(path: str) -> httpx.Timeout:
54
+ return STREAM_TIMEOUT if path == "/v1/scenes/stream" else READ_TIMEOUT
55
+
56
+
57
+ async def _proxy_body(upstream: httpx.Response) -> AsyncIterator[bytes]:
58
+ try:
59
+ async for chunk in upstream.aiter_raw():
60
+ yield chunk
61
+ finally:
62
+ await upstream.aclose()
63
+
64
+
65
+ def build_read_gate_app(origin_url: str | None = None) -> FastAPI:
66
+ origin = (origin_url or os.environ.get(ORIGIN_ENV) or DEFAULT_ORIGIN).rstrip("/")
67
+
68
+ @asynccontextmanager
69
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
70
+ app.state.client = httpx.AsyncClient()
71
+ try:
72
+ yield
73
+ finally:
74
+ await app.state.client.aclose()
75
+
76
+ app = FastAPI(title="small-cuts public read gate", lifespan=lifespan)
77
+
78
+ @app.api_route(
79
+ "/{path:path}",
80
+ methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
81
+ response_model=None,
82
+ )
83
+ async def public_gate(path: str, request: Request) -> Response:
84
+ if not is_public_read_allowed(request.method, request.url.path):
85
+ return PlainTextResponse(BLOCKED_TEXT, status_code=403)
86
+
87
+ client: httpx.AsyncClient = request.app.state.client
88
+ upstream = await client.send(
89
+ client.build_request(
90
+ "GET",
91
+ _origin_url(origin, request),
92
+ headers=_forward_headers(request.headers),
93
+ timeout=_timeout_for_path(request.url.path),
94
+ ),
95
+ stream=True,
96
+ )
97
+
98
+ return StreamingResponse(
99
+ _proxy_body(upstream),
100
+ status_code=upstream.status_code,
101
+ headers=_forward_headers(upstream.headers),
102
+ media_type=upstream.headers.get("content-type"),
103
+ )
104
+
105
+ return app
106
+
107
+
108
+ app = build_read_gate_app()
src/small_cuts/engine/session.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 os
18
+ import sys
19
+ import time
20
+ import uuid
21
+ import wave
22
+ from collections import OrderedDict
23
+ from collections.abc import Callable
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime, timedelta, timezone
26
+ from importlib import resources
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import jsonschema
31
+ import numpy as np
32
+ from fastapi import WebSocket, WebSocketDisconnect
33
+ from PIL import Image
34
+
35
+ from small_cuts import narrator, tts
36
+ from small_cuts.styles import DEFAULT_STYLE_KEY
37
+
38
+ CONTRACT_VERSION = "1.1.0"
39
+ PLAY_BY_SECONDS = 60
40
+ MAX_FRAME_SIDE = 1024 # contract cap: decoded longest side <= 1024 px (moment.schema.json)
41
+ SEEN_MOMENTS_CAP = 4096 # a day of moments is far less
42
+ CONTRACTS_DIR_ENV = "SMALL_CUTS_CONTRACTS_DIR"
43
+ _SOURCE_CONTRACTS = Path(__file__).resolve().parents[3] / "docs" / "contracts"
44
+
45
+ SceneSink = Callable[[dict[str, Any]], Any]
46
+ """Receives every successful scene; Task 2 plugs the library/SSE fan-out here."""
47
+
48
+
49
+ def _contract_text(name: str) -> str:
50
+ configured = os.environ.get(CONTRACTS_DIR_ENV)
51
+ if configured:
52
+ return (Path(configured) / name).read_text()
53
+ source_contract = _SOURCE_CONTRACTS / name
54
+ if source_contract.exists():
55
+ return source_contract.read_text()
56
+ return (resources.files("small_cuts") / "contracts" / name).read_text()
57
+
58
+
59
+ def _validator(name: str) -> jsonschema.Draft202012Validator:
60
+ return jsonschema.Draft202012Validator(json.loads(_contract_text(name)))
61
+
62
+
63
+ _MOMENT = _validator("moment.schema.json")
64
+ _SCENE_AUDIO = _validator("scene-audio.schema.json")
65
+ _BACKGROUND_STORAGE_TASKS: set[asyncio.Task[None]] = set()
66
+
67
+
68
+ def _noop_sink(scene: dict[str, Any]) -> None:
69
+ return None
70
+
71
+
72
+ class MomentIdLRU:
73
+ """Bounded dedupe set: insertion-ordered, oldest ids evicted past `cap`."""
74
+
75
+ def __init__(self, cap: int = SEEN_MOMENTS_CAP) -> None:
76
+ self._cap = cap
77
+ self._ids: OrderedDict[str, None] = OrderedDict()
78
+
79
+ def __contains__(self, moment_id: object) -> bool:
80
+ return moment_id in self._ids
81
+
82
+ def add(self, moment_id: str) -> None:
83
+ self._ids[moment_id] = None
84
+ self._ids.move_to_end(moment_id)
85
+ while len(self._ids) > self._cap:
86
+ self._ids.popitem(last=False)
87
+
88
+ def discard(self, moment_id: str) -> None:
89
+ self._ids.pop(moment_id, None)
90
+
91
+
92
+ @dataclass
93
+ class EngineState:
94
+ """Process-lifetime state shared across session sockets."""
95
+
96
+ sink: SceneSink = _noop_sink
97
+ error_sink: SceneSink | None = None # receives every error ControlFrame (viewer fan-out, D9)
98
+ seen_moment_ids: MomentIdLRU = field(default_factory=MomentIdLRU)
99
+
100
+
101
+ @dataclass
102
+ class _Queued:
103
+ envelope: dict[str, Any]
104
+ queued_at: float
105
+
106
+
107
+ class _ValidationFailure(Exception):
108
+ """Post-admission validation failure (undecodable or over-cap frame); never retryable."""
109
+
110
+ def __init__(self, code: str, message: str) -> None:
111
+ super().__init__(message)
112
+ self.code = code
113
+
114
+
115
+ def _log_worker_failure(task: asyncio.Task) -> None:
116
+ """A drain-task bug must fail loudly, not strand moments as unretrieved exceptions."""
117
+ if task.cancelled():
118
+ return
119
+ exc = task.exception()
120
+ if exc is not None:
121
+ print(f"small_cuts.engine: session worker task crashed: {exc!r}", file=sys.stderr)
122
+
123
+
124
+ def _retain_background_storage(task: asyncio.Task[None]) -> None:
125
+ """Keep shielded scene storage alive after the client WebSocket is gone."""
126
+ _BACKGROUND_STORAGE_TASKS.add(task)
127
+ task.add_done_callback(_BACKGROUND_STORAGE_TASKS.discard)
128
+
129
+
130
+ class SessionRunner:
131
+ """One connected capture app: admission, the single queue slot, the pipeline."""
132
+
133
+ def __init__(self, ws: WebSocket, state: EngineState) -> None:
134
+ self._ws = ws
135
+ self._state = state
136
+ self._send_lock = asyncio.Lock()
137
+ self._pending: _Queued | None = None
138
+ self._worker: asyncio.Task | None = None
139
+ self._processing = False
140
+ self._last_status: tuple[bool, int] | None = None
141
+
142
+ async def run(self) -> None:
143
+ try:
144
+ while True:
145
+ message = await self._ws.receive()
146
+ if message["type"] == "websocket.disconnect":
147
+ break
148
+ text = message.get("text")
149
+ if text is None: # binary frame: not in the contract, but don't drop the socket
150
+ await self._send_ack(None, "rejected", "binary frames not supported")
151
+ continue
152
+ await self._admit(text)
153
+ except WebSocketDisconnect:
154
+ pass
155
+ finally:
156
+ if self._worker is not None:
157
+ self._worker.cancel()
158
+
159
+ # -- admission (every envelope gets exactly one ack) ----------------------
160
+
161
+ async def _admit(self, raw: str) -> None:
162
+ try:
163
+ envelope = json.loads(raw)
164
+ except json.JSONDecodeError as exc:
165
+ await self._send_ack(None, "rejected", f"invalid JSON: {exc}")
166
+ return
167
+ moment_id = envelope.get("moment_id") if isinstance(envelope, dict) else None
168
+ if not isinstance(moment_id, str):
169
+ moment_id = None
170
+ error = jsonschema.exceptions.best_match(_MOMENT.iter_errors(envelope))
171
+ if error is not None:
172
+ await self._send_ack(moment_id, "rejected", error.message)
173
+ return
174
+ dedupe_key = _moment_dedupe_key(envelope)
175
+ if dedupe_key in self._state.seen_moment_ids:
176
+ await self._send_ack(moment_id, "duplicate")
177
+ return
178
+ self._state.seen_moment_ids.add(dedupe_key)
179
+
180
+ queued = _Queued(envelope, time.perf_counter())
181
+ if not self._processing:
182
+ self._processing = True
183
+ await self._send_ack(moment_id, "accepted")
184
+ self._worker = asyncio.create_task(self._drain(queued))
185
+ self._worker.add_done_callback(_log_worker_failure)
186
+ elif self._pending is None:
187
+ self._pending = queued
188
+ await self._send_ack(moment_id, "accepted")
189
+ else: # D8: replace the un-started moment; stale narration is worse than none
190
+ dropped = self._pending
191
+ self._pending = queued
192
+ await self._send_ack(dropped.envelope["moment_id"], "dropped_coalesced")
193
+ await self._send_ack(moment_id, "accepted")
194
+ await self._emit_status()
195
+
196
+ # -- processing ------------------------------------------------------------
197
+
198
+ async def _drain(self, queued: _Queued) -> None:
199
+ current: _Queued | None = queued
200
+ try:
201
+ while current is not None:
202
+ await self._process(current)
203
+ current, self._pending = self._pending, None
204
+ await self._emit_status()
205
+ finally:
206
+ self._processing = False
207
+ await self._emit_status() # skipped on cancellation: the socket is gone
208
+
209
+ async def _process(self, item: _Queued) -> None:
210
+ envelope = item.envelope
211
+ moment_id: str = envelope["moment_id"]
212
+ context = envelope.get("context") or {}
213
+ style_key = context.get("style_key") or DEFAULT_STYLE_KEY
214
+ started = time.perf_counter()
215
+ queue_ms = _ms(started - item.queued_at)
216
+ stage = "narration"
217
+ try:
218
+ image, narration = await asyncio.to_thread(
219
+ _decode_and_narrate,
220
+ envelope,
221
+ style_key,
222
+ context.get("user_hint", ""),
223
+ )
224
+ narration_ms = _ms(time.perf_counter() - started)
225
+ stage = "tts"
226
+ tts_started = time.perf_counter()
227
+ speech = await asyncio.to_thread(tts.speak, narration.text)
228
+ audio_b64 = base64.b64encode(_wav_bytes(speech.audio, speech.sample_rate)).decode()
229
+ tts_ms = _ms(time.perf_counter() - tts_started)
230
+
231
+ stage = "storage" # the outgoing SceneAudio is the engine's stored artifact
232
+ created_at = datetime.now(timezone.utc)
233
+ payload = {
234
+ "contract_version": CONTRACT_VERSION,
235
+ "scene_id": str(uuid.uuid4()),
236
+ "moment_id": moment_id,
237
+ "created_at": created_at.isoformat(),
238
+ "play_by": (created_at + timedelta(seconds=PLAY_BY_SECONDS)).isoformat(),
239
+ "format": "wav_complete",
240
+ "audio_b64": audio_b64,
241
+ "sample_rate": speech.sample_rate,
242
+ "narration": narration.text,
243
+ }
244
+ _SCENE_AUDIO.validate(payload) # outgoing drift becomes an error frame, never silence
245
+ await self._send_json(payload)
246
+ except _ValidationFailure as exc:
247
+ # The resend would fail the same way, but dedupe only what produced a scene.
248
+ self._state.seen_moment_ids.discard(_moment_dedupe_key(envelope))
249
+ await self._send_error(moment_id, "validation", exc, code=exc.code, retryable=False)
250
+ return
251
+ except Exception as exc:
252
+ # Drop the id so a client resend is genuinely re-processed (honest retryable).
253
+ self._state.seen_moment_ids.discard(_moment_dedupe_key(envelope))
254
+ retryable = stage in ("narration", "tts")
255
+ code = "scene_audio_schema_drift" if stage == "storage" else None
256
+ await self._send_error(moment_id, stage, exc, code=code, retryable=retryable)
257
+ return
258
+
259
+ storage_task = asyncio.create_task(
260
+ self._finish_scene_storage(
261
+ envelope=envelope,
262
+ image=image,
263
+ scene_audio=payload,
264
+ narration_text=narration.text,
265
+ title=narration.title,
266
+ speech=speech,
267
+ style_key=style_key,
268
+ queue_ms=queue_ms,
269
+ narration_ms=narration_ms,
270
+ tts_ms=tts_ms,
271
+ )
272
+ )
273
+ _retain_background_storage(storage_task)
274
+ try:
275
+ await asyncio.shield(storage_task)
276
+ except asyncio.CancelledError:
277
+ storage_task.add_done_callback(_log_worker_failure)
278
+ raise
279
+
280
+ async def _finish_scene_storage(
281
+ self,
282
+ *,
283
+ envelope: dict[str, Any],
284
+ image: Image.Image,
285
+ scene_audio: dict[str, Any],
286
+ narration_text: str,
287
+ title: str,
288
+ speech: tts.Speech,
289
+ style_key: str,
290
+ queue_ms: int,
291
+ narration_ms: int,
292
+ tts_ms: int,
293
+ ) -> None:
294
+ clip_frames = await asyncio.to_thread(
295
+ _decode_clip_frames_for_storage, envelope, image, scene_audio["scene_id"]
296
+ )
297
+ await self._hand_to_sink(
298
+ self._state.sink,
299
+ {
300
+ "scene_id": scene_audio["scene_id"],
301
+ "moment_id": envelope["moment_id"],
302
+ "session_id": envelope["session_id"],
303
+ "captured_at": envelope["captured_at"],
304
+ "created_at": scene_audio["created_at"],
305
+ "style_key": style_key,
306
+ "title": title,
307
+ "narration": narration_text,
308
+ "image": image,
309
+ "clip_frames": clip_frames,
310
+ "audio": speech.audio,
311
+ "sample_rate": speech.sample_rate,
312
+ "latency_ms": {
313
+ "queue": queue_ms,
314
+ "narration": narration_ms,
315
+ "tts": tts_ms,
316
+ "total": queue_ms + narration_ms + tts_ms,
317
+ },
318
+ },
319
+ )
320
+
321
+ async def _hand_to_sink(self, sink: SceneSink | None, payload: dict[str, Any]) -> None:
322
+ if sink is None:
323
+ return
324
+ with contextlib.suppress(Exception): # a sink bug must not kill the session
325
+ result = sink(payload)
326
+ if inspect.isawaitable(result):
327
+ await result
328
+
329
+ # -- outbound frames ---------------------------------------------------------
330
+
331
+ async def _send_ack(self, moment_id: str | None, result: str, detail: str = "") -> None:
332
+ ack: dict[str, Any] = {"result": result}
333
+ if detail:
334
+ ack["detail"] = detail[:200]
335
+ await self._send_json(
336
+ {
337
+ "contract_version": CONTRACT_VERSION,
338
+ "kind": "ack",
339
+ "moment_id": moment_id,
340
+ "ack": ack,
341
+ }
342
+ )
343
+
344
+ async def _send_error(
345
+ self,
346
+ moment_id: str,
347
+ stage: str,
348
+ exc: Exception,
349
+ *,
350
+ retryable: bool,
351
+ code: str | None = None,
352
+ ) -> None:
353
+ frame = {
354
+ "contract_version": CONTRACT_VERSION,
355
+ "kind": "error",
356
+ "moment_id": moment_id,
357
+ "error": {
358
+ "stage": stage,
359
+ "code": (code or type(exc).__name__)[:60],
360
+ "message": str(exc)[:300],
361
+ "retryable": retryable,
362
+ },
363
+ }
364
+ await self._send_json(frame)
365
+ # D9 honest timeline: the same failure fans out to the viewer stream.
366
+ await self._hand_to_sink(self._state.error_sink, frame)
367
+
368
+ async def _emit_status(self) -> None:
369
+ snapshot = (self._processing, int(self._pending is not None))
370
+ if snapshot == self._last_status:
371
+ return
372
+ self._last_status = snapshot
373
+ await self._send_json(
374
+ {
375
+ "contract_version": CONTRACT_VERSION,
376
+ "kind": "status",
377
+ "moment_id": None,
378
+ "status": {"busy": snapshot[0], "queue_depth": snapshot[1]},
379
+ }
380
+ )
381
+
382
+ async def _send_json(self, payload: dict[str, Any]) -> None:
383
+ text = json.dumps(payload) # serialization bugs must surface, not be swallowed
384
+ async with self._send_lock:
385
+ with contextlib.suppress(Exception): # client gone mid-send; run() closes out
386
+ await self._ws.send_text(text)
387
+
388
+
389
+ def _decode_and_narrate(
390
+ envelope: dict[str, Any], style_key: str, scene_hint: str
391
+ ) -> tuple[Image.Image, narrator.Narration]:
392
+ """Decode the selected frame + narrate it in one worker-thread hop."""
393
+ try:
394
+ selected = _decode_frame(envelope["frames"][0])
395
+ _validate_frame_size(selected)
396
+ except _ValidationFailure:
397
+ raise
398
+ except Exception as exc:
399
+ raise _ValidationFailure("frame_decode_failed", f"undecodable frame: {exc}") from exc
400
+ return (
401
+ selected,
402
+ narrator.narrate(selected, style_key=style_key, scene_hint=scene_hint),
403
+ )
404
+
405
+
406
+ def _decode_frame(frame: dict[str, Any]) -> Image.Image:
407
+ data = base64.b64decode(frame["jpeg_b64"])
408
+ image = Image.open(io.BytesIO(data))
409
+ image.load()
410
+ return image
411
+
412
+
413
+ def _moment_dedupe_key(envelope: dict[str, Any]) -> str:
414
+ return f"{envelope['session_id']}:{envelope['moment_id']}"
415
+
416
+
417
+ def _validate_frame_size(image: Image.Image) -> None:
418
+ longest = max(image.size)
419
+ if longest > MAX_FRAME_SIDE:
420
+ raise _ValidationFailure(
421
+ "frame_exceeds_cap",
422
+ f"decoded longest side {longest} px exceeds the {MAX_FRAME_SIDE} px contract cap",
423
+ )
424
+
425
+
426
+ def _decode_clip_frames(envelope: dict[str, Any], selected: Image.Image) -> list[Image.Image]:
427
+ decoded: list[tuple[int, int, Image.Image]] = [
428
+ (int(envelope["frames"][0].get("ts_offset_ms", 0)), 0, selected)
429
+ ]
430
+ for index, frame in enumerate(envelope["frames"][1:], start=1):
431
+ image = _decode_frame(frame)
432
+ _validate_frame_size(image)
433
+ decoded.append((int(frame.get("ts_offset_ms", index)), index, image))
434
+ return [image for _, _, image in sorted(decoded, key=lambda item: (item[0], item[1]))]
435
+
436
+
437
+ def _decode_clip_frames_for_storage(
438
+ envelope: dict[str, Any], selected: Image.Image, scene_id: str
439
+ ) -> list[Image.Image]:
440
+ """Decode viewer-only supplemental frames after SceneAudio is already sent."""
441
+ if len(envelope["frames"]) < 2:
442
+ return [selected]
443
+ try:
444
+ return _decode_clip_frames(envelope, selected)
445
+ except Exception as exc:
446
+ print(
447
+ f"small_cuts.engine: clip frame decode failed for scene {scene_id}: {exc!r}",
448
+ file=sys.stderr,
449
+ )
450
+ return [selected]
451
+
452
+
453
+ def _wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
454
+ buffer = io.BytesIO()
455
+ try:
456
+ import soundfile
457
+
458
+ soundfile.write(buffer, audio, sample_rate, format="WAV", subtype="PCM_16")
459
+ except ImportError:
460
+ pcm = (np.clip(audio, -1.0, 1.0) * 32767.0).astype("<i2")
461
+ with wave.open(buffer, "wb") as wav:
462
+ wav.setnchannels(1)
463
+ wav.setsampwidth(2)
464
+ wav.setframerate(sample_rate)
465
+ wav.writeframes(pcm.tobytes())
466
+ return buffer.getvalue()
467
+
468
+
469
+ def _ms(seconds: float) -> int:
470
+ return max(0, round(seconds * 1000))
src/small_cuts/eval.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M1 model-evaluation harness: candidate VLMs × images × styles → markdown report.
2
+
3
+ Designed to run on a CUDA box (DGX Spark) so results transfer to ZeroGPU:
4
+
5
+ uv sync --extra local
6
+ uv run python -m small_cuts.eval --images ~/eval-photos --out eval-report.md
7
+
8
+ Smoke test anywhere (no weights):
9
+
10
+ uv run python -m small_cuts.eval --images ~/eval-photos --backend mock
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import tempfile
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from PIL import Image
21
+
22
+ from .narrator import MockBackend, Narration, TransformersBackend, narrate
23
+
24
+ CANDIDATE_MODELS = [
25
+ "HuggingFaceTB/SmolVLM2-2.2B-Instruct",
26
+ "Qwen/Qwen2.5-VL-3B-Instruct",
27
+ "Qwen/Qwen2.5-VL-7B-Instruct",
28
+ "google/gemma-3-4b-it",
29
+ ]
30
+
31
+ EVAL_STYLES = ["deadpan", "noir", "nature_doc"]
32
+
33
+ # .heic/.heif (iPhone default) decode via pillow-heif, registered in small_cuts/__init__.py
34
+ IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}
35
+
36
+ # Real Small Cuts input is video (Ray-Ban / phone clips). When a directory holds
37
+ # videos, we sample frames so the model eval runs on representative stills.
38
+ VIDEO_SUFFIXES = {".mov", ".mp4", ".m4v", ".webm", ".avi", ".mkv"}
39
+
40
+ RUBRIC = (
41
+ "Score each cell 1-5 on: **S**pecificity (names real visible things), "
42
+ "**G**roundedness (no invented objects/people), **V**oice (style lands). "
43
+ "A model needs S>=4 and G>=4 on most images to be the pick."
44
+ )
45
+
46
+
47
+ def _sample_video_frames(
48
+ video: Path,
49
+ every_n_seconds: float = 3.0,
50
+ output_dir: Path | None = None,
51
+ ) -> list[Path]:
52
+ """Extract frames from a video into an output directory; return their paths."""
53
+ from .frames import sample_frames
54
+
55
+ images = sample_frames(video, every_n_seconds=every_n_seconds)
56
+ output = output_dir or Path(tempfile.mkdtemp(prefix="small-cuts-eval-frames-"))
57
+ output.mkdir(parents=True, exist_ok=True)
58
+ out_paths: list[Path] = []
59
+ for i, img in enumerate(images):
60
+ out = output / f"{video.stem}_frame{i:06d}.jpg"
61
+ img.save(out)
62
+ out_paths.append(out)
63
+ return out_paths
64
+
65
+
66
+ def load_images(images_dir: Path, frame_dir: Path | None = None) -> list[Path]:
67
+ if not images_dir.exists():
68
+ raise SystemExit(f"Directory does not exist: {images_dir}")
69
+ entries = sorted(p for p in images_dir.iterdir() if p.is_file())
70
+ paths = [p for p in entries if p.suffix.lower() in IMAGE_SUFFIXES]
71
+ videos = [p for p in entries if p.suffix.lower() in VIDEO_SUFFIXES]
72
+ for video in videos:
73
+ print(f"Sampling frames from {video.name}")
74
+ paths.extend(_sample_video_frames(video, output_dir=frame_dir))
75
+ if not paths:
76
+ listing = "\n".join(f" {p.name}" for p in entries) or " (directory is empty)"
77
+ raise SystemExit(
78
+ f"No images or videos found in {images_dir}.\n"
79
+ f"Directory contains:\n{listing}\n"
80
+ f"Recognized image suffixes: {sorted(IMAGE_SUFFIXES)}\n"
81
+ f"Recognized video suffixes: {sorted(VIDEO_SUFFIXES)}"
82
+ )
83
+ return sorted(paths)
84
+
85
+
86
+ def run_model(
87
+ model_id: str, image_paths: list[Path], styles: list[str], backend_name: str
88
+ ) -> dict[tuple[str, str], Narration]:
89
+ backend = MockBackend() if backend_name == "mock" else TransformersBackend(model_id=model_id)
90
+ results: dict[tuple[str, str], Narration] = {}
91
+ for path in image_paths:
92
+ image = Image.open(path).convert("RGB")
93
+ for style in styles:
94
+ result = narrate(image, style_key=style, backend=backend)
95
+ results[(path.name, style)] = result
96
+ print(f" {model_id} | {path.name} | {style} | {result.latency_s:.1f}s")
97
+ return results
98
+
99
+
100
+ def render_report(
101
+ all_results: dict[str, dict[tuple[str, str], Narration]],
102
+ image_paths: list[Path],
103
+ styles: list[str],
104
+ ) -> str:
105
+ lines = [
106
+ "# Small Cuts — M1 Narrator Model Eval",
107
+ "",
108
+ f"Generated {time.strftime('%Y-%m-%d %H:%M:%S')}.",
109
+ "",
110
+ RUBRIC,
111
+ "",
112
+ ]
113
+ for path in image_paths:
114
+ lines.append(f"## {path.name}")
115
+ lines.append("")
116
+ lines.append("| Model | Style | Narration | Latency | S | G | V |")
117
+ lines.append("|---|---|---|---|---|---|---|")
118
+ for model_id, results in all_results.items():
119
+ for style in styles:
120
+ narration = results.get((path.name, style))
121
+ if narration is None:
122
+ lines.append(f"| {model_id} | {style} | (failed) | - | | | |")
123
+ continue
124
+ text = narration.text.replace("\n", " ").replace("|", "\\|")
125
+ lines.append(
126
+ f"| {model_id} | {style} | {text} | {narration.latency_s:.1f}s | | | |"
127
+ )
128
+ lines.append("")
129
+ return "\n".join(lines)
130
+
131
+
132
+ def main(argv: list[str] | None = None) -> None:
133
+ parser = argparse.ArgumentParser(description=__doc__)
134
+ parser.add_argument("--images", type=Path, required=True, help="Directory of eval photos")
135
+ parser.add_argument("--models", nargs="*", default=CANDIDATE_MODELS)
136
+ parser.add_argument("--styles", nargs="*", default=EVAL_STYLES)
137
+ parser.add_argument("--out", type=Path, default=Path("eval-report.md"))
138
+ parser.add_argument("--backend", choices=["transformers", "mock"], default="transformers")
139
+ args = parser.parse_args(argv)
140
+
141
+ with tempfile.TemporaryDirectory(prefix="small-cuts-eval-frames-") as frame_dir:
142
+ image_paths = load_images(args.images, frame_dir=Path(frame_dir))
143
+ models = args.models if args.backend == "transformers" else ["mock"]
144
+ all_results = {}
145
+ failures = []
146
+ for model_id in models:
147
+ try:
148
+ all_results[model_id] = run_model(model_id, image_paths, args.styles, args.backend)
149
+ except Exception as exc: # one gated/broken model must not kill the eval
150
+ failures.append(f"{model_id}: {type(exc).__name__}: {exc}")
151
+ print(f" FAILED {model_id}: {exc}")
152
+ if not all_results:
153
+ raise SystemExit("All models failed:\n" + "\n".join(failures))
154
+ report = render_report(all_results, image_paths, args.styles)
155
+ if failures:
156
+ report += "\n## Failed models\n\n" + "\n".join(f"- {f}" for f in failures) + "\n"
157
+ args.out.write_text(report)
158
+ print(f"\nReport written to {args.out}")
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
src/small_cuts/frames.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video frame sampling utilities for Small Cuts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from PIL import Image, ImageFilter, ImageStat
8
+
9
+
10
+ def sample_frames(
11
+ path: str | Path,
12
+ every_n_seconds: float = 3.0,
13
+ max_frames: int | None = None,
14
+ ) -> list[Image.Image]:
15
+ """Decode *path* with PyAV and return a list of RGB PIL Images.
16
+
17
+ Every ``int(fps * every_n_seconds)``-th frame is kept (indices 0, step,
18
+ 2*step, …). No files are written. Decoding stops as soon as
19
+ *max_frames* images have been collected (when *max_frames* is not None).
20
+ """
21
+ import av # PyAV — ffmpeg-backed, reliable ARM64 wheels
22
+
23
+ kept: list[Image.Image] = []
24
+ container = av.open(str(path))
25
+ try:
26
+ stream = container.streams.video[0]
27
+ fps = float(stream.average_rate or stream.guessed_rate or 30)
28
+ step = max(1, int(fps * every_n_seconds))
29
+ for i, frame in enumerate(container.decode(stream)):
30
+ if i % step == 0:
31
+ img = frame.to_image().convert("RGB")
32
+ kept.append(img)
33
+ if max_frames is not None and len(kept) >= max_frames:
34
+ break
35
+ finally:
36
+ container.close()
37
+ return kept
38
+
39
+
40
+ def pick_frame(frames: list[Image.Image]) -> Image.Image:
41
+ """Return the middle frame (``frames[len(frames) // 2]``).
42
+
43
+ Raises ``ValueError`` when *frames* is empty.
44
+ """
45
+ if not frames:
46
+ raise ValueError("frames list is empty")
47
+ return frames[len(frames) // 2]
48
+
49
+
50
+ def pick_key_frame(frames: list[Image.Image]) -> Image.Image:
51
+ """Return the most useful library/poster frame from sampled video frames.
52
+
53
+ The score is intentionally deterministic and dependency-light: prefer frames
54
+ that are exposed near mid-brightness, have contrast, and have visible edges.
55
+ A centrality bonus breaks near-ties toward the middle of the clip, which is
56
+ usually more representative than the capture start or the trailing frame.
57
+ """
58
+ if not frames:
59
+ raise ValueError("frames list is empty")
60
+ middle = (len(frames) - 1) / 2
61
+ best_index, _best_score = max(
62
+ enumerate(frames),
63
+ key=lambda item: (_frame_quality(item[1]) + _centrality(item[0], middle), -item[0]),
64
+ )
65
+ return frames[best_index]
66
+
67
+
68
+ def _frame_quality(frame: Image.Image) -> float:
69
+ gray = frame.convert("L")
70
+ gray.thumbnail((160, 160), Image.Resampling.LANCZOS)
71
+ stats = ImageStat.Stat(gray)
72
+ brightness = stats.mean[0] / 255.0
73
+ contrast = min(stats.stddev[0] / 96.0, 1.0)
74
+ exposure = 1.0 - min(abs(brightness - 0.5) / 0.5, 1.0)
75
+ edges = ImageStat.Stat(gray.filter(ImageFilter.FIND_EDGES)).mean[0] / 255.0
76
+ return exposure * 0.45 + contrast * 0.35 + min(edges * 3.0, 1.0) * 0.20
77
+
78
+
79
+ def _centrality(index: int, middle: float) -> float:
80
+ if middle <= 0:
81
+ return 0.0
82
+ return (1.0 - min(abs(index - middle) / middle, 1.0)) * 0.08
src/small_cuts/hf_relay.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face bucket relay for finished Small Cuts scenes.
2
+
3
+ The Space uses this as a read-only scene source. The private engine or a local
4
+ publisher writes finished scene manifests + media into an HF bucket; the Space
5
+ downloads those files into a temp cache and serves them through Gradio.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ import os
13
+ import tempfile
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Protocol
20
+ from urllib.parse import quote, urlparse
21
+
22
+ import httpx
23
+
24
+ from .persistence import bucket_mount_path
25
+
26
+ RELAY_BUCKET_ENV = "SMALL_CUTS_RELAY_BUCKET"
27
+ RELAY_PREFIX_ENV = "SMALL_CUTS_RELAY_PREFIX"
28
+ RELAY_DIRECT_MEDIA_URLS_ENV = "SMALL_CUTS_RELAY_DIRECT_MEDIA_URLS"
29
+ RELAY_READ_TOKEN_ENV = "SMALL_CUTS_RELAY_READ_TOKEN"
30
+ RELAY_BUCKET_PRIVATE_ENV = "SMALL_CUTS_RELAY_BUCKET_PRIVATE"
31
+ DEFAULT_RELAY_PREFIX = "relay"
32
+ RELAY_MANIFEST = "manifest.json"
33
+ RELAY_CACHE_DIR = Path(tempfile.gettempdir()) / "small-cuts-hf-relay"
34
+ GRADIO_FILE_ROUTE = "/gradio_api/file="
35
+ DEFAULT_SCENE_LIMIT = 60
36
+ MEDIA_KEYS = ("frame_url", "card_url", "audio_url", "clip_url")
37
+ SHELF_MEDIA_KEYS = ("frame_url", "card_url")
38
+ PUBLISH_VISIBILITIES = frozenset({"shared", "public"})
39
+ HTTP_TIMEOUT_S = 20.0
40
+ MANIFEST_CACHE_TTL_S = 5.0
41
+ RELAY_CACHE_MAX_BYTES = 512 * 1024 * 1024
42
+
43
+ _MISSING_SHELF_MEDIA_SVG = (
44
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180">'
45
+ '<rect width="320" height="180" fill="#101014"/>'
46
+ '<rect x="20" y="20" width="280" height="140" rx="10" fill="none" '
47
+ 'stroke="#2f2f38" stroke-width="2" stroke-dasharray="8 8"/>'
48
+ '<text x="160" y="92" fill="#d4af37" font-family="monospace" '
49
+ 'font-size="18" text-anchor="middle">ROLLING</text>'
50
+ '<text x="160" y="118" fill="#8a8894" font-family="monospace" '
51
+ 'font-size="12" text-anchor="middle">media still landing</text>'
52
+ "</svg>"
53
+ )
54
+ MISSING_SHELF_MEDIA_PLACEHOLDER = f"data:image/svg+xml,{quote(_MISSING_SHELF_MEDIA_SVG, safe='')}"
55
+
56
+
57
+ class BucketFileSystem(Protocol):
58
+ def cat(self, path: str) -> bytes: ...
59
+
60
+
61
+ class BucketRelayError(RuntimeError):
62
+ """Raised when the bucket relay cannot read or hydrate its manifest."""
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class RelaySnapshot:
67
+ path: Path
68
+ scene_count: int
69
+ manifest_path: Path
70
+
71
+
72
+ def gradio_file_url(path: str | Path) -> str:
73
+ return f"{GRADIO_FILE_ROUTE}{quote(str(path))}"
74
+
75
+
76
+ def _normalize_prefix(prefix: str) -> str:
77
+ return prefix.strip().strip("/")
78
+
79
+
80
+ def _safe_bucket_slug(bucket_id: str) -> str:
81
+ return bucket_id.replace("/", "__")
82
+
83
+
84
+ class BucketSceneClient:
85
+ """Read finished NarratedScene payloads from a Hugging Face bucket manifest."""
86
+
87
+ base_url = ""
88
+ readonly = True
89
+
90
+ def __init__(
91
+ self,
92
+ bucket_id: str,
93
+ *,
94
+ prefix: str = DEFAULT_RELAY_PREFIX,
95
+ fs: BucketFileSystem | None = None,
96
+ cache_dir: str | Path | None = None,
97
+ register_static_paths: Any | None = None,
98
+ manifest_cache_ttl_s: float = MANIFEST_CACHE_TTL_S,
99
+ cache_max_bytes: int = RELAY_CACHE_MAX_BYTES,
100
+ direct_media_urls: bool | None = None,
101
+ ) -> None:
102
+ self.bucket_id = bucket_id.strip()
103
+ if not self.bucket_id:
104
+ raise ValueError("bucket_id is required")
105
+ self.prefix = _normalize_prefix(prefix)
106
+ self.root = f"hf://buckets/{self.bucket_id}"
107
+ if self.prefix:
108
+ self.root = f"{self.root}/{self.prefix}"
109
+ self._fs = fs
110
+ mount = bucket_mount_path()
111
+ self.bucket_mount_path = mount.expanduser().resolve() if mount is not None else None
112
+ self.mounted_root = (
113
+ self.bucket_mount_path / self.prefix
114
+ if self.bucket_mount_path is not None and self.prefix
115
+ else self.bucket_mount_path
116
+ )
117
+ self.cache_dir = (
118
+ Path(cache_dir)
119
+ if cache_dir is not None
120
+ else (RELAY_CACHE_DIR / _safe_bucket_slug(self.bucket_id))
121
+ )
122
+ if cache_dir is None and self.prefix:
123
+ self.cache_dir = self.cache_dir / self.prefix
124
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
125
+ if register_static_paths is not None:
126
+ static_paths = [self.cache_dir]
127
+ if self.bucket_mount_path is not None:
128
+ static_paths.append(self.bucket_mount_path)
129
+ register_static_paths(static_paths)
130
+ self.manifest_cache_ttl_s = manifest_cache_ttl_s
131
+ self.cache_max_bytes = cache_max_bytes
132
+ self.direct_media_urls = (
133
+ _default_direct_media_urls() if direct_media_urls is None else bool(direct_media_urls)
134
+ )
135
+ self._read_token = os.environ.get(RELAY_READ_TOKEN_ENV, "").strip() or None
136
+ self.bucket_private = _env_flag(RELAY_BUCKET_PRIVATE_ENV)
137
+ if self.bucket_private and self.direct_media_urls:
138
+ raise BucketRelayError(
139
+ f"refusing to start: direct-media (resolve) URLs against private bucket "
140
+ f"{self.bucket_id} would 404 for anonymous clients and leak the bucket path "
141
+ f"into client HTML; set {RELAY_DIRECT_MEDIA_URLS_ENV}=0 to serve same-origin"
142
+ )
143
+ self._manifest_lock = threading.Lock()
144
+ self._media_lock = threading.Lock()
145
+ self._manifest_cache: tuple[float, list[dict[str, Any]]] | None = None
146
+ self._prune_cache()
147
+
148
+ @property
149
+ def fs(self) -> BucketFileSystem:
150
+ if self._fs is None:
151
+ from huggingface_hub import HfFileSystem
152
+
153
+ self._fs = HfFileSystem(token=self._read_token)
154
+ return self._fs
155
+
156
+ def list_scenes(self, limit: int = DEFAULT_SCENE_LIMIT) -> list[dict[str, Any]]:
157
+ with self._manifest_lock:
158
+ now = time.monotonic()
159
+ if (
160
+ self._manifest_cache is not None
161
+ and now - self._manifest_cache[0] < self.manifest_cache_ttl_s
162
+ ):
163
+ return copy.deepcopy(self._manifest_cache[1][-limit:])
164
+ try:
165
+ raw = self._read_manifest()
166
+ scenes = self._manifest_scenes(raw) if raw is not None else []
167
+ hydrated = []
168
+ for scene in scenes:
169
+ try:
170
+ hydrated.append(self._hydrate_scene(scene, keys=self._list_media_keys()))
171
+ except FileNotFoundError:
172
+ continue
173
+ uploaded = self._uploaded_scenes()
174
+ hydrated = _merge_bucket_scenes(hydrated, uploaded)
175
+ self._manifest_cache = (now, hydrated)
176
+ return copy.deepcopy(hydrated[-limit:])
177
+ except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
178
+ raise BucketRelayError(
179
+ f"could not read relay bucket {self.bucket_id}: {exc}"
180
+ ) from exc
181
+
182
+ def invalidate_cache(self) -> None:
183
+ """Drop the cached scene list so the next ``list_scenes`` re-reads the bucket.
184
+
185
+ The relay-scene push path calls this so a freshly published cut is visible immediately, even
186
+ within ``MANIFEST_CACHE_TTL_S`` of a prior read; non-push reads keep using the cache.
187
+ """
188
+ with self._manifest_lock:
189
+ self._manifest_cache = None
190
+
191
+ def _manifest_scenes(self, raw: bytes) -> list[dict[str, Any]]:
192
+ manifest = json.loads(raw.decode("utf-8"))
193
+ scenes = manifest.get("scenes", [])
194
+ if not isinstance(scenes, list):
195
+ raise ValueError("relay manifest scenes must be a list")
196
+ return scenes
197
+
198
+ def _read_manifest(self) -> bytes | None:
199
+ local_manifest = self._mounted_file(RELAY_MANIFEST)
200
+ if local_manifest is not None and local_manifest.is_file():
201
+ return local_manifest.read_bytes()
202
+ try:
203
+ return self.fs.cat(f"{self.root}/{RELAY_MANIFEST}")
204
+ except FileNotFoundError:
205
+ return None
206
+
207
+ def _uploaded_scenes(self) -> list[dict[str, Any]]:
208
+ mounted_paths = self._mounted_upload_scene_paths()
209
+ if mounted_paths is not None:
210
+ uploaded = []
211
+ for scene_path in mounted_paths:
212
+ scene = json.loads(scene_path.read_bytes().decode("utf-8"))
213
+ if isinstance(scene, dict):
214
+ uploaded.append(self._hydrate_scene(scene, keys=self._list_media_keys()))
215
+ return uploaded
216
+ glob = getattr(self.fs, "glob", None)
217
+ if glob is None:
218
+ return []
219
+ try:
220
+ scene_paths = glob(f"{self.root}/uploads/*/scene.json")
221
+ except FileNotFoundError:
222
+ return []
223
+ uploaded = []
224
+ for scene_path in sorted(str(path) for path in scene_paths):
225
+ try:
226
+ raw = self.fs.cat(scene_path)
227
+ scene = json.loads(raw.decode("utf-8"))
228
+ if isinstance(scene, dict):
229
+ uploaded.append(self._hydrate_scene(scene, keys=self._list_media_keys()))
230
+ except FileNotFoundError:
231
+ continue
232
+ return uploaded
233
+
234
+ def media_url(self, path: str | None) -> str | None:
235
+ if not path:
236
+ return None
237
+ if path.startswith(("http://", "https://", "data:", GRADIO_FILE_ROUTE)):
238
+ return path
239
+ relative = self._relative_media_path(path)
240
+ if self.direct_media_urls:
241
+ return self._hf_resolve_url(relative)
242
+ mounted = self._mounted_file(relative)
243
+ if mounted is not None and mounted.is_file():
244
+ return gradio_file_url(mounted)
245
+ target = self.cache_dir / relative
246
+ with self._media_lock:
247
+ if not target.exists():
248
+ target.parent.mkdir(parents=True, exist_ok=True)
249
+ tmp = target.with_name(f".{target.name}.{os.getpid()}.{threading.get_ident()}.tmp")
250
+ try:
251
+ tmp.write_bytes(self.fs.cat(f"{self.root}/{relative.as_posix()}"))
252
+ tmp.replace(target)
253
+ finally:
254
+ tmp.unlink(missing_ok=True)
255
+ self._prune_cache(protected=target)
256
+ return gradio_file_url(target)
257
+
258
+ def _hf_resolve_url(self, relative: str | Path) -> str:
259
+ path = Path(relative).as_posix()
260
+ if self.prefix:
261
+ path = f"{self.prefix}/{path}"
262
+ return (
263
+ f"https://huggingface.co/buckets/{quote(self.bucket_id, safe='/')}"
264
+ f"/resolve/{quote(path, safe='/')}"
265
+ )
266
+
267
+ def _hydrate_scene(
268
+ self, scene: dict[str, Any], *, keys: tuple[str, ...] = MEDIA_KEYS
269
+ ) -> dict[str, Any]:
270
+ hydrated = copy.deepcopy(scene)
271
+ media = hydrated.get("media")
272
+ if not isinstance(media, dict):
273
+ hydrated["media"] = {}
274
+ return hydrated
275
+ for key in keys:
276
+ try:
277
+ media[key] = self.media_url(media.get(key))
278
+ except FileNotFoundError:
279
+ media[key] = self._missing_media_url(key)
280
+ return hydrated
281
+
282
+ def _missing_media_url(self, key: str) -> str | None:
283
+ if key in SHELF_MEDIA_KEYS:
284
+ return MISSING_SHELF_MEDIA_PLACEHOLDER
285
+ return None
286
+
287
+ def _list_media_keys(self) -> tuple[str, ...]:
288
+ # A private bucket is proxied entirely same-origin (clip + audio too, not just the
289
+ # shelf thumbnails) so Safari can load and seek the video; existing engine/upload
290
+ # proxy modes keep serving only the shelf keys (media arrives via a mount).
291
+ if self.direct_media_urls or self.bucket_private:
292
+ return MEDIA_KEYS
293
+ return SHELF_MEDIA_KEYS
294
+
295
+ def _relative_media_path(self, path: str) -> Path:
296
+ value = path.strip().lstrip("/")
297
+ if self.prefix and value.startswith(f"{self.prefix}/"):
298
+ value = value[len(self.prefix) + 1 :]
299
+ relative = Path(value)
300
+ if relative.is_absolute() or ".." in relative.parts:
301
+ raise ValueError(f"unsafe bucket media path: {path}")
302
+ return relative
303
+
304
+ def _mounted_file(self, relative: str | Path) -> Path | None:
305
+ if self.mounted_root is None:
306
+ return None
307
+ path = self.mounted_root / Path(relative)
308
+ if path.is_absolute() and self.mounted_root not in [path, *path.parents]:
309
+ raise ValueError(f"unsafe mounted bucket path: {relative}")
310
+ return path
311
+
312
+ def _mounted_upload_scene_paths(self) -> list[Path] | None:
313
+ if self.mounted_root is None or not self.mounted_root.exists():
314
+ return None
315
+ uploads_dir = self.mounted_root / "uploads"
316
+ if not uploads_dir.exists():
317
+ return []
318
+ return sorted(uploads_dir.glob("*/scene.json"))
319
+
320
+ def _prune_cache(self, protected: Path | None = None) -> None:
321
+ if self.cache_max_bytes <= 0 or not self.cache_dir.exists():
322
+ return
323
+ protected_resolved = protected.resolve() if protected is not None else None
324
+ files = [path for path in self.cache_dir.rglob("*") if path.is_file()]
325
+ total = sum(path.stat().st_size for path in files)
326
+ if total <= self.cache_max_bytes:
327
+ return
328
+ for path in sorted(files, key=lambda item: item.stat().st_mtime):
329
+ if protected_resolved is not None and path.resolve() == protected_resolved:
330
+ continue
331
+ size = path.stat().st_size
332
+ path.unlink(missing_ok=True)
333
+ total -= size
334
+ if total <= self.cache_max_bytes:
335
+ break
336
+
337
+
338
+ def prepare_relay_snapshot(
339
+ engine_url: str,
340
+ output_dir: str | Path,
341
+ *,
342
+ limit: int = DEFAULT_SCENE_LIMIT,
343
+ include_private: bool = False,
344
+ source: str | None = None,
345
+ client: httpx.Client | None = None,
346
+ ) -> RelaySnapshot:
347
+ """Stage a bucket-ready manifest + media snapshot from the private engine."""
348
+ base_url = engine_url.rstrip("/")
349
+ output = Path(output_dir)
350
+ media_root = output / "media"
351
+ output.mkdir(parents=True, exist_ok=True)
352
+ media_root.mkdir(parents=True, exist_ok=True)
353
+ close_client = client is None
354
+ http = client or httpx.Client(timeout=HTTP_TIMEOUT_S)
355
+ try:
356
+ response = http.get(f"{base_url}/v1/scenes")
357
+ response.raise_for_status()
358
+ scenes = response.json().get("scenes", [])[-limit:]
359
+ published = [
360
+ _stage_scene_media(base_url, output, scene, http, source=source)
361
+ for scene in scenes
362
+ if _should_publish_scene(scene, include_private=include_private)
363
+ ]
364
+ finally:
365
+ if close_client:
366
+ http.close()
367
+ manifest = {
368
+ "contract_version": "1.1.0",
369
+ "published_at": datetime.now(timezone.utc).isoformat(),
370
+ "source_engine": base_url,
371
+ "scenes": published,
372
+ }
373
+ manifest_path = output / RELAY_MANIFEST
374
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
375
+ return RelaySnapshot(output, len(published), manifest_path)
376
+
377
+
378
+ def _should_publish_scene(scene: dict[str, Any], *, include_private: bool) -> bool:
379
+ if include_private:
380
+ return True
381
+ return scene.get("visibility") in PUBLISH_VISIBILITIES
382
+
383
+
384
+ def _stage_scene_media(
385
+ engine_url: str,
386
+ output_dir: Path,
387
+ scene: dict[str, Any],
388
+ client: httpx.Client,
389
+ *,
390
+ source: str | None = None,
391
+ ) -> dict[str, Any]:
392
+ staged = copy.deepcopy(scene)
393
+ if source:
394
+ staged["source"] = source
395
+ staged["source_icon"] = source
396
+ media = staged.get("media")
397
+ if not isinstance(media, dict):
398
+ staged["media"] = {}
399
+ return staged
400
+ scene_dir = _safe_path_segment(str(staged.get("scene_id") or "scene"))
401
+ for key in MEDIA_KEYS:
402
+ media[key] = _download_media(engine_url, output_dir, scene_dir, media.get(key), client)
403
+ return staged
404
+
405
+
406
+ def _download_media(
407
+ engine_url: str,
408
+ output_dir: Path,
409
+ scene_dir: str,
410
+ url: str | None,
411
+ client: httpx.Client,
412
+ ) -> str | None:
413
+ if not url:
414
+ return None
415
+ if url.startswith(("data:", GRADIO_FILE_ROUTE)):
416
+ return None
417
+ absolute = url if url.startswith(("http://", "https://")) else f"{engine_url}/{url.lstrip('/')}"
418
+ relative = _relay_media_path(url, scene_dir)
419
+ target = output_dir / relative
420
+ target.parent.mkdir(parents=True, exist_ok=True)
421
+ response = client.get(absolute)
422
+ response.raise_for_status()
423
+ target.write_bytes(response.content)
424
+ return relative.as_posix()
425
+
426
+
427
+ def _relay_media_path(url: str, scene_dir: str) -> Path:
428
+ parsed = urlparse(url)
429
+ source_path = (parsed.path if parsed.scheme else url.split("?", 1)[0]).lstrip("/")
430
+ if source_path.startswith("media/"):
431
+ relative = Path(source_path)
432
+ else:
433
+ filename = _safe_path_segment(Path(source_path).name or "media.bin")
434
+ relative = Path("media") / scene_dir / filename
435
+ if relative.is_absolute() or ".." in relative.parts:
436
+ raise ValueError(f"unsafe relay media path: {url}")
437
+ return relative
438
+
439
+
440
+ def _safe_path_segment(value: str) -> str:
441
+ cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "-" for ch in value)
442
+ return cleaned.strip(".-") or "item"
443
+
444
+
445
+ def _merge_bucket_scenes(
446
+ manifest_scenes: list[dict[str, Any]], uploaded_scenes: list[dict[str, Any]]
447
+ ) -> list[dict[str, Any]]:
448
+ by_id: dict[str, dict[str, Any]] = {}
449
+ for scene in [*manifest_scenes, *uploaded_scenes]:
450
+ scene_id = str(scene.get("scene_id") or "")
451
+ if scene_id:
452
+ by_id[scene_id] = scene
453
+ return sorted(
454
+ by_id.values(),
455
+ key=lambda scene: (str(scene.get("created_at") or ""), str(scene.get("scene_id") or "")),
456
+ )
457
+
458
+
459
+ def _env_flag(name: str) -> bool:
460
+ return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
461
+
462
+
463
+ def _default_direct_media_urls() -> bool:
464
+ configured = os.environ.get(RELAY_DIRECT_MEDIA_URLS_ENV)
465
+ if configured is not None and configured.strip():
466
+ return _env_flag(RELAY_DIRECT_MEDIA_URLS_ENV)
467
+ return bool(
468
+ os.environ.get("SPACE_ID", "").strip() and os.environ.get(RELAY_BUCKET_ENV, "").strip()
469
+ )
src/small_cuts/modal_upload.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+
11
+ class ModalUploadError(RuntimeError):
12
+ """Raised when hosted post-cut inference fails."""
13
+
14
+
15
+ DEFAULT_UPLOAD_SOURCE_ID = "public-demo"
16
+ MAX_ERROR_DETAIL_CHARS = 240
17
+
18
+
19
+ def _safe_modal_detail(response: httpx.Response) -> str:
20
+ try:
21
+ payload = response.json()
22
+ except ValueError:
23
+ return _truncate_detail(response.text.strip() or response.reason_phrase)
24
+
25
+ detail = payload.get("detail") if isinstance(payload, dict) else payload
26
+ return _truncate_detail(_normalize_modal_detail(detail) or response.reason_phrase)
27
+
28
+
29
+ def _normalize_modal_detail(detail: Any) -> str:
30
+ if isinstance(detail, str):
31
+ return detail
32
+ if isinstance(detail, dict):
33
+ for key in ("msg", "message", "error"):
34
+ value = detail.get(key)
35
+ if isinstance(value, str) and value:
36
+ return value
37
+ return str(detail)
38
+ if isinstance(detail, list):
39
+ messages = [_normalize_modal_detail(item) for item in detail[:3]]
40
+ return "; ".join(message for message in messages if message)
41
+ return str(detail) if detail is not None else ""
42
+
43
+
44
+ def _truncate_detail(detail: str) -> str:
45
+ normalized = " ".join(detail.split())
46
+ if len(normalized) <= MAX_ERROR_DETAIL_CHARS:
47
+ return normalized
48
+ return f"{normalized[: MAX_ERROR_DETAIL_CHARS - 1]}..."
49
+
50
+
51
+ def _raise_for_modal_status(response: httpx.Response, action: str) -> None:
52
+ try:
53
+ response.raise_for_status()
54
+ except httpx.HTTPStatusError:
55
+ detail = _safe_modal_detail(response)
56
+ raise ModalUploadError(
57
+ f"Modal upload {action} failed ({response.status_code}): {detail}"
58
+ ) from None
59
+
60
+
61
+ @dataclass
62
+ class ModalUploadClient:
63
+ base_url: str
64
+ token: str
65
+ http_client: httpx.Client | None = None
66
+ poll_interval_s: float = 1.0
67
+ timeout_s: float = 900.0
68
+
69
+ def submit_video(
70
+ self,
71
+ video_path: str | Path,
72
+ *,
73
+ style_key: str = "deadpan",
74
+ scene_hint: str = "",
75
+ ) -> dict[str, Any]:
76
+ close = self.http_client is None
77
+ client = self.http_client or httpx.Client(timeout=30.0, follow_redirects=True)
78
+ try:
79
+ try:
80
+ job_id = self._submit(
81
+ client,
82
+ Path(video_path),
83
+ DEFAULT_UPLOAD_SOURCE_ID,
84
+ style_key,
85
+ scene_hint,
86
+ )
87
+ except ModalUploadError:
88
+ raise
89
+ except httpx.HTTPError as exc:
90
+ raise ModalUploadError(
91
+ f"Modal upload request failed: {type(exc).__name__}"
92
+ ) from None
93
+ try:
94
+ return self._poll(client, job_id)
95
+ except ModalUploadError:
96
+ raise
97
+ except httpx.HTTPError as exc:
98
+ raise ModalUploadError(
99
+ f"Modal upload status failed: {type(exc).__name__}"
100
+ ) from None
101
+ finally:
102
+ if close:
103
+ client.close()
104
+
105
+ def _submit(
106
+ self,
107
+ client: httpx.Client,
108
+ video_path: Path,
109
+ source_id: str,
110
+ style_key: str,
111
+ scene_hint: str,
112
+ ) -> str:
113
+ with video_path.open("rb") as handle:
114
+ response = client.post(
115
+ f"{self.base_url.rstrip('/')}/v1/cuts",
116
+ headers={"Authorization": f"Bearer {self.token}"},
117
+ data={
118
+ "style_key": style_key,
119
+ "scene_hint": scene_hint,
120
+ "uploader_id": source_id,
121
+ },
122
+ files={"video": (video_path.name, handle, "video/mp4")},
123
+ )
124
+ _raise_for_modal_status(response, "request")
125
+ job_id = response.json().get("job_id")
126
+ if not isinstance(job_id, str) or not job_id:
127
+ raise ModalUploadError("Modal did not return a job_id")
128
+ return job_id
129
+
130
+ def _poll(self, client: httpx.Client, job_id: str) -> dict[str, Any]:
131
+ deadline = time.monotonic() + self.timeout_s
132
+ while time.monotonic() < deadline:
133
+ response = client.get(
134
+ f"{self.base_url.rstrip('/')}/v1/cuts/{job_id}",
135
+ headers={"Authorization": f"Bearer {self.token}"},
136
+ )
137
+ if response.status_code == 202:
138
+ time.sleep(self.poll_interval_s)
139
+ continue
140
+ _raise_for_modal_status(response, "status")
141
+ payload = response.json()
142
+ scene = payload.get("scene")
143
+ if not isinstance(scene, dict):
144
+ raise ModalUploadError("Modal completed without a scene payload")
145
+ return scene
146
+ raise ModalUploadError("Modal upload timed out")
src/small_cuts/narrate_v2.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Greenfield narration writer for the v2 ``/v2/narrate`` pipeline (Mid Cuts).
2
+
3
+ This is the importable, GPU-free core that the Modal app depends on: build a contract-valid
4
+ ``NarratedScene`` and publish it to the private ``macayaven/mid-cuts`` bucket with atomic
5
+ ordering. Keeping it here (not in ``modal_app/``) makes it unit-testable — ``modal`` is not in
6
+ the CI venv. The model-bearing Omni backend lives in the Modal app; this module only defines the
7
+ swappable backend interface plus a GPU-free mock.
8
+
9
+ Fixes baked in (DESIGN §7): real ``uuid`` ``scene_id`` (#4); no schema-violating top-level keys,
10
+ provenance under ``engine{}`` (#3); media uploaded before ``scene.json`` (#6). The write token is
11
+ the caller's concern — it passes an ``uploader`` bound to ``HfApi(token=WRITE_TOKEN)`` (#1).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import sys
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any, Protocol
23
+ from uuid import uuid4
24
+
25
+ CONTRACT_VERSION = "1.2.0"
26
+ TITLE_MAX = 80
27
+ NARRATION_MAX = 2000
28
+ RELAY_HOOK_TIMEOUT_S = 5.0
29
+
30
+ # (local file, remote bucket-relative path) -> None. The Modal app binds this to a token-scoped
31
+ # bucket writer; tests bind it to a recorder.
32
+ Uploader = Callable[[Path, str], None]
33
+
34
+
35
+ def build_narrated_scene(
36
+ *,
37
+ narration: str,
38
+ title: str,
39
+ style_key: str,
40
+ media: dict[str, str],
41
+ captured_at: str,
42
+ created_at: str,
43
+ session_id: str = "upload",
44
+ seq: int = 0,
45
+ visibility: str = "public",
46
+ engine: dict[str, Any] | None = None,
47
+ timed_captions: list[dict[str, Any]] | None = None,
48
+ duration: float | None = None,
49
+ keyframe_time: float | None = None,
50
+ scene_id: str | None = None,
51
+ moment_id: str | None = None,
52
+ ) -> dict[str, Any]:
53
+ """Build a NarratedScene that validates against narrated-scene.schema.json by construction.
54
+
55
+ Only schema keys are emitted (``additionalProperties: false``); provenance goes under
56
+ ``engine{}``. ``scene_id``/``moment_id`` default to real uuids. ``duration`` is the playback
57
+ (narration-audio) length in seconds; ``keyframe_time`` is the poster frame's offset in the clip.
58
+ """
59
+ scene: dict[str, Any] = {
60
+ "contract_version": CONTRACT_VERSION,
61
+ "scene_id": scene_id or str(uuid4()),
62
+ "moment_id": moment_id or str(uuid4()),
63
+ "session_id": session_id,
64
+ "seq": seq,
65
+ "captured_at": captured_at,
66
+ "created_at": created_at,
67
+ "style_key": style_key,
68
+ "title": title[:TITLE_MAX],
69
+ "narration": narration[:NARRATION_MAX],
70
+ "visibility": visibility,
71
+ "media": media,
72
+ }
73
+ if engine is not None:
74
+ scene["engine"] = engine
75
+ if duration is not None:
76
+ scene["duration"] = duration
77
+ if keyframe_time is not None:
78
+ scene["keyframe_time"] = keyframe_time
79
+ if timed_captions is not None:
80
+ scene["timed_captions"] = timed_captions
81
+ return scene
82
+
83
+
84
+ def _norm(text: str) -> str:
85
+ return re.sub(r"[^0-9a-záéíóúñü]", "", text.lower())
86
+
87
+
88
+ def carrier_cut_index(words: list[dict[str, Any]], carrier: str) -> tuple[float, int]:
89
+ """Find where the spoken warm-up carrier ends in the aligned word list.
90
+
91
+ Accumulates normalized characters of the aligned words until they cover the carrier's
92
+ normalized length; returns (carrier_end_time, last_carrier_word_index). Robust to the
93
+ aligner's word/punctuation segmentation and to minor paraphrase (do_sample varies duration).
94
+ """
95
+ target = _norm(carrier)
96
+ accumulated = ""
97
+ for index, word in enumerate(words):
98
+ accumulated += _norm(word["word"])
99
+ if len(accumulated) >= len(target):
100
+ return float(word["t_end"]), index
101
+ return (float(words[-1]["t_end"]), len(words) - 1) if words else (0.0, -1)
102
+
103
+
104
+ def cues_from_words(
105
+ words: list[dict[str, Any]],
106
+ *,
107
+ start_index: int = 0,
108
+ t_offset: float = 0.0,
109
+ max_words: int = 5,
110
+ ) -> list[dict[str, Any]]:
111
+ """Group aligned words (from start_index on) into ~max_words caption cues, rebased so times are
112
+ relative to the trimmed audio (subtract t_offset, clamp >= 0). Drops the carrier words."""
113
+ real = words[start_index:]
114
+ cues: list[dict[str, Any]] = []
115
+ for start in range(0, len(real), max_words):
116
+ group = real[start : start + max_words]
117
+ if not group:
118
+ continue
119
+ cues.append(
120
+ {
121
+ "t_start": max(0.0, round(float(group[0]["t_start"]) - t_offset, 3)),
122
+ "t_end": max(0.0, round(float(group[-1]["t_end"]) - t_offset, 3)),
123
+ "text": " ".join(w["word"] for w in group).strip(),
124
+ }
125
+ )
126
+ return cues
127
+
128
+
129
+ def publish_scene(
130
+ uploader: Uploader,
131
+ *,
132
+ prefix: str,
133
+ scene: dict[str, Any],
134
+ media_files: dict[str, Path],
135
+ work_dir: Path,
136
+ ) -> dict[str, str]:
137
+ """Publish a scene under ``<prefix>/uploads/<scene_id>/`` with media-before-scene ordering.
138
+
139
+ Uploads every media file first, then ``scene.json`` last, so a relay reading
140
+ ``uploads/*/scene.json`` never sees a scene whose media has not landed yet (§7 #6). The relay
141
+ discovers uploads by globbing scene.json, so no manifest mutation is needed.
142
+ """
143
+ scene_id = scene["scene_id"]
144
+ base = f"{prefix.strip('/')}/uploads/{scene_id}"
145
+ for name, path in media_files.items():
146
+ uploader(Path(path), f"{base}/media/{name}")
147
+ scene_path = Path(work_dir) / "scene.json"
148
+ scene_path.write_text(json.dumps(scene, indent=2) + "\n")
149
+ uploader(scene_path, f"{base}/scene.json")
150
+ return {"scene_id": scene_id, "remote_prefix": base}
151
+
152
+
153
+ def notify_relay_hook(
154
+ hook_url: str | None,
155
+ hook_token: str | None,
156
+ *,
157
+ scene_id: str,
158
+ seq: int,
159
+ post: Callable[..., Any] | None = None,
160
+ ) -> bool:
161
+ """Best-effort one-shot push to the Space relay hook after a scene is published (push-not-poll).
162
+
163
+ POSTs the pointer ``{scene_id, seq}`` with the shared Bearer; the Space re-reads the bucket and
164
+ emits the scene on its SSE stream so open browsers refresh once. Returns ``True`` only when the
165
+ hook accepts the push (HTTP 2xx). NEVER raises: the scene is already durably in the bucket, so a
166
+ hook outage (Space paused/503, network) must not fail the publish — it is logged and swallowed.
167
+ A no-op returning ``False`` when unconfigured (missing url or token): the bucket stays the
168
+ source of truth and the headless poll endpoint remains the fallback. ``post`` is injectable
169
+ (defaults to ``httpx.post``), mirroring this module's ``Uploader`` seam for unit tests.
170
+ """
171
+ url = (hook_url or "").strip()
172
+ token = (hook_token or "").strip()
173
+ if not (url and token):
174
+ return False
175
+ try:
176
+ if post is None:
177
+ import httpx # inside the try so even a missing-httpx env degrades to a no-op
178
+
179
+ post = httpx.post
180
+ response = post(
181
+ url,
182
+ headers={"Authorization": f"Bearer {token}"},
183
+ json={"scene_id": scene_id, "seq": seq},
184
+ timeout=RELAY_HOOK_TIMEOUT_S,
185
+ )
186
+ response.raise_for_status()
187
+ return True
188
+ except Exception as exc: # best-effort: the scene is already published; never fail on the hook
189
+ print(f"narrate_v2: relay hook notify failed: {exc!r}", file=sys.stderr, flush=True)
190
+ return False
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class NarrationResult:
195
+ """One narration pass: text + speech + provenance (matches the contract's engine enum)."""
196
+
197
+ text: str
198
+ audio: Any # samples — numpy array from real backends; a plain list from the mock
199
+ sample_rate: int
200
+ narrator_model: str
201
+ tts_model: str
202
+ narrator_backend: str # contract enum: "llama_cpp" | "transformers" | "mock"
203
+ title: str = ""
204
+
205
+
206
+ class NarrationBackend(Protocol):
207
+ """Swappable narration backend — the modular seam the design requires."""
208
+
209
+ def narrate(self, clip_path: Path, *, style_key: str, language: str) -> NarrationResult: ...
210
+
211
+
212
+ class MockNarrationBackend:
213
+ """GPU-free backend for CI and local end-to-end tests (no model load)."""
214
+
215
+ def narrate(
216
+ self, clip_path: Path, *, style_key: str = "deadpan", language: str = "English"
217
+ ) -> NarrationResult:
218
+ stem = Path(clip_path).stem
219
+ return NarrationResult(
220
+ text=f"[mock {language}] a flat description of {stem}.",
221
+ audio=[0.0] * 2400, # 0.1 s @ 24 kHz placeholder
222
+ sample_rate=24_000,
223
+ narrator_model="mock",
224
+ tts_model="mock",
225
+ narrator_backend="mock",
226
+ title=stem.replace("-", " ").title()[:TITLE_MAX],
227
+ )
src/small_cuts/narrator.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Narration pipeline with pluggable model backends.
2
+
3
+ Backend selection is controlled by the SMALL_CUTS_BACKEND env var:
4
+
5
+ - ``mock`` deterministic, no model weights — CI, tests, UI development
6
+ - ``transformers`` small vision-language model via Hugging Face transformers
7
+ - ``llama_cpp`` GGUF model via llama.cpp (CPU fallback / Llama Champion quest)
8
+
9
+ All backends are local: no cloud APIs (Off the Grid quest).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import atexit
15
+ import base64
16
+ import io
17
+ import json
18
+ import os
19
+ import shutil
20
+ import socket
21
+ import subprocess
22
+ import threading
23
+ import time
24
+ from dataclasses import dataclass
25
+ from functools import cache
26
+ from pathlib import Path
27
+ from typing import Protocol
28
+
29
+ import httpx
30
+ from PIL import Image
31
+
32
+ from .styles import DEFAULT_STYLE_KEY, STYLES, build_messages, clean_scene_hint
33
+ from .title_card import TITLE_MAX_LEN, derive_title
34
+
35
+ # M1 final pick (docs/eval/run-006-scored.md): beats Qwen2.5-VL-7B head-to-head
36
+ # for BOTH judges (Codex 7/10 vs 0/10, Gemini 9/10 vs 0/10 images passing).
37
+ DEFAULT_MODEL_ID = "Qwen/Qwen3-VL-8B-Instruct"
38
+ LLAMA_REPO_ID = "Qwen/Qwen3-VL-8B-Instruct-GGUF"
39
+ LLAMA_GGUF_FILENAME = "Qwen3VL-8B-Instruct-Q4_K_M.gguf"
40
+ LLAMA_MMPROJ_FILENAME = "mmproj-Qwen3VL-8B-Instruct-F16.gguf"
41
+ LLAMA_TIMEOUT_S = 120.0
42
+ DEFAULT_MAX_NEW_TOKENS = 160
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Narration:
47
+ text: str
48
+ style_key: str
49
+ backend: str
50
+ model_id: str
51
+ latency_s: float
52
+ title: str = ""
53
+
54
+
55
+ class Backend(Protocol):
56
+ name: str
57
+ model_id: str
58
+
59
+ def generate(self, image: Image.Image, style_key: str, scene_hint: str) -> str: ...
60
+
61
+
62
+ class MockBackend:
63
+ """Deterministic narrator used in CI and UI development.
64
+
65
+ Derives a few real features from the image (dimensions, brightness,
66
+ dominant hue) so the output visibly depends on the input without any
67
+ model weights.
68
+ """
69
+
70
+ name = "mock"
71
+ model_id = "mock-narrator-0"
72
+
73
+ def generate(self, image: Image.Image, style_key: str, scene_hint: str) -> str:
74
+ style = STYLES[style_key]
75
+ r, g, b = image.convert("RGB").resize((1, 1)).getpixel((0, 0))
76
+ brightness = (r + g + b) / 3
77
+ light = "well-lit" if brightness > 127 else "dimly lit"
78
+ shape = "wide" if image.width >= image.height else "tall"
79
+ clean_hint = clean_scene_hint(scene_hint)
80
+ hint = f" {clean_hint}" if clean_hint else ""
81
+ narration = (
82
+ f"[{style.label}] The frame is {shape} and {light}, and the narrator has "
83
+ f"seen it all before.{hint} What happens next was, frankly, inevitable."
84
+ )
85
+ return json.dumps({"title": derive_title(narration), "narration": narration})
86
+
87
+
88
+ class TransformersBackend:
89
+ """Small VLM via transformers. Lazily loads on first use.
90
+
91
+ On a ZeroGPU Space, decorate the hot path with ``spaces.GPU`` (handled in
92
+ app.py so this module stays importable without the ``spaces`` package).
93
+ """
94
+
95
+ name = "transformers"
96
+
97
+ def __init__(self, model_id: str | None = None) -> None:
98
+ self.model_id = model_id or os.environ.get("SMALL_CUTS_MODEL_ID", DEFAULT_MODEL_ID)
99
+ self._pipe = None
100
+ self._load_lock = threading.Lock()
101
+
102
+ def _load(self):
103
+ with self._load_lock:
104
+ if self._pipe is None:
105
+ import torch
106
+ from transformers import AutoModelForImageTextToText, AutoProcessor
107
+
108
+ self._processor = AutoProcessor.from_pretrained(self.model_id)
109
+ if torch.cuda.is_available():
110
+ # Explicit .to("cuda") — ZeroGPU packs weights on this call;
111
+ # accelerate's device_map dispatch would fight it.
112
+ self._model = AutoModelForImageTextToText.from_pretrained(
113
+ self.model_id, torch_dtype=torch.bfloat16
114
+ ).to("cuda")
115
+ else:
116
+ self._model = AutoModelForImageTextToText.from_pretrained(
117
+ self.model_id, torch_dtype=torch.float32, device_map="auto"
118
+ )
119
+ self._pipe = True
120
+ return self._processor, self._model
121
+
122
+ def generate(self, image: Image.Image, style_key: str, scene_hint: str) -> str:
123
+ processor, model = self._load()
124
+ image = _downscale(image)
125
+ messages = build_messages(style_key, scene_hint)
126
+ # Attach the image to the user turn in the chat-template format.
127
+ chat = [
128
+ {"role": "system", "content": [{"type": "text", "text": messages[0]["content"]}]},
129
+ {
130
+ "role": "user",
131
+ "content": [
132
+ {"type": "image", "image": image},
133
+ {"type": "text", "text": messages[1]["content"]},
134
+ ],
135
+ },
136
+ ]
137
+ inputs = processor.apply_chat_template(
138
+ chat,
139
+ add_generation_prompt=True,
140
+ tokenize=True,
141
+ return_dict=True,
142
+ return_tensors="pt",
143
+ ).to(model.device)
144
+ # Low temperature: judged eval showed small VLMs confabulate; sampling
145
+ # heat feeds it. Overridable per-run for eval sweeps.
146
+ temperature = _temperature()
147
+ output = model.generate(
148
+ **inputs,
149
+ max_new_tokens=_max_output_tokens(),
150
+ do_sample=temperature > 0,
151
+ temperature=temperature,
152
+ )
153
+ text = processor.batch_decode(
154
+ output[:, inputs["input_ids"].shape[1] :], skip_special_tokens=True
155
+ )[0]
156
+ return text.strip()
157
+
158
+
159
+ def _downscale(image: Image.Image, max_side: int = 1024) -> Image.Image:
160
+ """Return a copy whose longest side is at most max_side."""
161
+ if max(image.size) <= max_side:
162
+ return image
163
+ resized = image.copy()
164
+ resized.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)
165
+ return resized
166
+
167
+
168
+ class LlamaCppBackend:
169
+ """GGUF vision model via llama.cpp — CPU fallback and Llama Champion quest."""
170
+
171
+ name = "llama_cpp"
172
+
173
+ def __init__(self, gguf_path: str | None = None, mmproj_path: str | None = None) -> None:
174
+ self._external_url = os.environ.get("SMALL_CUTS_LLAMA_URL", "").rstrip("/")
175
+ self._gguf_path = gguf_path or os.environ.get("SMALL_CUTS_GGUF_PATH", "")
176
+ self._mmproj_path = mmproj_path or os.environ.get("SMALL_CUTS_MMPROJ_PATH", "")
177
+ self.model_id = Path(self._gguf_path).name if self._gguf_path else LLAMA_REPO_ID
178
+ self._server_url = ""
179
+ self._process: subprocess.Popen | None = None
180
+ self._stderr_fh = None
181
+ self._cleanup_registered = False
182
+ self._spawn_lock = threading.Lock()
183
+
184
+ def generate(self, image: Image.Image, style_key: str, scene_hint: str) -> str:
185
+ if self._external_url:
186
+ server_url = self._external_url
187
+ else:
188
+ with self._spawn_lock: # Gradio handlers are threaded; spawn once
189
+ server_url = self._ensure_server()
190
+ body = self._build_request(image, style_key, scene_hint)
191
+ try:
192
+ response = httpx.post(
193
+ f"{server_url}/v1/chat/completions",
194
+ json=body,
195
+ timeout=LLAMA_TIMEOUT_S,
196
+ )
197
+ response.raise_for_status()
198
+ except httpx.HTTPStatusError as exc:
199
+ raise RuntimeError(
200
+ f"llama-server at {server_url} returned HTTP "
201
+ f"{exc.response.status_code} for chat completion."
202
+ ) from exc
203
+ except httpx.RequestError as exc:
204
+ raise RuntimeError(f"Could not reach llama-server at {server_url}: {exc}") from exc
205
+
206
+ try:
207
+ content = response.json()["choices"][0]["message"]["content"]
208
+ except (ValueError, KeyError, IndexError, TypeError) as exc:
209
+ raise RuntimeError(
210
+ f"Unexpected llama-server response from {server_url}; expected "
211
+ "choices[0].message.content."
212
+ ) from exc
213
+ if not isinstance(content, str):
214
+ raise RuntimeError(
215
+ f"Unexpected llama-server response from {server_url}; message content was not text."
216
+ )
217
+ return content.strip()
218
+
219
+ def close(self) -> None:
220
+ process = self._process
221
+ if process is not None and process.poll() is None:
222
+ process.terminate()
223
+ try:
224
+ process.wait(timeout=10)
225
+ except subprocess.TimeoutExpired:
226
+ process.kill()
227
+ process.wait(timeout=10)
228
+ self._process = None
229
+ self._server_url = ""
230
+ if self._stderr_fh is not None and not self._stderr_fh.closed:
231
+ self._stderr_fh.close()
232
+
233
+ def _build_request(self, image: Image.Image, style_key: str, scene_hint: str) -> dict:
234
+ messages = build_messages(style_key, scene_hint)
235
+ data_uri = _image_data_uri(_downscale(image))
236
+ return {
237
+ "messages": [
238
+ {"role": "system", "content": messages[0]["content"]},
239
+ {
240
+ "role": "user",
241
+ "content": [
242
+ {"type": "image_url", "image_url": {"url": data_uri}},
243
+ {"type": "text", "text": messages[1]["content"]},
244
+ ],
245
+ },
246
+ ],
247
+ "temperature": _temperature(),
248
+ "max_tokens": _max_output_tokens(),
249
+ }
250
+
251
+ def _ensure_server(self) -> str:
252
+ if self._server_url and self._process is not None and self._process.poll() is None:
253
+ return self._server_url
254
+ if self._process is not None and self._process.poll() is not None:
255
+ self._process = None
256
+ self._server_url = ""
257
+
258
+ binary = _llama_server_binary()
259
+ gguf_path, mmproj_path = self._model_paths()
260
+ port = _free_port()
261
+ self._server_url = f"http://127.0.0.1:{port}"
262
+ command = [
263
+ binary,
264
+ "-m",
265
+ gguf_path,
266
+ "--mmproj",
267
+ mmproj_path,
268
+ "--port",
269
+ str(port),
270
+ "-c",
271
+ "8192",
272
+ "--image-max-tokens",
273
+ "1024",
274
+ "--host",
275
+ "127.0.0.1",
276
+ ]
277
+ try:
278
+ self._process = subprocess.Popen(
279
+ command,
280
+ stdout=subprocess.DEVNULL,
281
+ stderr=self._stderr_file(),
282
+ )
283
+ except OSError as exc:
284
+ self._server_url = ""
285
+ raise RuntimeError(
286
+ f"Could not start llama-server with {binary!r}. Install llama.cpp "
287
+ "with `brew install llama.cpp`, set SMALL_CUTS_LLAMA_SERVER to the "
288
+ "binary path, or point SMALL_CUTS_LLAMA_URL at an already-running server."
289
+ ) from exc
290
+
291
+ if not self._cleanup_registered:
292
+ atexit.register(self.close)
293
+ self._cleanup_registered = True
294
+ self._wait_for_health(self._server_url)
295
+ return self._server_url
296
+
297
+ def _model_paths(self) -> tuple[str, str]:
298
+ gguf_path = self._gguf_path
299
+ mmproj_path = self._mmproj_path
300
+ if gguf_path and mmproj_path:
301
+ return gguf_path, mmproj_path
302
+
303
+ try:
304
+ from huggingface_hub import hf_hub_download
305
+ except ImportError as exc:
306
+ raise RuntimeError(
307
+ "huggingface_hub is required to resolve the default llama.cpp model. "
308
+ "Install the local dependencies, or set SMALL_CUTS_GGUF_PATH and "
309
+ "SMALL_CUTS_MMPROJ_PATH to local files."
310
+ ) from exc
311
+
312
+ if not gguf_path:
313
+ gguf_path = hf_hub_download(LLAMA_REPO_ID, LLAMA_GGUF_FILENAME)
314
+ if not mmproj_path:
315
+ mmproj_path = hf_hub_download(LLAMA_REPO_ID, LLAMA_MMPROJ_FILENAME)
316
+ return gguf_path, mmproj_path
317
+
318
+ def _stderr_file(self):
319
+ import tempfile
320
+
321
+ if self._stderr_fh is not None and not self._stderr_fh.closed:
322
+ self._stderr_fh.close()
323
+ self._stderr_path = Path(tempfile.gettempdir()) / f"llama-server-{os.getpid()}.err"
324
+ self._stderr_fh = self._stderr_path.open("w")
325
+ return self._stderr_fh
326
+
327
+ def _stderr_tail(self, lines: int = 5) -> str:
328
+ path = getattr(self, "_stderr_path", None)
329
+ if not path or not Path(path).exists():
330
+ return ""
331
+ tail = Path(path).read_text().splitlines()[-lines:]
332
+ return (" Last stderr lines: " + " | ".join(tail)) if tail else ""
333
+
334
+ def _wait_for_health(self, server_url: str) -> None:
335
+ deadline = time.monotonic() + LLAMA_TIMEOUT_S
336
+ while time.monotonic() < deadline:
337
+ if self._process is not None and self._process.poll() is not None:
338
+ raise RuntimeError(
339
+ f"llama-server exited before becoming healthy at {server_url} "
340
+ f"(a port conflict is possible).{self._stderr_tail()} "
341
+ "Check the GGUF/mmproj paths and llama.cpp installation."
342
+ )
343
+ try:
344
+ response = httpx.get(f"{server_url}/health", timeout=2.0)
345
+ if response.status_code == 200:
346
+ return
347
+ except httpx.RequestError:
348
+ pass
349
+ time.sleep(0.5)
350
+
351
+ self.close()
352
+ raise RuntimeError(
353
+ f"Timed out waiting for llama-server at {server_url}/health after "
354
+ f"{int(LLAMA_TIMEOUT_S)} seconds. Check model load time, GGUF/mmproj paths, "
355
+ "or use SMALL_CUTS_LLAMA_URL to point at a server you started manually."
356
+ )
357
+
358
+
359
+ def _image_data_uri(image: Image.Image) -> str:
360
+ buffer = io.BytesIO()
361
+ image.convert("RGB").save(buffer, format="JPEG", quality=90)
362
+ encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
363
+ return f"data:image/jpeg;base64,{encoded}"
364
+
365
+
366
+ def _temperature() -> float:
367
+ try:
368
+ return float(os.environ.get("SMALL_CUTS_TEMPERATURE", "0.3"))
369
+ except ValueError as exc:
370
+ raise RuntimeError("SMALL_CUTS_TEMPERATURE must be a floating-point number.") from exc
371
+
372
+
373
+ def _max_output_tokens() -> int:
374
+ raw = os.environ.get("SMALL_CUTS_MAX_NEW_TOKENS", "").strip()
375
+ if not raw:
376
+ return DEFAULT_MAX_NEW_TOKENS
377
+ try:
378
+ value = int(raw)
379
+ except ValueError as exc:
380
+ raise RuntimeError("SMALL_CUTS_MAX_NEW_TOKENS must be an integer.") from exc
381
+ if value < 1:
382
+ raise RuntimeError("SMALL_CUTS_MAX_NEW_TOKENS must be greater than zero.")
383
+ return value
384
+
385
+
386
+ def _llama_server_binary() -> str:
387
+ configured = os.environ.get("SMALL_CUTS_LLAMA_SERVER", "")
388
+ binary = configured or shutil.which("llama-server")
389
+ if binary and _is_executable(binary):
390
+ return binary
391
+ raise RuntimeError(
392
+ "llama.cpp backend needs a llama-server binary. Install it with "
393
+ "`brew install llama.cpp`, set SMALL_CUTS_LLAMA_SERVER to the binary path, "
394
+ "or set SMALL_CUTS_LLAMA_URL to an already-running llama-server."
395
+ )
396
+
397
+
398
+ def _is_executable(path: str) -> bool:
399
+ resolved = path if os.path.sep in path else shutil.which(path)
400
+ return bool(resolved and Path(resolved).is_file() and os.access(resolved, os.X_OK))
401
+
402
+
403
+ def _free_port() -> int:
404
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
405
+ sock.bind(("127.0.0.1", 0))
406
+ return int(sock.getsockname()[1])
407
+
408
+
409
+ _BACKENDS = {
410
+ "mock": MockBackend,
411
+ "transformers": TransformersBackend,
412
+ "llama_cpp": LlamaCppBackend,
413
+ }
414
+
415
+
416
+ @cache
417
+ def _backend_instance(key: str) -> Backend:
418
+ return _BACKENDS[key]()
419
+
420
+
421
+ def get_backend(name: str | None = None) -> Backend:
422
+ key = (name or os.environ.get("SMALL_CUTS_BACKEND", "mock")).lower()
423
+ if key not in _BACKENDS:
424
+ raise ValueError(f"Unknown backend {key!r}; expected one of {sorted(_BACKENDS)}")
425
+ # One instance per backend: model weights load once per process, not per call.
426
+ return _backend_instance(key)
427
+
428
+
429
+ def narrate(
430
+ image: Image.Image,
431
+ style_key: str = DEFAULT_STYLE_KEY,
432
+ scene_hint: str = "",
433
+ backend: Backend | None = None,
434
+ ) -> Narration:
435
+ """Narrate a single moment. The one entry point the UI calls."""
436
+ if style_key not in STYLES:
437
+ raise ValueError(f"Unknown style {style_key!r}")
438
+ backend = backend or get_backend()
439
+ start = time.perf_counter()
440
+ raw = backend.generate(image, style_key, scene_hint)
441
+ title, text = _parse_generation(raw)
442
+ return Narration(
443
+ text=text,
444
+ style_key=style_key,
445
+ backend=backend.name,
446
+ model_id=backend.model_id,
447
+ latency_s=time.perf_counter() - start,
448
+ title=title,
449
+ )
450
+
451
+
452
+ def _parse_generation(raw: str) -> tuple[str, str]:
453
+ """Return (title, narration), tolerating legacy plain-text model output."""
454
+ text = raw.strip()
455
+ if not text:
456
+ return "Untitled Scene", ""
457
+ parsed = _json_object_from_model(text)
458
+ if parsed is not None:
459
+ narration = str(parsed.get("narration", "")).strip()
460
+ title = _clean_title(str(parsed.get("title", "")).strip(), fallback=narration)
461
+ if narration:
462
+ return title, narration
463
+ return derive_title(text), text
464
+
465
+
466
+ def _json_object_from_model(text: str) -> dict | None:
467
+ candidates = [text]
468
+ if text.startswith("```"):
469
+ stripped = text.strip("`").strip()
470
+ if stripped.lower().startswith("json"):
471
+ stripped = stripped[4:].strip()
472
+ candidates.append(stripped)
473
+ first = text.find("{")
474
+ last = text.rfind("}")
475
+ if first != -1 and last > first:
476
+ candidates.append(text[first : last + 1])
477
+ for candidate in candidates:
478
+ try:
479
+ value = json.loads(candidate)
480
+ except json.JSONDecodeError:
481
+ continue
482
+ if isinstance(value, dict):
483
+ return value
484
+ return None
485
+
486
+
487
+ def _clean_title(title: str, fallback: str) -> str:
488
+ title = " ".join(title.replace("\n", " ").split())
489
+ if not title:
490
+ return derive_title(fallback)
491
+ return derive_title(title, max_len=TITLE_MAX_LEN)
src/small_cuts/observability.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional Sentry observability for demo/runtime failures.
2
+
3
+ No Sentry traffic is sent unless ``SENTRY_DSN`` is configured. Payload scrubbing
4
+ keeps frames, audio, cookies, and auth headers out of events.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any
11
+
12
+ SENTRY_DSN_ENV = "SENTRY_DSN"
13
+ SENTRY_ENV_ENV = "SENTRY_ENVIRONMENT"
14
+ SENTRY_RELEASE_ENV = "SENTRY_RELEASE"
15
+ SENSITIVE_HEADERS = {"authorization", "cookie", "x-api-key", "x-forwarded-for"}
16
+
17
+ _INITIALIZED = False
18
+
19
+
20
+ def init_sentry(dsn: str | None = None, *, sdk: Any | None = None) -> bool:
21
+ global _INITIALIZED
22
+ dsn = dsn if dsn is not None else os.environ.get(SENTRY_DSN_ENV, "").strip()
23
+ if not dsn:
24
+ return False
25
+ if _INITIALIZED:
26
+ return True
27
+ sdk = sdk or _import_sentry_sdk()
28
+ if sdk is None:
29
+ return False
30
+ sdk.init(
31
+ dsn=dsn,
32
+ environment=os.environ.get(SENTRY_ENV_ENV) or os.environ.get("SPACE_ID") or "local",
33
+ release=os.environ.get(SENTRY_RELEASE_ENV) or os.environ.get("SPACE_COMMIT_SHA"),
34
+ send_default_pii=False,
35
+ attach_stacktrace=True,
36
+ traces_sample_rate=0.0,
37
+ before_send=_scrub_event,
38
+ )
39
+ _INITIALIZED = True
40
+ return True
41
+
42
+
43
+ def capture_exception(exc: BaseException, *, sdk: Any | None = None) -> None:
44
+ if not _INITIALIZED and not init_sentry(sdk=sdk):
45
+ return
46
+ sdk = sdk or _import_sentry_sdk()
47
+ if sdk is not None:
48
+ sdk.capture_exception(exc)
49
+
50
+
51
+ def _import_sentry_sdk() -> Any | None:
52
+ try:
53
+ import sentry_sdk
54
+ except ImportError:
55
+ return None
56
+ return sentry_sdk
57
+
58
+
59
+ def _scrub_event(event: dict[str, Any], _hint: dict[str, Any]) -> dict[str, Any]:
60
+ request = event.get("request")
61
+ if isinstance(request, dict):
62
+ request.pop("data", None)
63
+ request.pop("cookies", None)
64
+ headers = request.get("headers")
65
+ if isinstance(headers, dict):
66
+ request["headers"] = {
67
+ key: value for key, value in headers.items() if key.lower() not in SENSITIVE_HEADERS
68
+ }
69
+ return event
70
+
71
+
72
+ def reset_for_tests() -> None:
73
+ global _INITIALIZED
74
+ _INITIALIZED = False
src/small_cuts/persistence.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ BUCKET_MOUNT_PATH_ENV = "SMALL_CUTS_BUCKET_MOUNT_PATH"
7
+
8
+
9
+ def bucket_mount_path() -> Path | None:
10
+ raw = os.environ.get(BUCKET_MOUNT_PATH_ENV, "").strip()
11
+ if not raw:
12
+ return None
13
+ return Path(raw).expanduser()
14
+
15
+
16
+ def persistent_path(*parts: str) -> Path | None:
17
+ mount = bucket_mount_path()
18
+ if mount is None:
19
+ return None
20
+ return mount.joinpath("space", *parts)
src/small_cuts/seed_media/desk-laptop.jpg ADDED
src/small_cuts/seed_media/desk-laptop.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c50d9aef2c2b3b591b37d28eb24eb1207ee1c1bbd67fd2c387b2de5ed0733480
3
+ size 257229
src/small_cuts/seed_media/desk-laptop.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c6d9e22a67888f755c03d70de70c45ccc2d8b853e0c2f7b6bf00ae58e2f472be
3
+ size 2537344
src/small_cuts/seed_media/night-drive.jpg ADDED
src/small_cuts/seed_media/night-drive.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e8dc4b2d254d62e571e57fba1df98f87e233b75d78426841a864ea135a033040
3
+ size 246573
src/small_cuts/seed_media/night-drive.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06c1866c429bb5a47333e38a56f57ae536b5eb806e6cfedbbb201aea225e26f2
3
+ size 3427164
src/small_cuts/seed_media/rayuela.jpg ADDED
src/small_cuts/seed_media/rayuela.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dfd85e684c1c231aa0301c47949d427e31646e750ab01d7f52a72b961add1010
3
+ size 294957
src/small_cuts/seed_media/rayuela.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3878bd95d642e3befdc767d2b54d4c4acce8cb43ffef46d1c232ba793cebed01
3
+ size 4268710
src/small_cuts/seed_media/street-parked-car.jpg ADDED
src/small_cuts/seed_media/street-parked-car.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c7b15be641666c6e99a4038d94fd09f47bce3dada55fa66d10f63c80c6af76b5
3
+ size 169965
src/small_cuts/seed_media/street-parked-car.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3d8444745071aab9dc208e77753328b248f472eac11861a920e77e7e607bee8e
3
+ size 4136123
src/small_cuts/seed_media/the-stumble.jpg ADDED
src/small_cuts/seed_media/the-stumble.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ddd9eab9ae92d64c90a94b98759db0f4aa31ccc9cd79ca89c9ef6ea9184e3c2e
3
+ size 233901
src/small_cuts/seed_media/the-stumble.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f8c6ba5d9c32717da065e2a1c923e9c1e96e7830aa5e00038a0e88d367d19baf
3
+ size 3395323
src/small_cuts/space_hooks.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Push hooks for relay-scene updates in the Gradio Space.
2
+
3
+ The Space should not poll Hugging Face control-plane APIs or repeatedly poll the
4
+ relay bucket. The local publisher calls the protected hook after it writes a new
5
+ manifest; browser clients keep one SSE connection open and refresh once per
6
+ hook event.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import hmac
13
+ import json
14
+ import os
15
+ from collections.abc import AsyncIterator
16
+ from typing import Annotated, Any
17
+
18
+ from fastapi import Body, FastAPI, Header, HTTPException, Request
19
+ from fastapi.responses import StreamingResponse
20
+
21
+ RELAY_HOOK_TOKEN_ENV = "SMALL_CUTS_RELAY_HOOK_TOKEN"
22
+ RELAY_HOOK_PATH = "/small-cuts/hooks/relay-scene"
23
+ RELAY_EVENTS_PATH = "/small-cuts/events"
24
+ SSE_HEARTBEAT_S = 15.0
25
+
26
+
27
+ class RelayEventHub:
28
+ def __init__(self) -> None:
29
+ self._next_id = 0
30
+ self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
31
+
32
+ def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
33
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=8)
34
+ self._subscribers.add(queue)
35
+ return queue
36
+
37
+ def unsubscribe(self, queue: asyncio.Queue[dict[str, Any]]) -> None:
38
+ self._subscribers.discard(queue)
39
+
40
+ async def publish(self, payload: dict[str, Any] | None = None) -> dict[str, Any]:
41
+ self._next_id += 1
42
+ event = {"id": self._next_id, "payload": payload or {}}
43
+ stale: list[asyncio.Queue[dict[str, Any]]] = []
44
+ for queue in list(self._subscribers):
45
+ try:
46
+ queue.put_nowait(event)
47
+ except asyncio.QueueFull:
48
+ stale.append(queue)
49
+ for queue in stale:
50
+ self.unsubscribe(queue)
51
+ return event
52
+
53
+
54
+ def install_relay_hooks(app: FastAPI, *, hub: RelayEventHub | None = None) -> RelayEventHub:
55
+ if getattr(app.state, "small_cuts_relay_hooks_installed", False):
56
+ return app.state.small_cuts_relay_event_hub
57
+
58
+ event_hub = hub or RelayEventHub()
59
+ app.state.small_cuts_relay_hooks_installed = True
60
+ app.state.small_cuts_relay_event_hub = event_hub
61
+
62
+ @app.post(RELAY_HOOK_PATH, status_code=202)
63
+ async def relay_scene_hook(
64
+ payload: Annotated[dict[str, Any] | None, Body()] = None,
65
+ authorization: Annotated[str | None, Header()] = None,
66
+ ) -> dict[str, Any]:
67
+ _require_hook_authorization(authorization)
68
+ event = await event_hub.publish(payload or {})
69
+ return {"status": "accepted", "event_id": event["id"]}
70
+
71
+ @app.get(RELAY_EVENTS_PATH)
72
+ async def relay_events(request: Request) -> StreamingResponse:
73
+ return StreamingResponse(
74
+ relay_event_stream(event_hub, request, heartbeat_s=SSE_HEARTBEAT_S),
75
+ media_type="text/event-stream",
76
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
77
+ )
78
+
79
+ return event_hub
80
+
81
+
82
+ async def relay_event_stream(
83
+ event_hub: RelayEventHub,
84
+ request: Request,
85
+ *,
86
+ heartbeat_s: float = SSE_HEARTBEAT_S,
87
+ ) -> AsyncIterator[str]:
88
+ queue = event_hub.subscribe()
89
+ try:
90
+ yield _sse("ready", {"status": "connected"})
91
+ while True:
92
+ if await request.is_disconnected():
93
+ break
94
+ try:
95
+ event = await asyncio.wait_for(queue.get(), timeout=heartbeat_s)
96
+ except asyncio.TimeoutError:
97
+ yield ": ping\n\n"
98
+ continue
99
+ yield _sse("relay-scene", event)
100
+ finally:
101
+ event_hub.unsubscribe(queue)
102
+
103
+
104
+ def _require_hook_authorization(authorization: str | None) -> None:
105
+ expected = os.environ.get(RELAY_HOOK_TOKEN_ENV, "").strip()
106
+ if not expected:
107
+ raise HTTPException(status_code=503, detail="relay hook is not configured")
108
+ if not authorization or not hmac.compare_digest(authorization, f"Bearer {expected}"):
109
+ raise HTTPException(status_code=401, detail="unauthorized")
110
+
111
+
112
+ def _sse(event: str, data: dict[str, Any]) -> str:
113
+ return f"event: {event}\ndata: {json.dumps(data, separators=(',', ':'))}\n\n"
src/small_cuts/styles.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Director style presets and prompt construction for the narrator."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ # v3 — evidence-based rebalance. The judged A/B (docs/eval/prompt-ab-comparison.md)
6
+ # showed v2's "find the story" license cost gemma -0.77 S and -0.67 G with zero V
7
+ # gain. v3 restores v1's hard grounding and moves all voice into tone, not facts.
8
+ SYSTEM_PROMPT = (
9
+ "You are the omniscient narrator of this person's life, in the spirit of the "
10
+ "narrators in 'The Invention of Lying': you can only say what is true, you see "
11
+ "the scene exactly as it is, and you describe it with cinematic certainty. "
12
+ "Narrate ONLY what is visible or directly inferable from the image. Never invent "
13
+ "objects, people, actions, weather, or sounds. If you are not certain something "
14
+ "is in the image, leave it out. Quote text on signs only when it is clearly "
15
+ "legible. Pick the one or two most telling visible details and build the "
16
+ "narration on them — the director's style changes the TONE of your sentences, "
17
+ "never the facts. Write 2 to 4 sentences, present tense, third person. "
18
+ "Also write a short movie-title style title grounded in the same visible facts. "
19
+ "Output only a compact JSON object with exactly two string keys: "
20
+ "`title` and `narration`. No markdown, no commentary, no emoji."
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class DirectorStyle:
26
+ """A narration style, presented to users as a director's cut."""
27
+
28
+ key: str
29
+ label: str
30
+ direction: str
31
+ example: str # one-shot example narration to anchor the voice
32
+
33
+
34
+ STYLES: dict[str, DirectorStyle] = {
35
+ style.key: style
36
+ for style in (
37
+ DirectorStyle(
38
+ key="deadpan",
39
+ label="Deadpan Omniscient (the classic)",
40
+ direction=(
41
+ "Flat, matter-of-fact omniscience. Gentle comic timing comes from "
42
+ "stating slightly-too-honest truths plainly, the way the narrators "
43
+ "in 'The Invention of Lying' would."
44
+ ),
45
+ example=(
46
+ "He stirs the coffee for the fourth time, though nothing about it "
47
+ "has changed. It is, and will remain, slightly too bitter. He "
48
+ "drinks it anyway, because the mug was a gift and he is sentimental."
49
+ ),
50
+ ),
51
+ DirectorStyle(
52
+ key="noir",
53
+ label="Noir Detective",
54
+ direction=(
55
+ "Hard-boiled 1940s noir voiceover. Shadows, rain, cigarettes that "
56
+ "aren't there. World-weary metaphors grounded in what's actually visible."
57
+ ),
58
+ example=(
59
+ "The desk lamp threw its light like an accusation. Somewhere in "
60
+ "that pile of cables was an answer, and answers in this town never "
61
+ "came cheap."
62
+ ),
63
+ ),
64
+ DirectorStyle(
65
+ key="nature_doc",
66
+ label="Nature Documentary",
67
+ direction=(
68
+ "Hushed, reverent wildlife-documentary narration. Treat the subject "
69
+ "as a fascinating specimen observed in its natural habitat."
70
+ ),
71
+ example=(
72
+ "Here, in the fluorescent clearing of the open-plan office, the "
73
+ "adult male attempts a ritual as old as the species itself: the "
74
+ "fourth coffee before noon."
75
+ ),
76
+ ),
77
+ DirectorStyle(
78
+ key="trailer",
79
+ label="Epic Trailer Voice",
80
+ direction=(
81
+ "Booming movie-trailer gravitas. Short punchy sentences. 'In a "
82
+ "world...' energy applied to a completely ordinary moment."
83
+ ),
84
+ example=("One man. One sandwich. This summer, lunch... changes everything."),
85
+ ),
86
+ DirectorStyle(
87
+ key="telenovela",
88
+ label="Telenovela",
89
+ direction=(
90
+ "Breathless melodrama. Every glance is betrayal, every object holds "
91
+ "a secret. Spanish-telenovela emotional stakes for mundane scenes."
92
+ ),
93
+ example=(
94
+ "She looks at the empty fridge — the same fridge that promised her "
95
+ "so much on Sunday. Inside, only mustard remains. Mustard... and lies."
96
+ ),
97
+ ),
98
+ DirectorStyle(
99
+ key="symmetrist",
100
+ label="Wes Anderson Symmetrist",
101
+ direction=(
102
+ "Precise, whimsical, faux-naive storybook narration. Note colors, "
103
+ "symmetry, and small formal details. Affectionate melancholy."
104
+ ),
105
+ example=(
106
+ "The bicycle is mustard yellow, which is also the color of the "
107
+ "third button on his cardigan. He parked it at exactly the angle "
108
+ "his father would have disapproved of, which is why he did."
109
+ ),
110
+ ),
111
+ )
112
+ }
113
+
114
+ DEFAULT_STYLE_KEY = "deadpan"
115
+ MAX_SCENE_HINT_CHARS = 280
116
+
117
+
118
+ def clean_scene_hint(scene_hint: str) -> str:
119
+ return scene_hint.strip()[:MAX_SCENE_HINT_CHARS]
120
+
121
+
122
+ def style_choices() -> list[tuple[str, str]]:
123
+ """(label, key) pairs for UI dropdowns."""
124
+ return [(style.label, style.key) for style in STYLES.values()]
125
+
126
+
127
+ def build_messages(style_key: str, scene_hint: str = "") -> list[dict]:
128
+ """Build the chat messages (minus the image, attached by the backend)."""
129
+ style = STYLES[style_key]
130
+ hint = clean_scene_hint(scene_hint)
131
+ user_text = (
132
+ f"Director's cut: {style.label}.\n"
133
+ f"Style direction: {style.direction}\n"
134
+ f"Example of the voice (different scene): {style.example}\n"
135
+ )
136
+ if hint:
137
+ user_text += f"Context offered by the person living this moment: {hint}\n"
138
+ user_text += (
139
+ "Now title and narrate the attached moment as JSON, for example: "
140
+ '{"title":"The Fourth Coffee","narration":"He stirs the coffee again. '
141
+ 'Nothing about it improves."}'
142
+ )
143
+ return [
144
+ {"role": "system", "content": SYSTEM_PROMPT},
145
+ {"role": "user", "content": user_text},
146
+ ]
src/small_cuts/theme.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Off-Brand Gradio theme for Small Cuts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gradio as gr
6
+
7
+ from .title_card import STYLE_CARDS
8
+
9
+ CANVAS = STYLE_CARDS["trailer"][0]
10
+ MARQUEE_GOLD = STYLE_CARDS["trailer"][1]
11
+ BONE_WHITE = STYLE_CARDS["noir"][1]
12
+ LIFTED_CANVAS = "#16161C"
13
+ BORDER = "#2A292F"
14
+
15
+
16
+ class OffBrand(gr.themes.Base):
17
+ """Dark cinematic Gradio theme using the Small Cuts house palette."""
18
+
19
+ def __init__(self) -> None:
20
+ super().__init__(
21
+ font=[gr.themes.GoogleFont("Spectral"), "serif"],
22
+ font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"],
23
+ )
24
+ self.set(
25
+ body_background_fill=CANVAS,
26
+ body_background_fill_dark=CANVAS,
27
+ body_text_color=BONE_WHITE,
28
+ body_text_color_dark=BONE_WHITE,
29
+ background_fill_primary=CANVAS,
30
+ background_fill_primary_dark=CANVAS,
31
+ background_fill_secondary=LIFTED_CANVAS,
32
+ background_fill_secondary_dark=LIFTED_CANVAS,
33
+ block_background_fill=LIFTED_CANVAS,
34
+ block_background_fill_dark=LIFTED_CANVAS,
35
+ block_border_color=BORDER,
36
+ block_border_color_dark=BORDER,
37
+ block_title_text_color=MARQUEE_GOLD,
38
+ block_title_text_color_dark=MARQUEE_GOLD,
39
+ border_color_primary=BORDER,
40
+ border_color_primary_dark=BORDER,
41
+ input_background_fill=STYLE_CARDS["noir"][0],
42
+ input_background_fill_dark=STYLE_CARDS["noir"][0],
43
+ input_background_fill_focus=CANVAS,
44
+ input_background_fill_focus_dark=CANVAS,
45
+ input_border_color=BORDER,
46
+ input_border_color_dark=BORDER,
47
+ link_text_color=MARQUEE_GOLD,
48
+ link_text_color_dark=MARQUEE_GOLD,
49
+ button_primary_background_fill=MARQUEE_GOLD,
50
+ button_primary_background_fill_dark=MARQUEE_GOLD,
51
+ button_primary_border_color=MARQUEE_GOLD,
52
+ button_primary_border_color_dark=MARQUEE_GOLD,
53
+ button_primary_text_color=CANVAS,
54
+ button_primary_text_color_dark=CANVAS,
55
+ button_secondary_background_fill=LIFTED_CANVAS,
56
+ button_secondary_background_fill_dark=LIFTED_CANVAS,
57
+ button_secondary_text_color=BONE_WHITE,
58
+ button_secondary_text_color_dark=BONE_WHITE,
59
+ button_secondary_border_color=BORDER,
60
+ button_secondary_border_color_dark=BORDER,
61
+ # P0 contrast fixes (#28): code chips rendered near-white-on-white
62
+ # and the in-block labels were too dim on the charcoal canvas.
63
+ code_background_fill=STYLE_CARDS["noir"][0],
64
+ code_background_fill_dark=STYLE_CARDS["noir"][0],
65
+ block_label_text_color=MARQUEE_GOLD,
66
+ block_label_text_color_dark=MARQUEE_GOLD,
67
+ block_label_background_fill=LIFTED_CANVAS,
68
+ block_label_background_fill_dark=LIFTED_CANVAS,
69
+ )
70
+
71
+
72
+ def build_theme() -> gr.themes.Base:
73
+ return OffBrand()
src/small_cuts/title_card.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic movie-style title cards for Small Cuts."""
2
+
3
+ from PIL import Image, ImageDraw, ImageFont
4
+
5
+ from .styles import STYLES
6
+
7
+ TITLE_MAX_LEN = 60
8
+
9
+ STYLE_CARDS = {
10
+ "deadpan": ("#F2EFE6", "#1A1A1A", "rules"),
11
+ "noir": ("#0D0D0F", "#E8E4D8", "hard_frame"),
12
+ "nature_doc": ("#0E2A1B", "#F0E9D2", "double_frame"),
13
+ "trailer": ("#101014", "#D4AF37", "spaced"),
14
+ "telenovela": ("#5C0A14", "#FFE9EC", "ornament"),
15
+ "symmetrist": ("#F7D6C9", "#5B3A29", "thin_frame"),
16
+ }
17
+
18
+
19
+ def derive_title(text: str, max_len: int = TITLE_MAX_LEN) -> str:
20
+ value = text.strip()
21
+ if value.startswith("["):
22
+ tag_end = value.find("]")
23
+ if tag_end != -1:
24
+ value = value[tag_end + 1 :].strip()
25
+ if not value:
26
+ return "Untitled Scene"
27
+ title = _first_clause(value).strip()
28
+ if not title:
29
+ return "Untitled Scene"
30
+ return _truncate_title(title, max_len)
31
+
32
+
33
+ def render_title_card(
34
+ title: str,
35
+ style_key: str,
36
+ size: tuple[int, int] = (1280, 720),
37
+ ) -> Image.Image:
38
+ style = STYLES[style_key]
39
+ bg, fg, treatment = STYLE_CARDS[style_key]
40
+ width, height = size
41
+ image = Image.new("RGB", size, bg)
42
+ draw = ImageDraw.Draw(image)
43
+ if treatment == "hard_frame":
44
+ inset = max(8, min(width, height) // 22)
45
+ box = (inset, inset, width - inset - 1, height - inset - 1)
46
+ draw.rectangle(box, outline=fg, width=2)
47
+ elif treatment == "double_frame":
48
+ inset = max(8, min(width, height) // 24)
49
+ for offset in (inset, inset + max(4, inset // 3)):
50
+ box = (offset, offset, width - offset - 1, height - offset - 1)
51
+ draw.rectangle(box, outline=fg)
52
+ elif treatment == "thin_frame":
53
+ draw.rectangle((8, 8, width - 9, height - 9), outline=fg)
54
+ margin = max(12, width // 9)
55
+ content_width = max(1, width - margin * 2)
56
+ max_title_height = max(24, int(height * 0.42))
57
+ spaced = treatment == "spaced"
58
+ for size_px in range(max(12, int(height * 0.12)), 5, -2):
59
+ title_font = _font(size_px)
60
+ lines = _wrap(draw, title.strip().upper(), title_font, content_width, spaced)
61
+ block_width, block_height = _block(draw, lines, title_font)
62
+ if block_width <= content_width and block_height <= max_title_height:
63
+ break
64
+ else:
65
+ title_font = _font(6)
66
+ lines = _wrap(draw, title.strip().upper(), title_font, content_width, spaced)
67
+ _, block_height = _block(draw, lines, title_font)
68
+ kicker_font = _font(max(10, int(height * 0.04)))
69
+ subtitle_font = _font(max(10, int(height * 0.035)))
70
+ _center(draw, "A SMALL CUTS PICTURE", width, int(height * 0.17), kicker_font, fg)
71
+ if lines:
72
+ title_top = max(int(height * 0.28), (height - block_height) // 2)
73
+ title_size = getattr(title_font, "size", 12)
74
+ if treatment == "rules":
75
+ top_rule = max(8, title_top - title_size // 2)
76
+ bottom_rule = min(height - 8, title_top + block_height + title_size // 2)
77
+ draw.line((margin, top_rule, width - margin, top_rule), fill=fg)
78
+ draw.line((margin, bottom_rule, width - margin, bottom_rule), fill=fg)
79
+ line_height = _measure(draw, "Ag", title_font)[1]
80
+ y = title_top
81
+ for line in lines:
82
+ _center(draw, line, width, y, title_font, fg)
83
+ y += line_height
84
+ if treatment == "ornament":
85
+ _center(draw, "♦ ─── ♦", width, y + max(8, title_size // 4), subtitle_font, fg)
86
+ subtitle_y = min(height - getattr(subtitle_font, "size", 10) * 2, int(height * 0.78))
87
+ _center(draw, style.label.upper(), width, subtitle_y, subtitle_font, fg)
88
+ return image
89
+
90
+
91
+ def _first_clause(text: str) -> str:
92
+ index = 0
93
+ while index < len(text):
94
+ if text.startswith("...", index):
95
+ if _ellipsis_ends_clause(text, index, 3):
96
+ return text[:index]
97
+ index += 3
98
+ continue
99
+ if text[index] == "…":
100
+ if _ellipsis_ends_clause(text, index, 1):
101
+ return text[:index]
102
+ elif text[index] in ".!?;—":
103
+ return text[:index]
104
+ index += 1
105
+ return text
106
+
107
+
108
+ def _ellipsis_ends_clause(text: str, index: int, width: int) -> bool:
109
+ if len(text[:index].split()) < 3:
110
+ return False
111
+ next_index = index + width
112
+ if next_index >= len(text) or not text[next_index].isspace():
113
+ return False
114
+ while next_index < len(text) and text[next_index].isspace():
115
+ next_index += 1
116
+ return next_index < len(text) and text[next_index].isupper()
117
+
118
+
119
+ def _truncate_title(title: str, max_len: int) -> str:
120
+ if max_len < 1:
121
+ return ""
122
+ if len(title) <= max_len:
123
+ return title
124
+ if max_len == 1:
125
+ return "…"
126
+ cut = -1
127
+ for index, char in enumerate(title[:max_len]):
128
+ if char.isspace():
129
+ cut = index
130
+ if cut <= 0:
131
+ return "…"
132
+ return f"{title[:cut].rstrip()}…"
133
+
134
+
135
+ def _font(size):
136
+ try:
137
+ return ImageFont.load_default(size=size)
138
+ except TypeError:
139
+ return ImageFont.load_default()
140
+
141
+
142
+ def _wrap(draw, text, font, max_width, spaced):
143
+ def show(value):
144
+ return " ".join(value) if spaced else value
145
+
146
+ def fits(value):
147
+ return _measure(draw, show(value), font)[0] <= max_width
148
+
149
+ lines = []
150
+ current = ""
151
+ for word in text.split():
152
+ candidate = f"{current} {word}".strip()
153
+ if fits(candidate):
154
+ current = candidate
155
+ continue
156
+ if current:
157
+ lines.append(show(current))
158
+ current = word
159
+ while len(current) > 1 and not fits(current):
160
+ split = 1
161
+ while split < len(current) and fits(current[: split + 1]):
162
+ split += 1
163
+ lines.append(show(current[:split]))
164
+ current = current[split:]
165
+ if current:
166
+ lines.append(show(current))
167
+ return lines
168
+
169
+
170
+ def _block(draw, lines, font):
171
+ line_height = _measure(draw, "Ag", font)[1]
172
+ widths = [_measure(draw, line, font)[0] for line in lines]
173
+ return max(widths, default=0), len(lines) * line_height
174
+
175
+
176
+ def _center(draw, text, width, y, font, fill):
177
+ x = (width - _measure(draw, text, font)[0]) / 2
178
+ draw.text((x, y), text, font=font, fill=fill)
179
+
180
+
181
+ def _measure(draw, text, font):
182
+ left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
183
+ return right - left, bottom - top
src/small_cuts/tts.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text-to-speech pipeline with pluggable local backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass
9
+ from functools import cache
10
+ from typing import Protocol
11
+
12
+ import numpy as np
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Speech:
17
+ sample_rate: int
18
+ audio: np.ndarray
19
+ backend: str
20
+ model_id: str
21
+ latency_s: float
22
+
23
+
24
+ class TTSBackend(Protocol):
25
+ name: str
26
+ model_id: str
27
+
28
+ def synthesize(self, text: str) -> tuple[int, np.ndarray]: ...
29
+
30
+
31
+ class MockTTSBackend:
32
+ name = "mock"
33
+ model_id = "mock-tts-0"
34
+
35
+ def synthesize(self, text: str) -> tuple[int, np.ndarray]:
36
+ sample_rate = 24_000
37
+ duration_s = min(2.0, max(0.2, len(text) / 40))
38
+ encoded = text.encode("utf-8")
39
+ frequency = 220 + sum((i + 1) * byte for i, byte in enumerate(encoded)) % 220
40
+ samples = int(sample_rate * duration_s)
41
+ t = np.arange(samples, dtype=np.float32) / sample_rate
42
+ audio = 0.25 * np.sin(2 * np.pi * frequency * t)
43
+ return sample_rate, audio.astype(np.float32, copy=False)
44
+
45
+
46
+ class KokoroBackend:
47
+ name = "kokoro"
48
+ model_id = "hexgrad/Kokoro-82M"
49
+
50
+ def __init__(self) -> None:
51
+ self._pipeline = None
52
+ self._load_lock = threading.Lock()
53
+
54
+ def _load(self):
55
+ with self._load_lock:
56
+ if self._pipeline is None:
57
+ try:
58
+ from kokoro import KPipeline
59
+ except ImportError as exc:
60
+ raise RuntimeError(
61
+ "Kokoro TTS is not installed. Run `uv sync --extra tts` to enable it."
62
+ ) from exc
63
+ # Pin to CPU: on ZeroGPU the hijacked CUDA is only usable inside
64
+ # @spaces.GPU, and the speak path runs outside it. Forcing
65
+ # map_location keeps torch.load from initializing CUDA in the
66
+ # main process while restoring the checkpoint — that cuInit
67
+ # poisons every later ZeroGPU worker fork ("No CUDA GPUs are
68
+ # available").
69
+ import torch
70
+
71
+ device = os.environ.get("SMALL_CUTS_TTS_DEVICE", "cpu")
72
+ original_load = torch.load
73
+
74
+ def _cpu_load(*args, **kwargs):
75
+ kwargs["map_location"] = "cpu"
76
+ return original_load(*args, **kwargs)
77
+
78
+ torch.load = _cpu_load
79
+ try:
80
+ self._pipeline = KPipeline(lang_code="a", device=device)
81
+ finally:
82
+ torch.load = original_load
83
+ return self._pipeline
84
+
85
+ def synthesize(self, text: str) -> tuple[int, np.ndarray]:
86
+ pipeline = self._load()
87
+ voice = os.environ.get("SMALL_CUTS_TTS_VOICE", "af_heart")
88
+ segments = []
89
+ for _, _, audio in pipeline(text, voice=voice):
90
+ if hasattr(audio, "detach"):
91
+ audio = audio.detach().cpu().numpy()
92
+ segment = np.asarray(audio, dtype=np.float32).reshape(-1)
93
+ segments.append(segment)
94
+ if not segments:
95
+ return 24_000, np.zeros(0, dtype=np.float32)
96
+ return 24_000, np.clip(np.concatenate(segments), -1.0, 1.0).astype(np.float32, copy=False)
97
+
98
+
99
+ _BACKENDS = {
100
+ "mock": MockTTSBackend,
101
+ "kokoro": KokoroBackend,
102
+ }
103
+
104
+
105
+ @cache
106
+ def _backend_instance(key: str) -> TTSBackend:
107
+ return _BACKENDS[key]()
108
+
109
+
110
+ def get_tts_backend(name: str | None = None) -> TTSBackend:
111
+ key = (name or os.environ.get("SMALL_CUTS_TTS_BACKEND", "mock")).lower()
112
+ if key not in _BACKENDS:
113
+ raise ValueError(f"Unknown TTS backend {key!r}; expected one of {sorted(_BACKENDS)}")
114
+ # One instance per backend: the Kokoro pipeline loads once per process.
115
+ return _backend_instance(key)
116
+
117
+
118
+ def speak(text: str, backend: TTSBackend | None = None) -> Speech:
119
+ text = text.strip()
120
+ if not text:
121
+ raise ValueError("Cannot synthesize empty text")
122
+ backend = backend or get_tts_backend()
123
+ start = time.perf_counter()
124
+ sample_rate, audio = backend.synthesize(text)
125
+ audio = np.asarray(audio, dtype=np.float32).reshape(-1)
126
+ audio = np.clip(audio, -1.0, 1.0).astype(np.float32, copy=False)
127
+ return Speech(
128
+ sample_rate=sample_rate,
129
+ audio=audio,
130
+ backend=backend.name,
131
+ model_id=backend.model_id,
132
+ latency_s=time.perf_counter() - start,
133
+ )
src/small_cuts/ui.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio UI for Small Cuts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gradio as gr
6
+ import numpy as np
7
+ from PIL import Image
8
+
9
+ from .frames import pick_key_frame, sample_frames
10
+ from .narrator import get_backend, narrate
11
+ from .styles import DEFAULT_STYLE_KEY, style_choices
12
+ from .theme import build_theme
13
+ from .title_card import derive_title, render_title_card
14
+ from .tts import speak
15
+
16
+ TITLE = "🎬 Small Cuts"
17
+ TAGLINE = (
18
+ "Your life, narrated. Drop in a moment — from your phone, webcam, or "
19
+ "smart-glasses footage — pick a director, and hear what scene you're really in. "
20
+ "Every model under 32B. Everything runs in this Space."
21
+ )
22
+
23
+ # Off-Brand cinematic theme for the M2 custom UI quest.
24
+ THEME = build_theme()
25
+
26
+
27
+ def _gpu(duration: int = 90):
28
+ """Mark an event handler for ZeroGPU. No-op off-Space.
29
+
30
+ ZeroGPU's startup scan looks for the GPU mark on the functions Gradio
31
+ binds — decorating an inner helper instead leaves requests unscheduled
32
+ (worker dies with "No CUDA GPUs are available"). TTS is marked too:
33
+ any torch forward in the main process poisons later worker forks.
34
+ """
35
+
36
+ def deco(fn):
37
+ try:
38
+ import spaces
39
+ except ImportError:
40
+ return fn
41
+ return spaces.GPU(duration=duration)(fn)
42
+
43
+ return deco
44
+
45
+
46
+ def _narrate_core(
47
+ image: Image.Image | None, style_key: str, scene_hint: str, empty_text: str
48
+ ) -> tuple[Image.Image, str]:
49
+ if image is None:
50
+ text = empty_text
51
+ else:
52
+ result = narrate(image, style_key=style_key, scene_hint=scene_hint or "")
53
+ text = result.text
54
+ return render_title_card(derive_title(text), style_key), text
55
+
56
+
57
+ @_gpu()
58
+ def _narrate_handler(
59
+ image: Image.Image | None, style_key: str, scene_hint: str
60
+ ) -> tuple[Image.Image, str]:
61
+ return _narrate_core(
62
+ image,
63
+ style_key,
64
+ scene_hint,
65
+ "The narrator clears his throat, looks at the empty screen, and waits. "
66
+ "Some scenes, after all, require a scene.",
67
+ )
68
+
69
+
70
+ @_gpu()
71
+ def _narrate_video_handler(
72
+ video_path: str | None, style_key: str, scene_hint: str
73
+ ) -> tuple[Image.Image, str]:
74
+ frame = pick_key_frame(sample_frames(video_path)) if video_path else None
75
+ return _narrate_core(
76
+ frame,
77
+ style_key,
78
+ scene_hint,
79
+ "The narrator squints at the projector. Nothing. He has narrated "
80
+ "blank screens before, but never by choice.",
81
+ )
82
+
83
+
84
+ @_gpu(duration=30)
85
+ def _speak_handler(text: str) -> tuple[int, np.ndarray] | None:
86
+ if not text.strip():
87
+ return None
88
+ speech = speak(text)
89
+ return speech.sample_rate, speech.audio
90
+
91
+
92
+ def build_app() -> gr.Blocks:
93
+ backend = get_backend()
94
+ with gr.Blocks(title=TITLE) as demo:
95
+ gr.Markdown(f"# {TITLE}\n{TAGLINE}")
96
+ with gr.Row():
97
+ with gr.Column(scale=1):
98
+ image = gr.Image(label="Your moment", type="pil", sources=["upload", "webcam"])
99
+ video = gr.Video(
100
+ label="…or a clip (glasses or phone, narrates the middle of the scene)",
101
+ sources=["upload"],
102
+ )
103
+ style = gr.Dropdown(
104
+ choices=style_choices(),
105
+ value=DEFAULT_STYLE_KEY,
106
+ label="Director's cut",
107
+ )
108
+ hint = gr.Textbox(
109
+ label="Anything the narrator should know? (optional)",
110
+ placeholder="e.g. this is my third coffee today",
111
+ )
112
+ go = gr.Button("🎬 Roll narration", variant="primary")
113
+ with gr.Column(scale=1):
114
+ card = gr.Image(label="Title card", interactive=False)
115
+ narration = gr.Textbox(label="The narrator says…", lines=8)
116
+ speak_btn = gr.Button("🔊 Read it to me", variant="secondary")
117
+ audio = gr.Audio(label="The narrator speaks…", interactive=False)
118
+ gr.Markdown(
119
+ f"<sub>backend: `{backend.name}` · model: `{backend.model_id}` · "
120
+ "no cloud APIs — Off the Grid 🏕️</sub>"
121
+ )
122
+ go.click(_narrate_handler, inputs=[image, style, hint], outputs=[card, narration])
123
+ image.change(_narrate_handler, inputs=[image, style, hint], outputs=[card, narration])
124
+ video.change(_narrate_video_handler, inputs=[video, style, hint], outputs=[card, narration])
125
+ speak_btn.click(_speak_handler, inputs=[narration], outputs=[audio])
126
+ return demo
src/small_cuts/upload_budget.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sqlite3
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from collections.abc import Callable
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .persistence import persistent_path
16
+
17
+ UPLOAD_BUDGET_DB_ENV = "SMALL_CUTS_UPLOAD_BUDGET_DB"
18
+ DAILY_GPU_BUDGET_SECONDS_ENV = "SMALL_CUTS_DAILY_GPU_BUDGET_SECONDS"
19
+ GPU_RESERVATION_SECONDS_ENV = "SMALL_CUTS_GPU_SECONDS_PER_UPLOAD_RESERVATION"
20
+ UPLOAD_RESERVATION_TTL_SECONDS_ENV = "SMALL_CUTS_UPLOAD_RESERVATION_TTL_SECONDS"
21
+ DEFAULT_BUDGET_DB = "~/.small-cuts/upload-budget.sqlite3"
22
+ DEFAULT_DAILY_LIMIT_S = 20 * 60
23
+ DEFAULT_RESERVE_S = 60
24
+ DEFAULT_RESERVATION_TTL_S = 30 * 60
25
+
26
+ _DAILY_SCHEMA = """\
27
+ CREATE TABLE IF NOT EXISTS upload_daily_budget (
28
+ day TEXT PRIMARY KEY,
29
+ used_s REAL NOT NULL DEFAULT 0,
30
+ reserved_s REAL NOT NULL DEFAULT 0,
31
+ updated_at TEXT NOT NULL
32
+ )"""
33
+ _RESERVATIONS_SCHEMA = """\
34
+ CREATE TABLE IF NOT EXISTS upload_reservations (
35
+ reservation_id TEXT PRIMARY KEY,
36
+ day TEXT NOT NULL,
37
+ reserved_s REAL NOT NULL,
38
+ created_at_s REAL NOT NULL,
39
+ expires_at_s REAL NOT NULL
40
+ )"""
41
+ _RESERVATIONS_INDEX = """\
42
+ CREATE INDEX IF NOT EXISTS idx_upload_reservations_day_expiry
43
+ ON upload_reservations(day, expires_at_s)
44
+ """
45
+ _NO_EXPIRY_S = 1.0e20
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class BudgetDecision:
50
+ allowed: bool
51
+ token: dict[str, Any] | None = None
52
+ message: str = ""
53
+ remaining_s: float = 0.0
54
+
55
+
56
+ class DailyProcessingBudget:
57
+ """Global daily upload-processing budget.
58
+
59
+ The budget is intentionally identity-free. It protects demo GPU credits by tracking committed
60
+ processing seconds for the current UTC day. A preflight reservation counts against the limit
61
+ immediately, so queued concurrent requests cannot all pass the same remaining-capacity check.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ db_path: str | Path | None = None,
67
+ *,
68
+ daily_limit_s: float | None = None,
69
+ reserve_s: float | None = None,
70
+ reservation_ttl_s: float | None = None,
71
+ now_fn: Callable[[], float] | None = None,
72
+ ) -> None:
73
+ self.db_path = Path(
74
+ db_path
75
+ or os.environ.get(UPLOAD_BUDGET_DB_ENV)
76
+ or persistent_path("upload-budget.sqlite3")
77
+ or DEFAULT_BUDGET_DB
78
+ )
79
+ self.db_path = self.db_path.expanduser().resolve()
80
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
81
+ self.daily_limit_s = float(
82
+ daily_limit_s
83
+ if daily_limit_s is not None
84
+ else os.environ.get(DAILY_GPU_BUDGET_SECONDS_ENV, DEFAULT_DAILY_LIMIT_S)
85
+ )
86
+ self.reserve_s = float(
87
+ reserve_s
88
+ if reserve_s is not None
89
+ else os.environ.get(GPU_RESERVATION_SECONDS_ENV, DEFAULT_RESERVE_S)
90
+ )
91
+ self.reservation_ttl_s = float(
92
+ reservation_ttl_s
93
+ if reservation_ttl_s is not None
94
+ else os.environ.get(UPLOAD_RESERVATION_TTL_SECONDS_ENV, DEFAULT_RESERVATION_TTL_S)
95
+ )
96
+ self._now = now_fn or time.time
97
+ self._lock = threading.Lock()
98
+ self._db = sqlite3.connect(self.db_path, check_same_thread=False, isolation_level=None)
99
+ self._db.execute("PRAGMA busy_timeout = 5000")
100
+ self._db.execute(_DAILY_SCHEMA)
101
+ self._db.execute(_RESERVATIONS_SCHEMA)
102
+ self._db.execute(_RESERVATIONS_INDEX)
103
+ self._migrate_legacy_reserved_seconds()
104
+
105
+ @classmethod
106
+ def from_env(cls) -> DailyProcessingBudget:
107
+ return cls()
108
+
109
+ def try_reserve(self, reserve_s: float | None = None) -> BudgetDecision:
110
+ reserve = max(0.0, float(self.reserve_s if reserve_s is None else reserve_s))
111
+ day = self._day_key()
112
+ with self._lock:
113
+ with self._transaction():
114
+ self._ensure_day(day)
115
+ self._expire_stale_reservations(day)
116
+ used_s, reserved_s = self._totals(day)
117
+ committed_s = used_s + reserved_s
118
+ remaining_s = max(0.0, self.daily_limit_s - committed_s)
119
+ if reserve > remaining_s:
120
+ return BudgetDecision(
121
+ allowed=False,
122
+ message="Demo daily GPU budget reached. Uploads reopen tomorrow.",
123
+ remaining_s=remaining_s,
124
+ )
125
+ reservation_id = uuid.uuid4().hex
126
+ now_s = self._now()
127
+ expires_at_s = (
128
+ now_s + self.reservation_ttl_s if self.reservation_ttl_s > 0 else _NO_EXPIRY_S
129
+ )
130
+ self._db.execute(
131
+ """
132
+ INSERT INTO upload_reservations
133
+ (reservation_id, day, reserved_s, created_at_s, expires_at_s)
134
+ VALUES (?, ?, ?, ?, ?)
135
+ """,
136
+ (reservation_id, day, reserve, now_s, expires_at_s),
137
+ )
138
+ self._db.execute(
139
+ "UPDATE upload_daily_budget SET updated_at = ? WHERE day = ?",
140
+ (self._now_iso(), day),
141
+ )
142
+ return BudgetDecision(
143
+ allowed=True,
144
+ token={"day": day, "reservation_id": reservation_id, "reserved_s": reserve},
145
+ remaining_s=max(0.0, remaining_s - reserve),
146
+ )
147
+
148
+ def finish(self, token: Any, elapsed_s: float) -> None:
149
+ if not isinstance(token, dict):
150
+ return
151
+ day = str(token.get("day") or "")
152
+ if not day:
153
+ return
154
+ reserved_s = max(0.0, float(token.get("reserved_s") or 0.0))
155
+ elapsed = max(0.0, float(elapsed_s or 0.0))
156
+ reservation_id = str(token.get("reservation_id") or "")
157
+ with self._lock, self._transaction():
158
+ self._ensure_day(day)
159
+ if reservation_id:
160
+ self._db.execute(
161
+ """
162
+ DELETE FROM upload_reservations
163
+ WHERE day = ? AND reservation_id = ?
164
+ """,
165
+ (day, reservation_id),
166
+ )
167
+ elif reserved_s > 0:
168
+ self._release_reserved_seconds(day, reserved_s)
169
+ self._db.execute(
170
+ """
171
+ UPDATE upload_daily_budget
172
+ SET used_s = used_s + ?, reserved_s = 0, updated_at = ?
173
+ WHERE day = ?
174
+ """,
175
+ (elapsed, self._now_iso(), day),
176
+ )
177
+
178
+ def seconds_used_today(self) -> float:
179
+ with self._lock:
180
+ day = self._day_key()
181
+ with self._transaction():
182
+ self._ensure_day(day)
183
+ used_s, _ = self._totals(day)
184
+ return used_s
185
+
186
+ def seconds_committed_today(self) -> float:
187
+ with self._lock:
188
+ day = self._day_key()
189
+ with self._transaction():
190
+ self._ensure_day(day)
191
+ self._expire_stale_reservations(day)
192
+ used_s, reserved_s = self._totals(day)
193
+ return used_s + reserved_s
194
+
195
+ def close(self) -> None:
196
+ self._db.close()
197
+
198
+ def _day_key(self) -> str:
199
+ return datetime.fromtimestamp(self._now(), tz=timezone.utc).date().isoformat()
200
+
201
+ def _now_iso(self) -> str:
202
+ return datetime.fromtimestamp(self._now(), tz=timezone.utc).isoformat()
203
+
204
+ def _ensure_day(self, day: str) -> None:
205
+ self._db.execute(
206
+ """
207
+ INSERT OR IGNORE INTO upload_daily_budget (day, used_s, reserved_s, updated_at)
208
+ VALUES (?, 0, 0, ?)
209
+ """,
210
+ (day, self._now_iso()),
211
+ )
212
+
213
+ def _totals(self, day: str) -> tuple[float, float]:
214
+ row = self._db.execute(
215
+ "SELECT used_s FROM upload_daily_budget WHERE day = ?", (day,)
216
+ ).fetchone()
217
+ if row is None:
218
+ return 0.0, 0.0
219
+ reserved = self._db.execute(
220
+ "SELECT COALESCE(SUM(reserved_s), 0) FROM upload_reservations WHERE day = ?",
221
+ (day,),
222
+ ).fetchone()
223
+ return float(row[0]), float(reserved[0] if reserved is not None else 0.0)
224
+
225
+ def _expire_stale_reservations(self, day: str) -> None:
226
+ if self.reservation_ttl_s <= 0:
227
+ return
228
+ self._db.execute(
229
+ "DELETE FROM upload_reservations WHERE day = ? AND expires_at_s <= ?",
230
+ (day, self._now()),
231
+ )
232
+
233
+ def _release_reserved_seconds(self, day: str, reserved_s: float) -> None:
234
+ remaining = reserved_s
235
+ rows = self._db.execute(
236
+ """
237
+ SELECT reservation_id, reserved_s
238
+ FROM upload_reservations
239
+ WHERE day = ?
240
+ ORDER BY created_at_s ASC, reservation_id ASC
241
+ """,
242
+ (day,),
243
+ ).fetchall()
244
+ for reservation_id, row_reserved_s in rows:
245
+ if remaining <= 0:
246
+ break
247
+ row_reserved = float(row_reserved_s)
248
+ if row_reserved <= remaining:
249
+ self._db.execute(
250
+ "DELETE FROM upload_reservations WHERE reservation_id = ?",
251
+ (reservation_id,),
252
+ )
253
+ remaining -= row_reserved
254
+ else:
255
+ self._db.execute(
256
+ """
257
+ UPDATE upload_reservations
258
+ SET reserved_s = ?
259
+ WHERE reservation_id = ?
260
+ """,
261
+ (row_reserved - remaining, reservation_id),
262
+ )
263
+ remaining = 0
264
+
265
+ def _migrate_legacy_reserved_seconds(self) -> None:
266
+ with self._transaction():
267
+ rows = self._db.execute(
268
+ """
269
+ SELECT day, reserved_s, updated_at
270
+ FROM upload_daily_budget
271
+ WHERE reserved_s > 0
272
+ """
273
+ ).fetchall()
274
+ now_s = self._now()
275
+ for day, reserved_s, updated_at in rows:
276
+ reserved = float(reserved_s)
277
+ if reserved <= 0:
278
+ continue
279
+ parsed = _parse_updated_at(str(updated_at))
280
+ created_at_s = parsed.timestamp() if parsed is not None else now_s
281
+ if self.reservation_ttl_s > 0 and now_s - created_at_s > self.reservation_ttl_s:
282
+ continue
283
+ self._db.execute(
284
+ """
285
+ INSERT OR IGNORE INTO upload_reservations
286
+ (reservation_id, day, reserved_s, created_at_s, expires_at_s)
287
+ VALUES (?, ?, ?, ?, ?)
288
+ """,
289
+ (
290
+ f"legacy-{day}",
291
+ str(day),
292
+ reserved,
293
+ created_at_s,
294
+ created_at_s + self.reservation_ttl_s
295
+ if self.reservation_ttl_s > 0
296
+ else _NO_EXPIRY_S,
297
+ ),
298
+ )
299
+ self._db.execute("UPDATE upload_daily_budget SET reserved_s = 0 WHERE reserved_s > 0")
300
+
301
+ @contextmanager
302
+ def _transaction(self):
303
+ self._db.execute("BEGIN IMMEDIATE")
304
+ try:
305
+ yield
306
+ except BaseException:
307
+ self._db.execute("ROLLBACK")
308
+ raise
309
+ else:
310
+ self._db.execute("COMMIT")
311
+
312
+
313
+ def _parse_updated_at(value: str) -> datetime | None:
314
+ if not value:
315
+ return None
316
+ try:
317
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
318
+ except ValueError:
319
+ return None
320
+ if parsed.tzinfo is None:
321
+ return parsed.replace(tzinfo=timezone.utc)
322
+ return parsed.astimezone(timezone.utc)
src/small_cuts/upload_library.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import copy
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import sqlite3
10
+ import threading
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import unquote
15
+
16
+ from PIL import Image
17
+
18
+ from .hf_relay import GRADIO_FILE_ROUTE, gradio_file_url
19
+ from .persistence import persistent_path
20
+
21
+ UPLOAD_LIBRARY_DIR_ENV = "SMALL_CUTS_UPLOAD_LIBRARY_DIR"
22
+ DEFAULT_UPLOAD_LIBRARY_DIR = "~/.small-cuts/uploads"
23
+ SOURCE = "upload"
24
+
25
+ _DATA_URI_RE = re.compile(r"^data:(?P<mime>[-\w./+]+);base64,(?P<body>.*)$", re.DOTALL)
26
+ _MEDIA_FILENAMES = {
27
+ "frame_url": "frame.jpg",
28
+ "card_url": "card.webp",
29
+ "audio_url": "voice.wav",
30
+ "clip_url": "clip.mp4",
31
+ }
32
+ _SCHEMA = """\
33
+ CREATE TABLE IF NOT EXISTS upload_scenes (
34
+ scene_id TEXT PRIMARY KEY,
35
+ created_at TEXT NOT NULL,
36
+ stored_at TEXT NOT NULL,
37
+ payload TEXT NOT NULL
38
+ )"""
39
+
40
+
41
+ class LocalUploadLibrary:
42
+ """Persistent upload shelf backed by SQLite plus stable media files."""
43
+
44
+ def __init__(self, root: str | Path | None = None) -> None:
45
+ base = (
46
+ root
47
+ or os.environ.get(UPLOAD_LIBRARY_DIR_ENV)
48
+ or persistent_path("upload-library")
49
+ or DEFAULT_UPLOAD_LIBRARY_DIR
50
+ )
51
+ self.root = Path(base).expanduser().resolve()
52
+ self.media_dir = self.root / "media"
53
+ self.media_dir.mkdir(parents=True, exist_ok=True)
54
+ self._lock = threading.Lock()
55
+ self._db = sqlite3.connect(self.root / "uploads.sqlite3", check_same_thread=False)
56
+ self._db.execute(_SCHEMA)
57
+ self._db.commit()
58
+
59
+ @classmethod
60
+ def from_env(cls) -> LocalUploadLibrary:
61
+ return cls()
62
+
63
+ def save_scene(
64
+ self,
65
+ scene: dict[str, Any],
66
+ *,
67
+ source_video_path: str | Path | None = None,
68
+ ) -> dict[str, Any]:
69
+ payload = _json_safe_scene(scene)
70
+ scene_id = _safe_path_segment(str(payload.get("scene_id") or "upload-scene"))
71
+ scene_dir = self.media_dir / scene_id
72
+ scene_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ media = payload.get("media") if isinstance(payload.get("media"), dict) else {}
75
+ payload["media"] = dict(media)
76
+ for key, filename in _MEDIA_FILENAMES.items():
77
+ value = payload["media"].get(key)
78
+ if isinstance(value, str):
79
+ payload["media"][key] = self._materialize_value(value, scene_dir, filename)
80
+
81
+ frame_src = payload.pop("frame_src", None)
82
+ if isinstance(frame_src, str):
83
+ payload["media"]["frame_url"] = self._materialize_value(
84
+ frame_src, scene_dir, "frame.jpg"
85
+ )
86
+
87
+ audio_src = payload.pop("audio_src", None)
88
+ if isinstance(audio_src, str):
89
+ payload["media"]["audio_url"] = self._materialize_value(
90
+ audio_src, scene_dir, "voice.wav"
91
+ )
92
+
93
+ clip_src = payload.pop("clip_src", None)
94
+ if source_video_path:
95
+ clip_name = f"clip{Path(source_video_path).suffix or '.mp4'}"
96
+ payload["media"]["clip_url"] = self._copy_file(
97
+ Path(source_video_path), scene_dir, clip_name
98
+ )
99
+ elif isinstance(clip_src, str):
100
+ payload["media"]["clip_url"] = self._materialize_value(clip_src, scene_dir, "clip.mp4")
101
+
102
+ card_thumb = scene.get("card_thumb")
103
+ if isinstance(card_thumb, Image.Image):
104
+ card_path = scene_dir / "card.webp"
105
+ card_thumb.save(card_path, "WEBP")
106
+ payload["media"]["card_url"] = gradio_file_url(card_path)
107
+
108
+ payload.pop("card_thumb", None)
109
+ payload.setdefault("scene_id", scene_id)
110
+ payload.setdefault("created_at", _now_iso())
111
+ payload.setdefault("visibility", "private")
112
+ payload["source"] = SOURCE
113
+ payload["source_icon"] = SOURCE
114
+
115
+ stored_at = _now_iso()
116
+ with self._lock, self._db:
117
+ self._db.execute(
118
+ """
119
+ INSERT INTO upload_scenes (scene_id, created_at, stored_at, payload)
120
+ VALUES (?, ?, ?, ?)
121
+ ON CONFLICT(scene_id) DO UPDATE SET
122
+ created_at = excluded.created_at,
123
+ stored_at = excluded.stored_at,
124
+ payload = excluded.payload
125
+ """,
126
+ (
127
+ str(payload["scene_id"]),
128
+ str(payload["created_at"]),
129
+ stored_at,
130
+ json.dumps(payload, sort_keys=True),
131
+ ),
132
+ )
133
+ return copy.deepcopy(payload)
134
+
135
+ def list_scenes(self, limit: int = 60) -> list[dict[str, Any]]:
136
+ with self._lock:
137
+ rows = self._db.execute(
138
+ """
139
+ SELECT payload FROM (
140
+ SELECT created_at, scene_id, payload
141
+ FROM upload_scenes
142
+ ORDER BY created_at DESC, scene_id DESC
143
+ LIMIT ?
144
+ )
145
+ ORDER BY created_at ASC, scene_id ASC
146
+ """,
147
+ (int(limit),),
148
+ ).fetchall()
149
+ return [json.loads(row[0]) for row in rows]
150
+
151
+ def static_paths(self) -> list[Path]:
152
+ return [self.root]
153
+
154
+ def close(self) -> None:
155
+ with self._lock:
156
+ self._db.close()
157
+
158
+ def _materialize_value(self, value: str, scene_dir: Path, filename: str) -> str:
159
+ if value.startswith(("http://", "https://")):
160
+ return value
161
+ data = _decode_data_uri(value)
162
+ if data is not None:
163
+ target = scene_dir / filename
164
+ target.write_bytes(data)
165
+ return gradio_file_url(target)
166
+ source = _local_path_from_url(value)
167
+ if source is not None and source.is_file():
168
+ return self._copy_file(source, scene_dir, filename)
169
+ return value
170
+
171
+ def _copy_file(self, source: Path, scene_dir: Path, filename: str) -> str:
172
+ target = scene_dir / _safe_path_segment(filename)
173
+ if source.resolve() != target.resolve():
174
+ shutil.copy2(source, target)
175
+ return gradio_file_url(target)
176
+
177
+
178
+ def _json_safe_scene(scene: dict[str, Any]) -> dict[str, Any]:
179
+ clone = {key: value for key, value in scene.items() if key != "card_thumb"}
180
+ return json.loads(json.dumps(clone, default=str))
181
+
182
+
183
+ def _decode_data_uri(value: str) -> bytes | None:
184
+ match = _DATA_URI_RE.match(value)
185
+ if not match:
186
+ return None
187
+ return base64.b64decode(match.group("body"), validate=False)
188
+
189
+
190
+ def _local_path_from_url(value: str) -> Path | None:
191
+ if value.startswith(GRADIO_FILE_ROUTE):
192
+ return Path(unquote(value[len(GRADIO_FILE_ROUTE) :]))
193
+ path = Path(value)
194
+ return path if path.is_absolute() else None
195
+
196
+
197
+ def _safe_path_segment(value: str) -> str:
198
+ cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "-" for ch in value)
199
+ return cleaned.strip(".-") or "item"
200
+
201
+
202
+ def _now_iso() -> str:
203
+ return datetime.now(timezone.utc).isoformat()
src/small_cuts/viewer.py ADDED
The diff for this file is too large to render. See raw diff