Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitattributes +10 -0
- .gitignore +29 -0
- .hfignore +18 -0
- LICENSE +21 -0
- README.md +147 -6
- app.py +101 -0
- pyproject.toml +70 -0
- requirements.txt +10 -0
- src/small_cuts/__init__.py +9 -0
- src/small_cuts/_icons.py +24 -0
- src/small_cuts/demo_seed.py +81 -0
- src/small_cuts/engine/__init__.py +9 -0
- src/small_cuts/engine/__main__.py +33 -0
- src/small_cuts/engine/app.py +152 -0
- src/small_cuts/engine/library.py +377 -0
- src/small_cuts/engine/read_gate.py +108 -0
- src/small_cuts/engine/session.py +470 -0
- src/small_cuts/eval.py +162 -0
- src/small_cuts/frames.py +82 -0
- src/small_cuts/hf_relay.py +306 -0
- src/small_cuts/modal_upload.py +87 -0
- src/small_cuts/narrator.py +491 -0
- src/small_cuts/observability.py +74 -0
- src/small_cuts/seed_media/desk-laptop.jpg +0 -0
- src/small_cuts/seed_media/desk-laptop.mp3 +3 -0
- src/small_cuts/seed_media/desk-laptop.mp4 +3 -0
- src/small_cuts/seed_media/night-drive.jpg +0 -0
- src/small_cuts/seed_media/night-drive.mp3 +3 -0
- src/small_cuts/seed_media/night-drive.mp4 +3 -0
- src/small_cuts/seed_media/rayuela.jpg +0 -0
- src/small_cuts/seed_media/rayuela.mp3 +3 -0
- src/small_cuts/seed_media/rayuela.mp4 +3 -0
- src/small_cuts/seed_media/street-parked-car.jpg +0 -0
- src/small_cuts/seed_media/street-parked-car.mp3 +3 -0
- src/small_cuts/seed_media/street-parked-car.mp4 +3 -0
- src/small_cuts/seed_media/the-stumble.jpg +0 -0
- src/small_cuts/seed_media/the-stumble.mp3 +3 -0
- src/small_cuts/seed_media/the-stumble.mp4 +3 -0
- src/small_cuts/space_hooks.py +113 -0
- src/small_cuts/styles.py +146 -0
- src/small_cuts/theme.py +73 -0
- src/small_cuts/title_card.py +183 -0
- src/small_cuts/tts.py +133 -0
- src/small_cuts/ui.py +126 -0
- 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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.18.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|