macayaven commited on
Commit
24e5b39
·
verified ·
1 Parent(s): e43a899

Upload folder using huggingface_hub

Browse files
Files changed (45) hide show
  1. .gitattributes +10 -0
  2. .gitignore +29 -0
  3. .hfignore +18 -0
  4. LICENSE +21 -0
  5. README.md +147 -6
  6. app.py +101 -0
  7. pyproject.toml +70 -0
  8. requirements.txt +10 -0
  9. src/small_cuts/__init__.py +9 -0
  10. src/small_cuts/_icons.py +24 -0
  11. src/small_cuts/demo_seed.py +81 -0
  12. src/small_cuts/engine/__init__.py +9 -0
  13. src/small_cuts/engine/__main__.py +33 -0
  14. src/small_cuts/engine/app.py +152 -0
  15. src/small_cuts/engine/library.py +377 -0
  16. src/small_cuts/engine/read_gate.py +108 -0
  17. src/small_cuts/engine/session.py +470 -0
  18. src/small_cuts/eval.py +162 -0
  19. src/small_cuts/frames.py +82 -0
  20. src/small_cuts/hf_relay.py +306 -0
  21. src/small_cuts/modal_upload.py +87 -0
  22. src/small_cuts/narrator.py +491 -0
  23. src/small_cuts/observability.py +74 -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/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
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ dist/
7
+ build/
8
+
9
+ # Tooling
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ uv.lock
13
+
14
+ # Models / large artifacts
15
+ *.gguf
16
+ models/
17
+ flagged/
18
+
19
+ # OS / editor
20
+ .DS_Store
21
+ .idea/
22
+ .vscode/
23
+
24
+ # Env — never commit secrets
25
+ .env
26
+ .env.*
27
+ .playwright-mcp/
28
+ ui-proposal.jpeg
29
+ aimode-content.yml
.hfignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git/
2
+ .venv/
3
+ .github/
4
+ .claude/
5
+ ios/
6
+ kb/
7
+ docs/
8
+ modal_app/
9
+ tests/
10
+ scripts/
11
+ CLAUDE.md
12
+ __pycache__/
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+ .playwright-mcp/
16
+ xcodebuild-*.log
17
+ *.mov
18
+ *.mp4
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carlos Crespo Macaya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,13 +1,154 @@
1
  ---
2
  title: Small Cuts
3
- emoji: 🐠
4
- colorFrom: purple
5
- colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 6.18.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: Small Cuts
3
+ emoji: 🎬
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.18.0
 
8
  app_file: app.py
9
+ hf_oauth: true
10
+ pinned: true
11
+ license: mit
12
+ short_description: A deadpan narrator for your life, from small open models.
13
+ models:
14
+ - Qwen/Qwen3-VL-8B-Instruct
15
+ tags:
16
+ - track:wood
17
+ - sponsor:modal
18
+ - achievement:offgrid
19
+ - achievement:offbrand
20
+ - achievement:llama
21
+ - achievement:fieldnotes
22
  ---
23
 
24
+ # Small Cuts 🎬
25
+
26
+ > *"And that was the moment Carlos realized the coffee had been decaf all along."*
27
+
28
+ **Small Cuts** turns first-person moments into grounded, cinematic, **spoken** narration —
29
+ an omniscient, slightly-too-honest narrator in the spirit of *The Invention of Lying* —
30
+ using only **small (≤32B) open models**. No script, no cloud LLM: a small vision-language
31
+ model watches your moment and a small text-to-speech voice speaks the line, the way a film
32
+ narrator would if your life were the film.
33
+
34
+ There is exactly **one narrator**: a single deadpan, unnamed voice. No menus, no director to
35
+ pick. You point at what's happening; it tells you what it means.
36
+
37
+ This is a submission to the **Build Small Hackathon** ("Small Models, Big Adventures" —
38
+ Gradio × Hugging Face).
39
+
40
+ ---
41
+
42
+ ## Two paths, one finished cut
43
+
44
+ Small Cuts is a deliberate comparison of two techniques that both land as the **same** finished
45
+ artifact in this Space — a clip with a generated title, narration, Kokoro voice, synced
46
+ captions, a library tile, and a source badge.
47
+
48
+ | Path | Who | Channel | What it proves | Badge |
49
+ |---|---|---|---|---|
50
+ | **Whole video, one pass** | A judge, in this Space | Past events, any recording | It is **real and running** — verify it yourself, no access to the maker's hardware needed | `source="upload"` |
51
+ | **Pieces + hints** | The wearer (Ray-Ban Meta glasses / iOS) | Clips from ~3s ago | The **soul** — embodied, off-grid, narrated in-ear *while the moment is still recent past* | `source="glasses"` |
52
+
53
+ The public Space never reaches the maker's local hardware; the private home engine never exposes
54
+ inference hardware to the public. Same output shape, two very different journeys.
55
+
56
+ ---
57
+
58
+ ## How judges can try it
59
+
60
+ The Space is the **view platform + library** half of the loop:
61
+
62
+ - **A live stage** with the current moment and movie-style subtitles over a constant dark bar,
63
+ advancing with the voice-over.
64
+ - **Voice-over replay** in a compact custom player whose video, sound, captions, and progress all
65
+ share one audio clock.
66
+ - **A public library** of real Ray-Ban Meta glasses moments, generated through the same local
67
+ engine path so the channel is never empty. Source clips and mark points are curated; the visible
68
+ titles, narration, voice, thumbnails, and clips are **produced by Small Cuts**.
69
+ - **"Try it"** — a tucked-away, HF-login upload drawer. Sign in, drop a short video, and a private
70
+ **Modal** GPU service runs the real Qwen + Kokoro pipeline and replays your generated cut in the
71
+ same theater. This is the judge-verifiable path: no glasses or iOS required. **Privacy-first:**
72
+ your upload is narrated *only for your session* — it's never added to the public library or shown
73
+ to anyone else, and a refresh clears it. The library you browse is separate, curated, persistent
74
+ content (real glasses moments); your private try-it never mixes into it.
75
+
76
+ ---
77
+
78
+ ## Architecture in one glance
79
+
80
+ ```
81
+ Ray-Ban Meta glasses ──image frames──▶ home engine (small VLM + TTS) ──▶ narration in your ear
82
+
83
+ └──── finished cuts ────▶ the Space (watch · library)
84
+
85
+ judge's browser ──short video──▶ Modal GPU (Qwen3-VL-8B + Kokoro) ──▶ finished cut in the Space
86
+ ```
87
+
88
+ You walk through a moment, tap **Action!**, then tap **Cut!** when the scene has a readable beat.
89
+ The narrator watches a selected first-person frame and speaks one grounded, deadpan line back in
90
+ your ear while the moment is still recent past. The finished cut lands in the Space as a short POV
91
+ clip with synced captions, title, voice, and library thumbnail.
92
+
93
+ ---
94
+
95
+ ## How it was built
96
+
97
+ | Piece | Choice | Why |
98
+ |---|---|---|
99
+ | Narrator (VLM) | `Qwen/Qwen3-VL-8B-Instruct` | Strong grounded captioning at **8B — well under 32B** |
100
+ | Voice (TTS) | **Kokoro** (24 kHz) | Tiny, expressive, open; one signature deadpan delivery |
101
+ | Space runtime | Gradio 6 on CPU | Public theater + library; uploads call Modal instead of warming models |
102
+ | Judge upload service | **Modal** GPU app (`small-cuts-postcut`) | Finished-video verification path with real Qwen + Kokoro output |
103
+ | Local live engine | FastAPI WS home node, **llama.cpp** | The in-ear loop + demo video; no cloud LLM/TTS API |
104
+ | Capture | iOS app for Ray-Ban Meta glasses (`ios/SmallCuts/`) | First-person moments, the way it's meant to be lived |
105
+
106
+ Built by **Carlos Crespo Macaya** as architect and lead. Development was accelerated with an
107
+ AI toolchain — Claude (Opus) for design critique, Codex (GPT-5.x) for paired implementation,
108
+ GLM for review, and Gemini for eval — all directed by Carlos.
109
+
110
+ ---
111
+
112
+ ## ≤32B compliance
113
+
114
+ Every model is small and open-weight:
115
+
116
+ - **Narrator:** `Qwen/Qwen3-VL-8B-Instruct` — **8B parameters**, comfortably under the 32B cap.
117
+ - **Voice:** **Kokoro** — a tiny open TTS model.
118
+
119
+ There is no cloud LLM anywhere in the loop. The live, in-ear path runs entirely on local hardware
120
+ through **llama.cpp**; the judge upload path runs the same small models on a Modal GPU so reviewers
121
+ can verify real output without touching the maker's machine.
122
+
123
+ ---
124
+
125
+ ## Hackathon compliance
126
+
127
+ | Rule | How Small Cuts complies |
128
+ |---|---|
129
+ | Gradio app hosted as a Space under the org | Final submission promotes to `build-small-hackathon/small-cuts` after personal-profile staging passes |
130
+ | Every model < 32B | 8B VLM narrator + small Kokoro TTS, all open weights |
131
+ | Demo video | Filmed POV with Ray-Ban Meta glasses → narrated by the app *(link below)* |
132
+ | Social post | Linked from this README *(link below)* |
133
+ | Track 2 — **Thousand Token Wood** (`track:wood`) | Whimsical, delightful, AI-load-bearing, original |
134
+ | Best Use of Modal (`sponsor:modal`) | The judge upload path runs Qwen + Kokoro on a Modal GPU |
135
+ | Off the Grid (`achievement:offgrid`) | Live inference/TTS run on local hardware; the public Space reads finished cuts only |
136
+ | Off-Brand (`achievement:offbrand`) | Custom cinematic frontend past the stock Gradio look |
137
+ | Llama (`achievement:llama`) | The live engine runs through `llama.cpp` |
138
+ | Field Notes (`achievement:fieldnotes`) | Public write-up — [field notes on the HF blog](https://huggingface.co/blog/build-small-hackathon/small-cuts-field-notes) |
139
+
140
+ ### Submission links
141
+
142
+ - 📹 **Demo video (YouTube):** _TODO — add public link before submission_
143
+ - 📣 **Social post (Reddit):** _TODO — add link before submission_
144
+ - 📣 **Social post (LinkedIn):** _TODO — add link before submission_
145
+ - 📝 **Field notes / write-up:** [hf.co/blog/build-small-hackathon/small-cuts-field-notes](https://huggingface.co/blog/build-small-hackathon/small-cuts-field-notes)
146
+
147
+ > **Integrity note:** every preselected/hero clip in this Space is narrated by the **actual
148
+ > Small Cuts pipeline** (real Qwen3-VL-8B + Kokoro output) — never hand-written narration.
149
+
150
+ ---
151
+
152
+ ## License
153
+
154
+ Released under the **MIT License** — see [`LICENSE`](LICENSE) in the repository.
app.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import gradio as gr
18
+ from starlette.exceptions import StarletteDeprecationWarning
19
+
20
+ ROOT = Path(__file__).resolve().parent
21
+ SRC = ROOT / "src"
22
+ if str(SRC) not in sys.path:
23
+ sys.path.insert(0, str(SRC))
24
+
25
+ warnings.filterwarnings(
26
+ "ignore",
27
+ message=r".*HTTP_422_UNPROCESSABLE_ENTITY.*HTTP_422_UNPROCESSABLE_CONTENT.*",
28
+ category=StarletteDeprecationWarning,
29
+ )
30
+
31
+ ON_SPACE = bool(os.environ.get("SPACE_ID"))
32
+ ENGINE_MODE = bool(os.environ.get("SMALL_CUTS_ENGINE_URL", "").strip())
33
+
34
+ from small_cuts.hf_relay import RELAY_BUCKET_ENV # noqa: E402
35
+
36
+ RELAY_MODE = bool(os.environ.get(RELAY_BUCKET_ENV, "").strip())
37
+ MODAL_UPLOAD_MODE = bool(os.environ.get("SMALL_CUTS_MODAL_API_URL", "").strip())
38
+ VIEWER_ONLY_MODE = ENGINE_MODE or RELAY_MODE or MODAL_UPLOAD_MODE
39
+ NEEDS_LOCAL_INFERENCE = not VIEWER_ONLY_MODE
40
+
41
+ try:
42
+ import spaces # noqa: F401 (must precede torch imports for ZeroGPU)
43
+ except ImportError: # local dev / CI: no ZeroGPU
44
+ spaces = None
45
+
46
+ if ON_SPACE and NEEDS_LOCAL_INFERENCE:
47
+ os.environ.setdefault("SMALL_CUTS_BACKEND", "transformers")
48
+ os.environ.setdefault("SMALL_CUTS_TTS_BACKEND", "kokoro")
49
+
50
+ from small_cuts.observability import capture_exception, init_sentry # noqa: E402
51
+ from small_cuts.space_hooks import install_relay_hooks # noqa: E402
52
+ from small_cuts.viewer import THEME, build_viewer_app # noqa: E402
53
+
54
+ init_sentry()
55
+
56
+ STARTUP_ERROR: str | None = None
57
+
58
+
59
+ def _allow_cpu_inference() -> bool:
60
+ return os.environ.get("SMALL_CUTS_ALLOW_CPU_INFERENCE", "").strip().lower() in (
61
+ "1",
62
+ "true",
63
+ "yes",
64
+ )
65
+
66
+
67
+ def _validate_startup_mode() -> None:
68
+ if ON_SPACE and NEEDS_LOCAL_INFERENCE and spaces is None and not _allow_cpu_inference():
69
+ raise RuntimeError(
70
+ "refusing local inference on a Space without ZeroGPU; configure relay, engine, "
71
+ "or Modal upload mode, or set SMALL_CUTS_ALLOW_CPU_INFERENCE=1 explicitly"
72
+ )
73
+
74
+
75
+ def _degraded_app(message: str) -> gr.Blocks:
76
+ with gr.Blocks(title="Small Cuts") as degraded:
77
+ gr.Markdown(
78
+ f"# Small Cuts is temporarily unavailable\n\nStartup configuration failed: `{message}`"
79
+ )
80
+ return degraded
81
+
82
+
83
+ def _build_demo() -> gr.Blocks:
84
+ _validate_startup_mode()
85
+ # In engine/relay/upload modes the Space is a public reader and upload front door, so it must
86
+ # not warm local model weights. In local-inference mode, ZeroGPU loads lazily inside the
87
+ # Gradio handler decorated with @spaces.GPU.
88
+ app = build_viewer_app()
89
+ install_relay_hooks(app.app)
90
+ return app
91
+
92
+
93
+ try:
94
+ demo = _build_demo()
95
+ except Exception as exc:
96
+ capture_exception(exc)
97
+ STARTUP_ERROR = str(exc)
98
+ demo = _degraded_app(STARTUP_ERROR)
99
+
100
+ if __name__ == "__main__":
101
+ demo.launch(theme=THEME)
pyproject.toml ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "small-cuts"
3
+ version = "0.1.0"
4
+ description = "An omniscient cinematic narrator for moments of your life, powered by small open models (Build Small Hackathon)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "gradio>=5.0",
9
+ "itsdangerous>=2.2",
10
+ "pillow>=10.0",
11
+ "pillow-heif>=0.18",
12
+ "av>=12.0",
13
+ "huggingface-hub>=1.19",
14
+ "sentry-sdk>=2.0",
15
+ # core viewer dep: decodes seed durations + writes generated voice-overs (viewer.py),
16
+ # imported at module scope — must be in the base install (and CI), not just the tts extra.
17
+ "soundfile>=0.12",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ local = [
22
+ "transformers>=4.49",
23
+ "torch>=2.4",
24
+ "torchvision>=0.19",
25
+ "accelerate>=1.0",
26
+ ]
27
+ tts = [
28
+ "kokoro>=0.9",
29
+ "soundfile>=0.12",
30
+ ]
31
+ llama = [
32
+ "llama-cpp-python>=0.3",
33
+ ]
34
+ engine = [
35
+ "fastapi>=0.110",
36
+ "uvicorn>=0.30",
37
+ "websockets>=12",
38
+ "jsonschema>=4.21",
39
+ "soundfile>=0.12",
40
+ "httpx>=0.27",
41
+ ]
42
+ dev = [
43
+ "pytest>=8.0",
44
+ "ruff>=0.8",
45
+ "jsonschema>=4.21",
46
+ ]
47
+
48
+ [build-system]
49
+ requires = ["hatchling"]
50
+ build-backend = "hatchling.build"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/small_cuts"]
54
+
55
+ [tool.hatch.build.targets.wheel.force-include]
56
+ "docs/contracts/control.schema.json" = "small_cuts/contracts/control.schema.json"
57
+ "docs/contracts/moment.schema.json" = "small_cuts/contracts/moment.schema.json"
58
+ "docs/contracts/narrated-scene.schema.json" = "small_cuts/contracts/narrated-scene.schema.json"
59
+ "docs/contracts/scene-audio.schema.json" = "small_cuts/contracts/scene-audio.schema.json"
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ addopts = "-q"
64
+
65
+ [tool.ruff]
66
+ line-length = 100
67
+ src = ["src", "tests"]
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
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/__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/__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,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ RELAY_BUCKET_ENV = "SMALL_CUTS_RELAY_BUCKET"
25
+ RELAY_PREFIX_ENV = "SMALL_CUTS_RELAY_PREFIX"
26
+ DEFAULT_RELAY_PREFIX = "relay"
27
+ RELAY_MANIFEST = "manifest.json"
28
+ RELAY_CACHE_DIR = Path(tempfile.gettempdir()) / "small-cuts-hf-relay"
29
+ GRADIO_FILE_ROUTE = "/gradio_api/file="
30
+ DEFAULT_SCENE_LIMIT = 60
31
+ MEDIA_KEYS = ("frame_url", "card_url", "audio_url", "clip_url")
32
+ PUBLISH_VISIBILITIES = frozenset({"shared", "public"})
33
+ HTTP_TIMEOUT_S = 20.0
34
+ MANIFEST_CACHE_TTL_S = 5.0
35
+ RELAY_CACHE_MAX_BYTES = 512 * 1024 * 1024
36
+
37
+
38
+ class BucketFileSystem(Protocol):
39
+ def cat(self, path: str) -> bytes: ...
40
+
41
+
42
+ class BucketRelayError(RuntimeError):
43
+ """Raised when the bucket relay cannot read or hydrate its manifest."""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class RelaySnapshot:
48
+ path: Path
49
+ scene_count: int
50
+ manifest_path: Path
51
+
52
+
53
+ def gradio_file_url(path: str | Path) -> str:
54
+ return f"{GRADIO_FILE_ROUTE}{quote(str(path))}"
55
+
56
+
57
+ def _normalize_prefix(prefix: str) -> str:
58
+ return prefix.strip().strip("/")
59
+
60
+
61
+ def _safe_bucket_slug(bucket_id: str) -> str:
62
+ return bucket_id.replace("/", "__")
63
+
64
+
65
+ class BucketSceneClient:
66
+ """Read finished NarratedScene payloads from a Hugging Face bucket manifest."""
67
+
68
+ base_url = ""
69
+ readonly = True
70
+
71
+ def __init__(
72
+ self,
73
+ bucket_id: str,
74
+ *,
75
+ prefix: str = DEFAULT_RELAY_PREFIX,
76
+ fs: BucketFileSystem | None = None,
77
+ cache_dir: str | Path | None = None,
78
+ register_static_paths: Any | None = None,
79
+ manifest_cache_ttl_s: float = MANIFEST_CACHE_TTL_S,
80
+ cache_max_bytes: int = RELAY_CACHE_MAX_BYTES,
81
+ ) -> None:
82
+ self.bucket_id = bucket_id.strip()
83
+ if not self.bucket_id:
84
+ raise ValueError("bucket_id is required")
85
+ self.prefix = _normalize_prefix(prefix)
86
+ self.root = f"hf://buckets/{self.bucket_id}"
87
+ if self.prefix:
88
+ self.root = f"{self.root}/{self.prefix}"
89
+ self._fs = fs
90
+ self.cache_dir = (
91
+ Path(cache_dir)
92
+ if cache_dir is not None
93
+ else (RELAY_CACHE_DIR / _safe_bucket_slug(self.bucket_id))
94
+ )
95
+ if cache_dir is None and self.prefix:
96
+ self.cache_dir = self.cache_dir / self.prefix
97
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
98
+ if register_static_paths is not None:
99
+ register_static_paths([self.cache_dir])
100
+ self.manifest_cache_ttl_s = manifest_cache_ttl_s
101
+ self.cache_max_bytes = cache_max_bytes
102
+ self._manifest_lock = threading.Lock()
103
+ self._media_lock = threading.Lock()
104
+ self._manifest_cache: tuple[float, list[dict[str, Any]]] | None = None
105
+ self._prune_cache()
106
+
107
+ @property
108
+ def fs(self) -> BucketFileSystem:
109
+ if self._fs is None:
110
+ from huggingface_hub import HfFileSystem
111
+
112
+ self._fs = HfFileSystem()
113
+ return self._fs
114
+
115
+ def list_scenes(self, limit: int = DEFAULT_SCENE_LIMIT) -> list[dict[str, Any]]:
116
+ with self._manifest_lock:
117
+ now = time.monotonic()
118
+ if (
119
+ self._manifest_cache is not None
120
+ and now - self._manifest_cache[0] < self.manifest_cache_ttl_s
121
+ ):
122
+ return copy.deepcopy(self._manifest_cache[1][-limit:])
123
+ try:
124
+ raw = self.fs.cat(f"{self.root}/{RELAY_MANIFEST}")
125
+ except FileNotFoundError:
126
+ self._manifest_cache = (now, [])
127
+ return []
128
+ try:
129
+ manifest = json.loads(raw.decode("utf-8"))
130
+ scenes = manifest.get("scenes", [])
131
+ if not isinstance(scenes, list):
132
+ raise ValueError("relay manifest scenes must be a list")
133
+ hydrated = []
134
+ for scene in scenes:
135
+ try:
136
+ hydrated.append(self._hydrate_scene(scene))
137
+ except FileNotFoundError:
138
+ continue
139
+ self._manifest_cache = (now, hydrated)
140
+ return copy.deepcopy(hydrated[-limit:])
141
+ except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
142
+ raise BucketRelayError(
143
+ f"could not read relay bucket {self.bucket_id}: {exc}"
144
+ ) from exc
145
+
146
+ def media_url(self, path: str | None) -> str | None:
147
+ if not path:
148
+ return None
149
+ if path.startswith(("http://", "https://", "data:", GRADIO_FILE_ROUTE)):
150
+ return path
151
+ relative = self._relative_media_path(path)
152
+ target = self.cache_dir / relative
153
+ with self._media_lock:
154
+ if not target.exists():
155
+ target.parent.mkdir(parents=True, exist_ok=True)
156
+ tmp = target.with_name(f".{target.name}.{os.getpid()}.{threading.get_ident()}.tmp")
157
+ try:
158
+ tmp.write_bytes(self.fs.cat(f"{self.root}/{relative.as_posix()}"))
159
+ tmp.replace(target)
160
+ finally:
161
+ tmp.unlink(missing_ok=True)
162
+ self._prune_cache(protected=target)
163
+ return gradio_file_url(target)
164
+
165
+ def _hydrate_scene(self, scene: dict[str, Any]) -> dict[str, Any]:
166
+ hydrated = copy.deepcopy(scene)
167
+ media = hydrated.get("media")
168
+ if not isinstance(media, dict):
169
+ hydrated["media"] = {}
170
+ return hydrated
171
+ for key in MEDIA_KEYS:
172
+ media[key] = self.media_url(media.get(key))
173
+ return hydrated
174
+
175
+ def _relative_media_path(self, path: str) -> Path:
176
+ value = path.strip().lstrip("/")
177
+ if self.prefix and value.startswith(f"{self.prefix}/"):
178
+ value = value[len(self.prefix) + 1 :]
179
+ relative = Path(value)
180
+ if relative.is_absolute() or ".." in relative.parts:
181
+ raise ValueError(f"unsafe bucket media path: {path}")
182
+ return relative
183
+
184
+ def _prune_cache(self, protected: Path | None = None) -> None:
185
+ if self.cache_max_bytes <= 0 or not self.cache_dir.exists():
186
+ return
187
+ protected_resolved = protected.resolve() if protected is not None else None
188
+ files = [path for path in self.cache_dir.rglob("*") if path.is_file()]
189
+ total = sum(path.stat().st_size for path in files)
190
+ if total <= self.cache_max_bytes:
191
+ return
192
+ for path in sorted(files, key=lambda item: item.stat().st_mtime):
193
+ if protected_resolved is not None and path.resolve() == protected_resolved:
194
+ continue
195
+ size = path.stat().st_size
196
+ path.unlink(missing_ok=True)
197
+ total -= size
198
+ if total <= self.cache_max_bytes:
199
+ break
200
+
201
+
202
+ def prepare_relay_snapshot(
203
+ engine_url: str,
204
+ output_dir: str | Path,
205
+ *,
206
+ limit: int = DEFAULT_SCENE_LIMIT,
207
+ include_private: bool = False,
208
+ source: str | None = None,
209
+ client: httpx.Client | None = None,
210
+ ) -> RelaySnapshot:
211
+ """Stage a bucket-ready manifest + media snapshot from the private engine."""
212
+ base_url = engine_url.rstrip("/")
213
+ output = Path(output_dir)
214
+ media_root = output / "media"
215
+ output.mkdir(parents=True, exist_ok=True)
216
+ media_root.mkdir(parents=True, exist_ok=True)
217
+ close_client = client is None
218
+ http = client or httpx.Client(timeout=HTTP_TIMEOUT_S)
219
+ try:
220
+ response = http.get(f"{base_url}/v1/scenes")
221
+ response.raise_for_status()
222
+ scenes = response.json().get("scenes", [])[-limit:]
223
+ published = [
224
+ _stage_scene_media(base_url, output, scene, http, source=source)
225
+ for scene in scenes
226
+ if _should_publish_scene(scene, include_private=include_private)
227
+ ]
228
+ finally:
229
+ if close_client:
230
+ http.close()
231
+ manifest = {
232
+ "contract_version": "1.1.0",
233
+ "published_at": datetime.now(timezone.utc).isoformat(),
234
+ "source_engine": base_url,
235
+ "scenes": published,
236
+ }
237
+ manifest_path = output / RELAY_MANIFEST
238
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
239
+ return RelaySnapshot(output, len(published), manifest_path)
240
+
241
+
242
+ def _should_publish_scene(scene: dict[str, Any], *, include_private: bool) -> bool:
243
+ if include_private:
244
+ return True
245
+ return scene.get("visibility") in PUBLISH_VISIBILITIES
246
+
247
+
248
+ def _stage_scene_media(
249
+ engine_url: str,
250
+ output_dir: Path,
251
+ scene: dict[str, Any],
252
+ client: httpx.Client,
253
+ *,
254
+ source: str | None = None,
255
+ ) -> dict[str, Any]:
256
+ staged = copy.deepcopy(scene)
257
+ if source:
258
+ staged["source"] = source
259
+ staged["source_icon"] = source
260
+ media = staged.get("media")
261
+ if not isinstance(media, dict):
262
+ staged["media"] = {}
263
+ return staged
264
+ scene_dir = _safe_path_segment(str(staged.get("scene_id") or "scene"))
265
+ for key in MEDIA_KEYS:
266
+ media[key] = _download_media(engine_url, output_dir, scene_dir, media.get(key), client)
267
+ return staged
268
+
269
+
270
+ def _download_media(
271
+ engine_url: str,
272
+ output_dir: Path,
273
+ scene_dir: str,
274
+ url: str | None,
275
+ client: httpx.Client,
276
+ ) -> str | None:
277
+ if not url:
278
+ return None
279
+ if url.startswith(("data:", GRADIO_FILE_ROUTE)):
280
+ return None
281
+ absolute = url if url.startswith(("http://", "https://")) else f"{engine_url}/{url.lstrip('/')}"
282
+ relative = _relay_media_path(url, scene_dir)
283
+ target = output_dir / relative
284
+ target.parent.mkdir(parents=True, exist_ok=True)
285
+ response = client.get(absolute)
286
+ response.raise_for_status()
287
+ target.write_bytes(response.content)
288
+ return relative.as_posix()
289
+
290
+
291
+ def _relay_media_path(url: str, scene_dir: str) -> Path:
292
+ parsed = urlparse(url)
293
+ source_path = (parsed.path if parsed.scheme else url.split("?", 1)[0]).lstrip("/")
294
+ if source_path.startswith("media/"):
295
+ relative = Path(source_path)
296
+ else:
297
+ filename = _safe_path_segment(Path(source_path).name or "media.bin")
298
+ relative = Path("media") / scene_dir / filename
299
+ if relative.is_absolute() or ".." in relative.parts:
300
+ raise ValueError(f"unsafe relay media path: {url}")
301
+ return relative
302
+
303
+
304
+ def _safe_path_segment(value: str) -> str:
305
+ cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "-" for ch in value)
306
+ return cleaned.strip(".-") or "item"
src/small_cuts/modal_upload.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ @dataclass
16
+ class ModalUploadClient:
17
+ base_url: str
18
+ token: str
19
+ http_client: httpx.Client | None = None
20
+ poll_interval_s: float = 1.0
21
+ timeout_s: float = 900.0
22
+
23
+ def submit_video(
24
+ self,
25
+ video_path: str | Path,
26
+ *,
27
+ uploader_hf_username: str,
28
+ style_key: str = "deadpan",
29
+ scene_hint: str = "",
30
+ ) -> dict[str, Any]:
31
+ close = self.http_client is None
32
+ client = self.http_client or httpx.Client(timeout=30.0, follow_redirects=True)
33
+ try:
34
+ job_id = self._submit(
35
+ client,
36
+ Path(video_path),
37
+ uploader_hf_username,
38
+ style_key,
39
+ scene_hint,
40
+ )
41
+ return self._poll(client, job_id)
42
+ finally:
43
+ if close:
44
+ client.close()
45
+
46
+ def _submit(
47
+ self,
48
+ client: httpx.Client,
49
+ video_path: Path,
50
+ uploader_hf_username: str,
51
+ style_key: str,
52
+ scene_hint: str,
53
+ ) -> str:
54
+ with video_path.open("rb") as handle:
55
+ response = client.post(
56
+ f"{self.base_url.rstrip('/')}/v1/cuts",
57
+ headers={"Authorization": f"Bearer {self.token}"},
58
+ data={
59
+ "style_key": style_key,
60
+ "scene_hint": scene_hint,
61
+ "uploader_hf_username": uploader_hf_username,
62
+ },
63
+ files={"video": (video_path.name, handle, "video/mp4")},
64
+ )
65
+ response.raise_for_status()
66
+ job_id = response.json().get("job_id")
67
+ if not isinstance(job_id, str) or not job_id:
68
+ raise ModalUploadError("Modal did not return a job_id")
69
+ return job_id
70
+
71
+ def _poll(self, client: httpx.Client, job_id: str) -> dict[str, Any]:
72
+ deadline = time.monotonic() + self.timeout_s
73
+ while time.monotonic() < deadline:
74
+ response = client.get(
75
+ f"{self.base_url.rstrip('/')}/v1/cuts/{job_id}",
76
+ headers={"Authorization": f"Bearer {self.token}"},
77
+ )
78
+ if response.status_code == 202:
79
+ time.sleep(self.poll_interval_s)
80
+ continue
81
+ response.raise_for_status()
82
+ payload = response.json()
83
+ scene = payload.get("scene")
84
+ if not isinstance(scene, dict):
85
+ raise ModalUploadError("Modal completed without a scene payload")
86
+ return scene
87
+ raise ModalUploadError("Modal upload timed out")
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/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/viewer.py ADDED
The diff for this file is too large to render. See raw diff