macayaven commited on
Commit
1995c34
·
verified ·
1 Parent(s): 036c0e9

Deploy hybrid Modal upload

Browse files
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Small Cuts Live
3
  emoji: 🎬
4
  colorFrom: indigo
5
  colorTo: purple
@@ -17,7 +17,7 @@ tags:
17
  - achievement:fieldnotes
18
  ---
19
 
20
- # Small Cuts Live 🎬
21
 
22
  > *"And that was the moment Carlos realized the coffee had been decaf all along."*
23
 
@@ -36,7 +36,7 @@ This is the **challenger submission** for the
36
  **June 15, 2026, 23:59 UTC**), the strategic successor to the original
37
  *Director's Cut* project.
38
 
39
- ## The soul of it — the live loop
40
 
41
  Small Cuts was born wearing glasses. The intended experience is a **live loop**:
42
 
@@ -46,14 +46,15 @@ Ray-Ban Meta glasses ──image frames──▶ home engine (small VLM + TTS)
46
  └──── finished cuts ────▶ the Space (watch · library)
47
  ```
48
 
49
- You walk through a moment; the narrator watches a selected first-person frame and speaks
50
- one grounded, deadpan line back in your ear while the moment is still *recent past*. The
51
- finished cut then lands in the Space as a short POV clip with synced captions, title, voice,
52
- and library thumbnail.
53
 
54
- **One pipeline, two surfaces:** the narration returns *in your ear* during capture, and lands
55
- in the **Space** below as a finished cut you can re-watch with film-style subtitles that
56
- crawl in sync with the voice and publish to a library.
 
57
 
58
  ## What's in this Space
59
 
@@ -66,7 +67,8 @@ The Space is the **view platform + library** half of the loop — a small stream
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. The source clips and mark points are curated; the
68
  visible titles, narration, voice, thumbnails, and clips are produced by Small Cuts.
69
- - **"Try it"** — a tucked-away sandbox (open only on request) to narrate your *own* short video.
 
70
 
71
  ## How it was built
72
 
@@ -74,7 +76,8 @@ The Space is the **view platform + library** half of the loop — a small stream
74
  |---|---|---|
75
  | Narrator (VLM) | `Qwen/Qwen3-VL-8B-Instruct` | Strong grounded captioning at 8B — well under 32B |
76
  | Voice (TTS) | **Kokoro** (24 kHz) | Tiny, expressive, open; one signature deadpan delivery |
77
- | Space runtime | Gradio 6 on CPU, viewer-only for live demo | The judged canvas: public theater + library |
 
78
  | Local live engine | FastAPI WS home node, **llama.cpp** | The in-ear loop + demo video; no cloud LLM/TTS API |
79
  | Capture | iOS app for Ray-Ban Meta glasses (`ios/SmallCuts/`) | First-person moments, the way it's meant to be lived |
80
 
@@ -117,6 +120,13 @@ SMALL_CUTS_BACKEND=transformers uv run --no-sync python app.py
117
  # run the real-time engine (needs `brew install llama.cpp`)
118
  SMALL_CUTS_BACKEND=llama_cpp SMALL_CUTS_TTS_BACKEND=kokoro uv run python -m small_cuts.engine
119
 
 
 
 
 
 
 
 
120
  # the gate (mirrors CI exactly)
121
  uv run ruff check . && uv run ruff format --check . && uv run pytest
122
  ```
 
1
  ---
2
+ title: Small Cuts
3
  emoji: 🎬
4
  colorFrom: indigo
5
  colorTo: purple
 
17
  - achievement:fieldnotes
18
  ---
19
 
20
+ # Small Cuts 🎬
21
 
22
  > *"And that was the moment Carlos realized the coffee had been decaf all along."*
23
 
 
36
  **June 15, 2026, 23:59 UTC**), the strategic successor to the original
37
  *Director's Cut* project.
38
 
39
+ ## The soul of it — the Action-to-Cut loop
40
 
41
  Small Cuts was born wearing glasses. The intended experience is a **live loop**:
42
 
 
46
  └──── finished cuts ────▶ the Space (watch · library)
47
  ```
48
 
49
+ You walk through a moment, tap **Action!**, then tap **Cut!** when the scene has a readable
50
+ beat. The narrator watches a selected first-person frame and speaks one grounded, deadpan line
51
+ back in your ear while the moment is still *recent past*. The finished cut then lands in the
52
+ Space as a short POV clip with synced captions, title, voice, and library thumbnail.
53
 
54
+ **One completed-cut experience, multiple inputs:** from the Space's point of view, glasses cuts
55
+ and authenticated browser uploads resolve to the same artifact shape: a finished video, generated
56
+ title, generated narration, Kokoro voice, synced captions, and a library tile. Glasses remain the
57
+ private wearer path; browser uploads are a judge-verifiable path with no glasses or iOS required.
58
 
59
  ## What's in this Space
60
 
 
67
  - **A public library** of real Ray-Ban Meta glasses moments, generated through the same local
68
  engine path so the channel is never empty. The source clips and mark points are curated; the
69
  visible titles, narration, voice, thumbnails, and clips are produced by Small Cuts.
70
+ - **"Try it"** — a tucked-away, HF-login upload drawer that sends your short video to a private
71
+ Modal post-cut service, then replays the generated cut in the same theater.
72
 
73
  ## How it was built
74
 
 
76
  |---|---|---|
77
  | Narrator (VLM) | `Qwen/Qwen3-VL-8B-Instruct` | Strong grounded captioning at 8B — well under 32B |
78
  | Voice (TTS) | **Kokoro** (24 kHz) | Tiny, expressive, open; one signature deadpan delivery |
79
+ | Space runtime | Gradio 6 on CPU | Public theater + library; uploads call Modal instead of warming models |
80
+ | Judge upload service | Modal GPU app (`small-cuts-postcut`) | Finished-video verification path with real Qwen + Kokoro output |
81
  | Local live engine | FastAPI WS home node, **llama.cpp** | The in-ear loop + demo video; no cloud LLM/TTS API |
82
  | Capture | iOS app for Ray-Ban Meta glasses (`ios/SmallCuts/`) | First-person moments, the way it's meant to be lived |
83
 
 
120
  # run the real-time engine (needs `brew install llama.cpp`)
121
  SMALL_CUTS_BACKEND=llama_cpp SMALL_CUTS_TTS_BACKEND=kokoro uv run python -m small_cuts.engine
122
 
123
+ # run the hybrid relay + Modal upload Space locally (token comes from your local secret env)
124
+ SMALL_CUTS_RELAY_BUCKET=build-small-hackathon/small-cuts-scenes \
125
+ SMALL_CUTS_RELAY_PREFIX=relay \
126
+ SMALL_CUTS_ENABLE_UPLOAD_SANDBOX=1 \
127
+ SMALL_CUTS_MODAL_API_URL=https://macayaven--small-cuts-postcut-api.modal.run \
128
+ uv run --no-sync python app.py
129
+
130
  # the gate (mirrors CI exactly)
131
  uv run ruff check . && uv run ruff format --check . && uv run pytest
132
  ```
app.py CHANGED
@@ -34,14 +34,16 @@ ENGINE_MODE = bool(os.environ.get("SMALL_CUTS_ENGINE_URL", "").strip())
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
- VIEWER_ONLY_MODE = ENGINE_MODE or RELAY_MODE
 
 
38
 
39
  try:
40
  import spaces # noqa: F401 (must precede torch imports for ZeroGPU)
41
  except ImportError: # local dev / CI: no ZeroGPU
42
  spaces = None
43
 
44
- if ON_SPACE and not VIEWER_ONLY_MODE:
45
  os.environ.setdefault("SMALL_CUTS_BACKEND", "transformers")
46
  os.environ.setdefault("SMALL_CUTS_TTS_BACKEND", "kokoro")
47
 
@@ -54,7 +56,7 @@ init_sentry()
54
  # Eager load: download + pack weights at startup, not on the first click.
55
  # The @spaces.GPU mark lives on the viewer's go-live handler (via ui._gpu;
56
  # ZeroGPU's startup scan only finds GPU functions on what Gradio binds).
57
- if not VIEWER_ONLY_MODE:
58
  _backend = narrator.get_backend()
59
  if spaces is not None and _backend.name == "transformers":
60
  _backend._load()
 
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
 
 
56
  # Eager load: download + pack weights at startup, not on the first click.
57
  # The @spaces.GPU mark lives on the viewer's go-live handler (via ui._gpu;
58
  # ZeroGPU's startup scan only finds GPU functions on what Gradio binds).
59
+ if NEEDS_LOCAL_INFERENCE:
60
  _backend = narrator.get_backend()
61
  if spaces is not None and _backend.name == "transformers":
62
  _backend._load()
docs/demo-readiness.md ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Demo-Readiness Checklist
2
+
3
+ Last updated: 2026-06-15 16:16 CEST.
4
+
5
+ ## Current Architecture Override - 2026-06-15
6
+
7
+ This section supersedes the older Cloudflare engine-mode checklist items below. Keep the old
8
+ evidence for audit/history, but do not use it as the active deploy posture for the final run.
9
+
10
+ - Active judged Space: `build-small-hackathon/small-cuts-live`, public, `cpu-basic`.
11
+ - Paused/private Spaces: `build-small-hackathon/small-cuts` and
12
+ `build-small-hackathon/small-cuts-buffer-poc`.
13
+ - The live Space is currently viewer-only. It reads finished scenes from the HF bucket relay, not
14
+ from Cloudflare or a live engine URL. The next implementation pass should make this same Space
15
+ hybrid by adding the judge upload path below.
16
+ - Current relay settings:
17
+ - `SMALL_CUTS_RELAY_BUCKET=build-small-hackathon/small-cuts-scenes`
18
+ - `SMALL_CUTS_RELAY_PREFIX=relay`
19
+ - `SMALL_CUTS_BACKEND=mock`
20
+ - `SMALL_CUTS_TTS_BACKEND=mock`
21
+ - The relay bucket currently has only the canary/health object; intentional library population is
22
+ still pending.
23
+ - Private live path remains: Ray-Ban Meta glasses -> iPhone app -> Mac Studio engine over
24
+ Tailnet/local network -> `SceneAudio` back to the iPhone/glasses after the wearer taps `Cut!`.
25
+ This local glasses-to-ear return path is non-negotiable.
26
+ - Public Space path is post-cut: after the take is completed, publish the finished clip, generated
27
+ title, narration, voice, thumbnail/poster, and manifest into the HF bucket relay.
28
+ - Test order for the final run:
29
+ 1. Short physical glasses smoke first, before populating the public library.
30
+ 2. Populate the library only from controlled, honest pipeline outputs.
31
+ 3. Run the full demo rehearsal after both private ear playback and public relay playback are
32
+ proven.
33
+ - Judge verification upload target for the next implementation pass: the submitted Gradio Space
34
+ must expose a finished-video upload path so judges can verify the app without glasses, iOS, or
35
+ local Tailnet access. Allow uploads up to 60 seconds by default and process them as completed
36
+ cuts. This matches an instant-clip/reel-length posture while keeping the Modal path bounded.
37
+ - Before deploying upload controls to the submitted org Space, prove this through the private Modal
38
+ app `small-cuts-postcut`.
39
+ - The final org Space should become a hybrid surface only after the Modal POC passes:
40
+ - relay/library mode for the live demo and public just-happened clips;
41
+ - on-demand upload mode for judge verification, with real Modal-hosted narration/TTS, not mock
42
+ output.
43
+ - Keep the submitted Space on `cpu-basic` for the Modal path. The Space should not warm Qwen/Kokoro;
44
+ it accepts uploads, requires HF login for upload only, calls Modal server-side, and renders the
45
+ returned scene.
46
+ - Modal upload inference should use the hackathon grant aggressively but bounded: H100 first with
47
+ A100-80GB and L40S fallbacks, one warm GPU container, one active buffer container, and up to four
48
+ GPU containers for parallel uploads. Do not use same-container GPU concurrency for Qwen/Kokoro
49
+ unless the model/TTS pipeline is explicitly proven thread-safe.
50
+ - Glasses-origin public clips should not go through Modal. `Action!` starts capture and `Cut!`
51
+ finalizes the take; after the local engine has produced clip, title, narration, and speech for
52
+ in-ear playback, the completed scene can be auto-published to the relay bucket by the local/admin
53
+ publisher. Those scenes should include `source="glasses"` and render a small glasses icon in the
54
+ top-left of the Space stage/library tile.
55
+ - Library population target: use longer, controlled, honest clip artifacts for the public relay
56
+ library instead of the current very short 24-frame samples.
57
+ - iOS restores wearer captions/status for the private glasses path: after `Cut!`, the phone shows a
58
+ waiting caption, then swaps to the generated narration when `SceneAudio` arrives. This baseline is
59
+ live post-cut, not continuous narration while rolling.
60
+ - `cpu-basic` remains correct for the Space if Modal handles judge-upload inference. ZeroGPU is now
61
+ only a contingency if Modal is deliberately ruled out after a measured failure.
62
+
63
+ ## Modal Post-Cut POC Evidence - 2026-06-15 15:30 CEST
64
+
65
+ - [x] Modal CLI authenticated as profile `macayaven`.
66
+ - [x] Stale Small Cuts Modal app `small-cuts-buffer-inference-poc` was stopped before creating the
67
+ new post-cut infra. The two interrupted `small-cuts-postcut` dev serve attempts are also stopped.
68
+ - [x] Modal secret `small-cuts-postcut` exists in Modal environment `main` with keys `HF_TOKEN` and
69
+ `SMALL_CUTS_MODAL_API_TOKEN`; no token values were written to repo docs.
70
+ - [x] Private Modal app `small-cuts-postcut` deployed at
71
+ `https://macayaven--small-cuts-postcut-api.modal.run`.
72
+ - [x] Modal GPU policy encoded as H100 -> A100-80GB -> L40S, with
73
+ `min_containers=1`, `buffer_containers=1`, `max_containers=4`, and no same-container GPU
74
+ concurrency on the Qwen/Kokoro worker.
75
+ - [x] Upload cap is now 60 seconds by default, with the Space-facing
76
+ `SMALL_CUTS_UPLOAD_MAX_SECONDS` remaining the deployment override. A 61-second synthetic MP4 was
77
+ rejected by the deployed Modal API with HTTP 422 and message
78
+ `video is too long; upload up to 60 seconds`.
79
+ - [x] Local syntax/lint smoke passed:
80
+ `python -m py_compile modal_app/small_cuts_postcut.py`,
81
+ `uv run ruff check modal_app/small_cuts_postcut.py src/small_cuts/engine/library.py tests/test_engine_library.py`,
82
+ `uv run ruff format --check modal_app/small_cuts_postcut.py src/small_cuts/engine/library.py tests/test_engine_library.py`,
83
+ and
84
+ `uv run pytest tests/test_engine_library.py::test_write_clip_mp4_can_disable_blends -q`.
85
+ - [x] Clean Modal dev serve smoke accepted `/private/tmp/small-cuts-codec-smoke.mp4` in 0.63 s,
86
+ completed with real Qwen/Kokoro output, and returned scene `modal-6da427b60106`:
87
+ title `The Green Screen`, model `Qwen/Qwen3-VL-8B-Instruct`, TTS `hexgrad/Kokoro-82M`.
88
+ - [x] HF bucket artifact writing smoke passed: `relay/uploads/modal-6da427b60106/` contains exactly
89
+ `scene.json`, `media/frame.jpg`, `media/card.webp`, `media/clip.mp4`, and `media/voice.wav`.
90
+ Relay root was checked after cleanup and contains only `health.txt` plus `uploads/`.
91
+ - [x] Space-side Modal client smoke accepted `/private/tmp/small-cuts-upload-5s.mp4` in 121.66 s
92
+ against the deployed endpoint, returned scene `modal-04f98e2795cd` with title
93
+ `The Smoke Test`, `source="upload"`, generated narration, `media/clip.mp4`, and
94
+ `media/voice.wav`.
95
+ - [x] HF bucket artifact writing smoke passed for
96
+ `relay/uploads/modal-04f98e2795cd/`: `scene.json`, `media/frame.jpg`, `media/card.webp`,
97
+ `media/clip.mp4`, and `media/voice.wav`.
98
+
99
+ ## Local Hybrid Verification - 2026-06-15 15:49 CEST
100
+
101
+ - [x] Full Python gate passed:
102
+ `uv run ruff check . && uv run ruff format --check . && uv run pytest`
103
+ (`205 passed`, 4 warnings).
104
+ - [x] Full iOS simulator gate passed with the plan command on `iPhone 17, OS=26.5`:
105
+ `66 tests`, `1` expected live-engine smoke skipped, `0` failures.
106
+ - [x] Local Gradio relay/upload-sandbox app launched on `http://127.0.0.1:7860` with
107
+ `SMALL_CUTS_ENABLE_UPLOAD_SANDBOX=1`, the deployed Modal URL, and the HF relay bucket.
108
+ - [x] Local Gradio OAuth mock sign-in worked as `macayaven`; the upload icon opened the
109
+ video-only upload drawer while anonymous cookie-free page load returned HTTP `200`.
110
+ - [x] Local >60-second guard rejected `/private/tmp/small-cuts-upload-61s.mp4` before any Modal
111
+ call with `Please upload a clip up to 60 seconds.`
112
+
113
+ ## Judged Space
114
+
115
+ - [x] Historical Space `build-small-hackathon/small-cuts` is public; active final target is
116
+ `build-small-hackathon/small-cuts-live` per the architecture override above.
117
+ - [x] Space runs on `cpu-basic` as a viewer/library, not as the inference host.
118
+ - [x] `SMALL_CUTS_ENGINE_URL` points at the public read gate.
119
+ - [x] Active Space endpoint uses the known-good quick tunnel:
120
+ `https://lincoln-greene-paragraph-tcp.trycloudflare.com`.
121
+ - [x] Visibility controls are disabled in Space engine mode.
122
+ - [x] Space loads a live engine scene through Cloudflare.
123
+ - [x] Space fetches `frame.jpg`, `card.webp`, `voice.wav`, and `clip.mp4` through `/media/*`.
124
+ - [x] Custom player uses file-backed `<audio id="sc-voice">` as the master clock.
125
+ - [x] Play/pause is bound to trusted DOM gestures instead of a Gradio callback.
126
+ - [x] Latest Space source deploy includes final thumbnail/title polish:
127
+ `3654f4e89f44a994f64332bdea980290f7c2039f`.
128
+ - [x] Space README-only copy update deployed as `8dfd628308a0849d4e9fc0baa646af94c33b949d`;
129
+ runtime returned to `RUNNING` and post-rebuild browser smoke still loaded the five hero cuts.
130
+ - [x] Browser automation verifies the active Space renders the real POV `rayuela` scene with
131
+ ready `clip.mp4` and `voice.wav` on desktop.
132
+ - [x] Browser automation verifies the active Space library uses real POV `frame.jpg` thumbnails
133
+ after the final deploy.
134
+ - [x] Public library is populated with five preselected first-person glasses clips sent through the
135
+ regular `/v1/session` engine pipeline, using 24-frame key-marked moment envelopes.
136
+ - [x] Public generated library serves each scene with `frame.jpg`, `card.webp`, `voice.wav`, and
137
+ 24-frame / 6 fps `clip.mp4` through the Cloudflare read gate.
138
+ - [ ] Human browser smoke: click play on the Space and confirm video, sound, captions, and progress advance together.
139
+ - [x] Gradio API smoke verifies the active Space `_tick` renders the latest generated title, video
140
+ stage, Cloudflare `clip.mp4`, ready `voice.wav`, visibility `public`, and five gallery captions.
141
+ - [x] Mobile viewport smoke verifies no document overflow, ready `clip.mp4`/`voice.wav`, captions,
142
+ and the five generated library thumbnails.
143
+ - [ ] Physical mobile smoke on iPhone Safari.
144
+
145
+ ## Private Live Path
146
+
147
+ - [x] Mac Studio engine listens privately on Tailnet-compatible `:8077`.
148
+ - [x] Public Cloudflare path points to read gate on `127.0.0.1:8078`, not directly to the engine.
149
+ - [x] Public `GET /v1/scenes` returns `200`.
150
+ - [x] Public `GET /v1/session` returns `403`.
151
+ - [x] Public `PATCH /v1/scenes/*` returns `403`.
152
+ - [x] Synthetic 24-frame moment returns `ack accepted` and `SceneAudio`.
153
+ - [x] Synthetic scene stores both audio and `clip.mp4`.
154
+ - [x] Real seed POV clip (`rayuela.mp4`) returns `ack accepted`, `SceneAudio`, idle status,
155
+ and public `clip.mp4`/`voice.wav` through the read gate.
156
+ - [x] Latest local engine restart is on the retained-storage-task fix, then warmed with
157
+ `rayuela.mp4` scene `162a9e89-3494-45d2-b28e-bf2a03d8c3cf` on the final thumbnail code.
158
+ - [x] Final warm-up scene stores `clip.mp4`, `voice.wav`, and a POV `frame.jpg`; active Space
159
+ renders it as the newest cut with no desktop overflow.
160
+ - [x] Model-generated library audit populated a clean five-scene run from the preselected glasses
161
+ clips before the hero-library switch. Quality was mixed: car-door, night-drive, and rayuela were
162
+ usable; the desk-laptop opener was too literal because the selected frame emphasized an overlay.
163
+ - [x] Curated hero rows were archived into SQLite table
164
+ `hidden_scenes_20260615_regular_pipeline_swap` and removed from the active `scenes` table, with
165
+ media preserved.
166
+ - [x] The final public library now comes from session
167
+ `regular-pipeline-24f-keymark-glasses-20260615T083000Z`; the previous 24-frame final-frame run
168
+ is archived in `hidden_scenes_20260615_final_frame_24f`.
169
+ - [x] Public media smoke confirms all five active scenes return `200` for `clip.mp4`, `voice.wav`,
170
+ `frame.jpg`, and `card.webp`.
171
+ - [x] Hidden warm-up moment against the hero-root engine returned `SceneAudio` in 15.4 s on
172
+ 2026-06-15 09:09 CEST; the warm-up row/media were pruned, leaving exactly the five hero cuts
173
+ visible through Cloudflare.
174
+ - [x] `tmux` session `small-cuts-awake` is running `caffeinate -dimsu` to keep the Mac Studio
175
+ awake for the live services.
176
+ - [x] iOS simulator suite passes: 64 tests, 1 live-engine smoke skipped, 0 failures.
177
+ - [x] Opt-in Swift live-engine smoke passes against `ws://127.0.0.1:8077/v1/session`
178
+ with real `SceneAudio` in 4.4 s.
179
+ - [x] Physical-device prep: `xcrun xctrace list devices` sees the iPhone 14 Pro on iOS 26.6,
180
+ and `xcodebuild build` for destination `00008120-001045510C3B401E` succeeded with
181
+ command-line signing overrides only.
182
+ - [x] Current build installed to the physical iPhone via `xcrun devicectl device install app`.
183
+ - [x] Relay publisher dry-run against `http://127.0.0.1:8077` with
184
+ `--include-private --source glasses` staged one scene locally and wrote `source="glasses"` plus
185
+ `source_icon="glasses"` into the staged manifest without syncing the HF bucket.
186
+ - [x] Manual physical app launch happened after install; observed behavior is live post-cut:
187
+ narration returns after `Cut!`, not continuously while rolling.
188
+ - [ ] Physical iPhone simulated-source smoke after reinstalling the latest app.
189
+ - [ ] Physical Ray-Ban Meta glasses smoke: in-ear narration returns while the Space receives the same cut.
190
+
191
+ ## Submission Metadata
192
+
193
+ - [x] README identifies the app as the Gradio Space submission.
194
+ - [x] README documents models under 32B: Qwen3-VL-8B + Kokoro.
195
+ - [x] README claims `track:wood`, `achievement:offgrid`, `achievement:offbrand`, `achievement:llama`, and `achievement:fieldnotes`.
196
+ - [x] Redeploy README/frontmatter after this checklist update.
197
+ - [x] Verify the Space metadata shows `offgrid` and `llama` tags after redeploy.
198
+ - [ ] Make the field-notes URL public; `https://huggingface.co/blog/macayaven/small-cuts-field-notes`
199
+ returned `401` on 2026-06-15 09:06 CEST. Full publishable draft is in
200
+ `docs/field-notes-small-cuts.md`.
201
+ - [ ] Add demo video link.
202
+ - [ ] Add social post link.
203
+ - [ ] Run the submission analyzer before final submission.
204
+
205
+ ## Demo Video
206
+
207
+ - [ ] Capture one motion-rich first-person moment from the glasses.
208
+ - [ ] Show in-ear narration returning to the wearer.
209
+ - [ ] Show the HF Space receiving the same just-happened POV clip.
210
+ - [ ] Show the custom theater player replaying video, audio, captions, and title.
211
+ - [ ] Keep final cut under 2 minutes, ideally around 60-90 seconds.
212
+
213
+ ## Known Constraints
214
+
215
+ - Capture is image-frame only for this version; source audio is intentionally not part of the payload.
216
+ - The current iOS capture path is `Action!` -> `Cut!` -> local narration return. True rolling
217
+ micro-segment narration with chronological continuity metadata is planned next.
218
+ - The Space is the public viewer, while local hardware runs the live small-model inference/TTS path.
219
+ - Browser autoplay with sound is intentionally not promised; sound starts from the explicit play gesture.
220
+ - Cloudflare quick-tunnel hostnames are ephemeral. The named `small-cuts.carloscrespomacaya.com`
221
+ tunnel direct smoke is `200` for reads and `403` for writes, but the HF Space stayed on
222
+ "Signal lost" when pointed at it during this run. Keep the Space on the known-good quick tunnel
223
+ until that Cloudflare/HF interaction is isolated.
224
+ - The current public library root is `/private/tmp/small-cuts-demo-library-generated-24f-20260615`.
225
+ A full curated-library backup exists at
226
+ `/private/tmp/small-cuts-demo-library-hero-20260615.pre-generated-backup-20260615T080924Z`.
227
+ Earlier model-generated roots remain available as
228
+ `/private/tmp/small-cuts-demo-library-hero-20260615` and
229
+ `/private/tmp/small-cuts-demo-library-curated-20260615`.
docs/field-notes-small-cuts.md CHANGED
@@ -11,6 +11,12 @@ model writes a grounded deadpan narration, Kokoro turns it into speech, the wear
11
  the same finished POV cut appears in a Hugging Face Gradio Space with video, voice, captions, title,
12
  and library thumbnail.
13
 
 
 
 
 
 
 
14
  ## The product shape
15
 
16
  The important decision was to keep the Space as the public product surface, not the capture path.
@@ -19,12 +25,11 @@ the master clock, captions follow the audio, and the library shows real POV fram
19
  generic generated cards.
20
 
21
  The glasses path stays private. During the live demo, the iPhone writes to the local engine over
22
- Tailnet. Public viewers and the Space only read finished scenes through a Cloudflare read gate:
23
- `GET /v1/scenes`, `GET /v1/scenes/stream`, and `/media/*`. The public hostname blocks the
24
- WebSocket capture path and visibility writes. That split is less glamorous than making everything
25
- public, but it is the right product boundary: private capture, public replay. For the hackathon
26
- demo this rides on a Cloudflare tunnel; a production version would turn that into durable auth,
27
- domain, and publishing controls rather than opening the capture path.
28
 
29
  ## Why small models worked
30
 
@@ -78,10 +83,15 @@ source sound is not needed for the narrator concept, and excluding it keeps priv
78
  simpler.
79
 
80
  The current live model reasons over sampled frames from an intentional Action-to-Cut take. The
81
- viewer can show a short POV clip assembled from those supplemental frames, but full video reasoning
82
- is a v2 direction. The next version should buffer a low-resolution clip directly into the Space,
83
- generate narration fragments with timestamps, and let the finished cut feel even closer to what the
84
- wearer experienced.
 
 
 
 
 
85
 
86
  Title generation now shares the model call with narration: the model returns a short grounded title
87
  and the spoken narration together, with deterministic title derivation kept as a fallback for
 
11
  the same finished POV cut appears in a Hugging Face Gradio Space with video, voice, captions, title,
12
  and library thumbnail.
13
 
14
+ The same public surface also supports a judge-verifiable browser upload path. From the Space's
15
+ point of view, origin should not change the playback contract: when a cut is complete, it appears
16
+ as a video with generated title, generated narration, generated voice, captions, and a thumbnail.
17
+ The difference is upstream. Glasses are the private physical input. Browser uploads are a finished
18
+ video path, authenticated with Hugging Face and processed by a private Modal GPU service.
19
+
20
  ## The product shape
21
 
22
  The important decision was to keep the Space as the public product surface, not the capture path.
 
25
  generic generated cards.
26
 
27
  The glasses path stays private. During the live demo, the iPhone writes to the local engine over
28
+ Tailnet. Public viewers and the Space read finished scenes through a relay/library surface rather
29
+ than opening the capture socket. That split is less glamorous than making everything public, but it
30
+ is the right product boundary: private capture, public replay. For the hackathon demo this relies
31
+ on local hardware plus hosted relay artifacts; a production version would turn that into durable
32
+ auth, domain, and publishing controls rather than opening the capture path.
 
33
 
34
  ## Why small models worked
35
 
 
83
  simpler.
84
 
85
  The current live model reasons over sampled frames from an intentional Action-to-Cut take. The
86
+ wearer hears the generated line after Cut, and the Space receives the completed cut. The viewer can
87
+ show a short POV clip assembled from supplemental frames, but rolling micro-segment narration is the
88
+ next production-grade version.
89
+
90
+ That next version should send small chronological segments while the take is still rolling, attach
91
+ device metadata such as time of day, motion, coarse location when explicitly permitted, and source
92
+ state, and let the narrator maintain continuity across fragments. The public Space would still show
93
+ the same completed-cut theater experience, but the wearer path would feel more like a narrator
94
+ thinking alongside the moment rather than only after it.
95
 
96
  Title generation now shares the model call with narration: the model returns a short grounded title
97
  and the spoken narration together, with deterministic title derivation kept as a fallback for
docs/submission-assets-2026-06-15.md ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Submission Assets - 2026-06-15
2
+
3
+ Working timestamp: 2026-06-15 14:16 UTC / 16:16 CEST.
4
+
5
+ Deadline: 2026-06-15 23:59 UTC / 2026-06-16 01:59 CEST.
6
+ Time remaining at this pass: about 9 hours 43 minutes.
7
+ Target latest demo start with six-hour buffer: 2026-06-15 17:59 UTC / 19:59 CEST.
8
+
9
+ ## Final Product Submission
10
+
11
+ Submit **Small Cuts** as a public Gradio Space that turns first-person glasses moments into
12
+ short cinematic cuts: a local small-model engine narrates the wearer POV, speaks it back in-ear,
13
+ and publishes the finished clip to a polished Space theater/library.
14
+
15
+ One-line pitch:
16
+
17
+ > A deadpan AI narrator for your life: Ray-Ban Meta POV moments become tiny narrated films,
18
+ > spoken in your ear and replayed on a public Hugging Face Space.
19
+
20
+ Short description:
21
+
22
+ > Small Cuts is a glasses-to-ear-to-Space loop. The iPhone sends image-frame moments from
23
+ > Ray-Ban Meta glasses to a private Mac Studio engine. Qwen3-VL-8B through llama.cpp writes a
24
+ > grounded deadpan narration, Kokoro speaks it, the wearer hears it, and the same just-happened
25
+ > POV clip lands in a Gradio theater with synced captions, title, voice, and library thumbnail.
26
+
27
+ Submission baseline:
28
+
29
+ - The public Space is source-agnostic once a cut is complete: glasses-origin scenes and
30
+ authenticated browser uploads render as the same theater artifact with video, generated title,
31
+ generated narration, Kokoro voice, synced captions, and a library tile.
32
+ - The browser upload path is for judge verification. It requires Hugging Face login, sends the
33
+ finished video to the private Modal `small-cuts-postcut` service, and does not promise glasses,
34
+ iOS, or real-time capture.
35
+ - The current glasses path is live post-cut: `Action!` records, `Cut!` sends the moment to the
36
+ private local engine, and `SceneAudio` returns to the wearer. Rolling micro-segment narration
37
+ while the take is still in progress is the next production-grade phase.
38
+
39
+ ## Why It Can Win
40
+
41
+ - It is not another chat wrapper. The product gesture is physical: look, mark, hear, replay.
42
+ - The judged Space is polished and alive: five real first-person glasses clips now populate the
43
+ public library after being sent through the same WebSocket/read-gated media path as live cuts.
44
+ - The architecture maps cleanly to the hackathon quests: small open models, Gradio Space, custom
45
+ Off-Brand frontend, local/offgrid inference, and llama.cpp.
46
+ - The demo has an obvious "show a friend" moment: ordinary life becomes a tiny film with a
47
+ narrator that notices the absurdity.
48
+ - If hardware is unstable, the fallback is still strong: real glasses footage, local narration,
49
+ Kokoro voice, and Space replay.
50
+
51
+ ## Known Weaknesses
52
+
53
+ - The live model narrates one selected frame per moment; the mini-MP4 gives context in the viewer
54
+ but the VLM is not yet doing full video reasoning.
55
+ - The current iOS wearer path is not continuous narration while rolling; narration returns after
56
+ `Cut!`. The planned next phase is chronological micro-segments with device metadata and continuity
57
+ state.
58
+ - Browser audio cannot autoplay; the Space correctly requires a user click before sound.
59
+ - Public viewing depends on the current Cloudflare quick tunnel until the named tunnel/HF
60
+ interaction is isolated.
61
+ - The public library now uses engine-generated narration, so it is honest as a model-quality sample.
62
+ The take end point is curated per source clip, matching how a wearer would tap Cut! when the
63
+ moment is readable.
64
+
65
+ ## Current Public Generated Library
66
+
67
+ Live root: `/private/tmp/small-cuts-demo-library-generated-24f-20260615`
68
+
69
+ Public read gate:
70
+
71
+ ```text
72
+ https://lincoln-greene-paragraph-tcp.trycloudflare.com
73
+ ```
74
+
75
+ Scenes visible in the Space:
76
+
77
+ | Order | Title | Role |
78
+ |---:|---|---|
79
+ | 1 | A hand, palm up, blocks the camera's view, revealing a... | Builder/desk sample; still literal but coherent |
80
+ | 2 | The camera lingers on a concrete alleyway at night, the... | Night urban atmosphere |
81
+ | 3 | The car door is open, revealing a black interior and a... | Strong physical POV situation |
82
+ | 4 | The driver grips the steering wheel with both hands, thumbs... | Motion/interior sample |
83
+ | 5 | The yellow circle with numbers is painted on the floor... | Strongest current generated hero cut |
84
+
85
+ Recommended library/thumbnail hero:
86
+
87
+ ```text
88
+ The yellow circle with numbers is painted on the floor...
89
+ scene_id: aead3e4d-2459-4638-81b8-74abddd2a149
90
+ poster: /private/tmp/small-cuts-demo-library-generated-24f-20260615/media/aead3e4d-2459-4638-81b8-74abddd2a149/frame.jpg
91
+ clip: /private/tmp/small-cuts-demo-library-generated-24f-20260615/media/aead3e4d-2459-4638-81b8-74abddd2a149/clip.mp4
92
+ ```
93
+
94
+ Reason: it is visually legible, currently active on the Space, uses the regular engine path, and
95
+ has the best current generated line: "as if marking a spot for something that never happened."
96
+
97
+ ## Demo Video Plan
98
+
99
+ Target duration: 60-90 seconds.
100
+
101
+ Shot list:
102
+
103
+ 1. 3-5 seconds: show the live Space already populated with the five-cut library.
104
+ 2. 6-10 seconds: show iPhone app connected to `ws://mac-studio.tail48bab7.ts.net:8077/v1/session`
105
+ or current Tailnet MagicDNS equivalent.
106
+ 3. 10-25 seconds: wear glasses and capture one readable motion-rich moment.
107
+ 4. 25-40 seconds: trigger/mark the moment and capture in-ear narration returning.
108
+ 5. 40-60 seconds: show the same cut appear in the HF Space.
109
+ 6. 60-80 seconds: click play in the Space; show video, Kokoro voice, captions, progress, title,
110
+ and library thumbnail moving together.
111
+ 7. Final 5 seconds: hold on the Space URL and the "Small Cuts" brand.
112
+
113
+ Primary live take criteria:
114
+
115
+ - Move for at least four seconds before marking.
116
+ - Choose a scene with one readable object/situation, not a visually noisy crowd.
117
+ - Avoid faces if possible.
118
+ - Do not trigger several marks in a row; one clean moment beats a cluttered library update.
119
+ - Warm the engine with one throwaway moment before recording the hero take.
120
+
121
+ Fallback demo if glasses/Bluetooth wobble:
122
+
123
+ - Use a real glasses-recorded POV clip.
124
+ - Send it through the local engine as a simulated moment.
125
+ - Show in the video that the Space receives a finished cut with clip, voice, captions, title, and
126
+ thumbnail. State truthfully that the judged Space is the product surface and the live glasses
127
+ path is the intended input path under test.
128
+
129
+ ## Social Post Draft
130
+
131
+ Short version:
132
+
133
+ > Built **Small Cuts** for the @huggingface Build Small Hackathon: Ray-Ban Meta POV moments become
134
+ > tiny narrated films. A local Qwen3-VL-8B + Kokoro engine speaks the narration in-ear, while a
135
+ > Gradio Space replays the same just-happened cut with synced captions and a cinematic library.
136
+ > #buildsmall
137
+
138
+ Longer version:
139
+
140
+ > Small Cuts is my Build Small Hackathon submission: a deadpan AI narrator for ordinary life.
141
+ > Ray-Ban Meta glasses capture first-person moments, an iPhone sends image frames privately to a
142
+ > Mac Studio, Qwen3-VL-8B through llama.cpp writes a grounded narration, Kokoro speaks it back in
143
+ > the ear, and the finished POV cut lands in a public Gradio Space with video, voice, captions,
144
+ > title, and library thumbnail. It is deliberately small, local, and weirdly cinematic. #buildsmall
145
+
146
+ Add after posting:
147
+
148
+ ```text
149
+ Space: https://huggingface.co/spaces/build-small-hackathon/small-cuts-live
150
+ Demo: TODO
151
+ ```
152
+
153
+ ## Field Notes Draft
154
+
155
+ Suggested title:
156
+
157
+ ```text
158
+ Small Cuts: Building a narrator for the moments that just happened
159
+ ```
160
+
161
+ Outline:
162
+
163
+ 1. The product idea: life becomes small narrated cuts, not a chatbot.
164
+ 2. Why glasses: first-person context makes the narration feel lived, not uploaded.
165
+ 3. Why small models: Qwen3-VL-8B is enough when the prompt is grounded and the moment is chosen.
166
+ 4. Why local/offgrid: latency, privacy, and hackathon constraints all point to the home engine.
167
+ 5. The hard parts:
168
+ - selecting the right frame,
169
+ - returning in-ear audio quickly,
170
+ - making the Space feel like a streaming platform, not a model demo,
171
+ - keeping public read separate from private write.
172
+ 6. What worked:
173
+ - audio-clock-driven video/caption sync,
174
+ - real POV thumbnails,
175
+ - read-gated public Cloudflare endpoint,
176
+ - model-generated public library from real glasses clips.
177
+ 7. What is next:
178
+ - rolling micro-segment narration for the glasses path,
179
+ - optional device metadata for chronology and narrative continuity,
180
+ - full video reasoning over buffered clips,
181
+ - stronger title generation,
182
+ - durable named tunnel,
183
+ - publish controls/auth.
184
+
185
+ Closing paragraph:
186
+
187
+ > The best version of Small Cuts is not a camera feature. It is a private narrator that notices
188
+ > the little cinematic beats a person usually walks past, says one honest line in the ear, and
189
+ > leaves behind a tiny film that feels like it happened on purpose.
190
+
191
+ ## Final Submission Checklist
192
+
193
+ Official field guide requirements checked from `build-small-hackathon/field-guide`:
194
+
195
+ - Ship a Gradio Space inside the `build-small-hackathon` org.
196
+ - Record a public demo video.
197
+ - Publish one social media post.
198
+ - Put both links in the Space README.
199
+ - Put track and badge tags in the README YAML frontmatter.
200
+
201
+ - [x] Space is public and running on `cpu-basic`.
202
+ - [x] Deployed Space SHA is `3654f4e89f44a994f64332bdea980290f7c2039f`.
203
+ - [x] Space variable `SMALL_CUTS_ENGINE_URL` points at
204
+ `https://lincoln-greene-paragraph-tcp.trycloudflare.com`.
205
+ - [x] HF metadata includes `track:wood`, `achievement:offgrid`, `achievement:offbrand`,
206
+ `achievement:llama`, and `achievement:fieldnotes`.
207
+ - [x] Cloudflare public read gate serves the five key-marked generated scenes with `clip.mp4`,
208
+ `voice.wav`, `frame.jpg`, and `card.webp`.
209
+ - [x] Cloudflare blocks public `/v1/session` and `PATCH /v1/scenes/*` with `403`.
210
+ - [x] Space Gradio `_tick` smoke returns latest video/audio/subtitle markup and all five generated
211
+ gallery captions.
212
+ - [ ] Field Notes URL is public, not `401`.
213
+ - [ ] Demo video uploaded and linked in README.
214
+ - [ ] Social post published and linked in README.
215
+ - [ ] README redeployed to HF Space after links are inserted.
216
+ - [ ] Human click confirms Space play starts audio, video, captions, and progress together.
217
+ - [ ] Physical e2e: glasses/iPhone produce in-ear narration and the same cut appears in Space.
218
+ - [ ] Submission analyzer passes.
219
+
220
+ Deadline rule: if the Field Notes URL is still private near final submission, either make it public
221
+ through the HF web editor or remove `achievement:fieldnotes` from README/frontmatter before the
222
+ final analyzer pass. Do not submit a broken badge claim.
modal_app/small_cuts_postcut.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Annotated, Any
11
+
12
+ import fastapi
13
+ import modal
14
+ from fastapi import File, Form, Header, HTTPException, UploadFile
15
+ from fastapi.responses import JSONResponse
16
+
17
+ BUCKET_ID = "build-small-hackathon/small-cuts-scenes"
18
+ RELAY_PREFIX = "relay"
19
+ MAX_UPLOAD_BYTES = 80 * 1024 * 1024
20
+ MAX_UPLOAD_SECONDS = 60.0
21
+ MAX_SAMPLE_FRAMES = 40
22
+
23
+ image = (
24
+ modal.Image.debian_slim(python_version="3.11")
25
+ .apt_install("ffmpeg")
26
+ .pip_install(
27
+ "fastapi[standard]",
28
+ "huggingface-hub>=1.19",
29
+ "pillow>=10.0",
30
+ "pillow-heif>=0.18",
31
+ "av>=12.0",
32
+ "soundfile>=0.12",
33
+ "transformers>=4.49",
34
+ "torch>=2.4",
35
+ "torchvision>=0.19",
36
+ "accelerate>=1.0",
37
+ "kokoro>=0.9",
38
+ )
39
+ .add_local_dir("src", remote_path="/root/src")
40
+ .add_local_dir("docs/contracts", remote_path="/root/docs/contracts")
41
+ )
42
+
43
+ app = modal.App("small-cuts-postcut")
44
+ web_app = fastapi.FastAPI()
45
+
46
+
47
+ def _require_bearer(authorization: str | None) -> None:
48
+ expected = os.environ["SMALL_CUTS_MODAL_API_TOKEN"]
49
+ if authorization != f"Bearer {expected}":
50
+ raise HTTPException(status_code=401, detail="unauthorized")
51
+
52
+
53
+ def _video_duration_s(path: Path) -> float:
54
+ import av
55
+
56
+ with av.open(str(path)) as container:
57
+ if container.duration is not None:
58
+ return float(container.duration / 1_000_000)
59
+ stream = container.streams.video[0]
60
+ if stream.duration is not None and stream.time_base is not None:
61
+ return float(stream.duration * stream.time_base)
62
+ raise ValueError("could not determine video duration")
63
+
64
+
65
+ def _sample_interval_s(duration_s: float) -> float:
66
+ return max(0.5, duration_s / MAX_SAMPLE_FRAMES)
67
+
68
+
69
+ @app.function(
70
+ image=image,
71
+ timeout=60,
72
+ min_containers=1,
73
+ buffer_containers=1,
74
+ scaledown_window=1200,
75
+ secrets=[modal.Secret.from_name("small-cuts-postcut")],
76
+ )
77
+ @modal.concurrent(max_inputs=20, target_inputs=10)
78
+ @modal.asgi_app()
79
+ def api():
80
+ return web_app
81
+
82
+
83
+ @web_app.post("/v1/cuts")
84
+ async def accept_cut(
85
+ video: Annotated[UploadFile, File()],
86
+ style_key: Annotated[str, Form()] = "deadpan",
87
+ scene_hint: Annotated[str, Form()] = "",
88
+ uploader_hf_username: Annotated[str, Form()] = "unknown",
89
+ authorization: Annotated[str | None, Header()] = None,
90
+ ) -> dict[str, str]:
91
+ _require_bearer(authorization)
92
+ payload = await video.read()
93
+ if len(payload) > MAX_UPLOAD_BYTES:
94
+ raise HTTPException(status_code=413, detail="video is too large")
95
+ safe_filename = Path(video.filename or "upload.mp4").name
96
+ with tempfile.TemporaryDirectory(prefix="small-cuts-accept-") as tmp:
97
+ probe_path = Path(tmp) / safe_filename
98
+ probe_path.write_bytes(payload)
99
+ try:
100
+ duration_s = _video_duration_s(probe_path)
101
+ except Exception as exc:
102
+ raise HTTPException(status_code=422, detail="could not decode video") from exc
103
+ if duration_s > MAX_UPLOAD_SECONDS + 0.25:
104
+ raise HTTPException(
105
+ status_code=422,
106
+ detail=f"video is too long; upload up to {MAX_UPLOAD_SECONDS:.0f} seconds",
107
+ )
108
+ call = await process_cut.spawn.aio(
109
+ payload,
110
+ safe_filename,
111
+ style_key,
112
+ scene_hint,
113
+ uploader_hf_username,
114
+ duration_s,
115
+ )
116
+ return {"job_id": call.object_id}
117
+
118
+
119
+ @web_app.get("/v1/cuts/{job_id}")
120
+ def poll_cut(job_id: str, authorization: str | None = Header(default=None)):
121
+ _require_bearer(authorization)
122
+ call = modal.FunctionCall.from_id(job_id)
123
+ try:
124
+ return call.get(timeout=0)
125
+ except TimeoutError:
126
+ return JSONResponse({"status": "running"}, status_code=202)
127
+
128
+
129
+ @app.function(
130
+ image=image,
131
+ gpu=["H100", "A100-80GB", "L40S"],
132
+ timeout=900,
133
+ min_containers=1,
134
+ buffer_containers=1,
135
+ max_containers=4,
136
+ scaledown_window=1200,
137
+ secrets=[modal.Secret.from_name("small-cuts-postcut")],
138
+ )
139
+ def process_cut(
140
+ video_bytes: bytes,
141
+ filename: str,
142
+ style_key: str,
143
+ scene_hint: str,
144
+ uploader_hf_username: str,
145
+ upload_duration_s: float,
146
+ ) -> dict[str, Any]:
147
+ sys.path.insert(0, "/root/src")
148
+ os.environ.setdefault("SMALL_CUTS_BACKEND", "transformers")
149
+ os.environ.setdefault("SMALL_CUTS_TTS_BACKEND", "kokoro")
150
+
151
+ import soundfile as sf
152
+ from huggingface_hub import HfApi
153
+
154
+ from small_cuts.engine.library import _write_clip_mp4
155
+ from small_cuts.frames import pick_key_frame, sample_frames
156
+ from small_cuts.narrator import narrate
157
+ from small_cuts.title_card import render_title_card
158
+ from small_cuts.tts import speak
159
+
160
+ scene_id = f"modal-{uuid.uuid4().hex[:12]}"
161
+ work = Path(tempfile.mkdtemp(prefix="small-cuts-modal-"))
162
+ input_path = work / Path(filename).name
163
+ input_path.write_bytes(video_bytes)
164
+
165
+ frames = sample_frames(
166
+ input_path,
167
+ every_n_seconds=_sample_interval_s(upload_duration_s),
168
+ max_frames=MAX_SAMPLE_FRAMES,
169
+ )
170
+ if not frames:
171
+ raise RuntimeError("could not decode video frames")
172
+ key_frame = pick_key_frame(frames)
173
+ narration = narrate(key_frame, style_key=style_key, scene_hint=scene_hint)
174
+ speech = speak(narration.text)
175
+
176
+ upload_prefix = f"uploads/{scene_id}"
177
+ scene_dir = work / upload_prefix
178
+ media_dir = scene_dir / "media"
179
+ media_dir.mkdir(parents=True, exist_ok=True)
180
+ frame_path = media_dir / "frame.jpg"
181
+ card_path = media_dir / "card.webp"
182
+ clip_path = media_dir / "clip.mp4"
183
+ voice_path = media_dir / "voice.wav"
184
+ key_frame.convert("RGB").save(frame_path, "JPEG", quality=90)
185
+ render_title_card(narration.title or narration.text, style_key=style_key).save(
186
+ card_path, "WEBP"
187
+ )
188
+ _write_clip_mp4(clip_path, frames, fps=8, blend_steps=0)
189
+ sf.write(voice_path, speech.audio, speech.sample_rate)
190
+
191
+ scene = {
192
+ "scene_id": scene_id,
193
+ "title": narration.title,
194
+ "narration": narration.text,
195
+ "style_key": style_key,
196
+ "created_at": datetime.now(timezone.utc).isoformat(),
197
+ "visibility": "public",
198
+ "uploader_hf_username": uploader_hf_username,
199
+ "source": "upload",
200
+ "media": {
201
+ "frame_url": f"{upload_prefix}/media/frame.jpg",
202
+ "card_url": f"{upload_prefix}/media/card.webp",
203
+ "clip_url": f"{upload_prefix}/media/clip.mp4",
204
+ "audio_url": f"{upload_prefix}/media/voice.wav",
205
+ },
206
+ "duration": len(speech.audio) / speech.sample_rate if speech.sample_rate else None,
207
+ "model": narration.model_id,
208
+ "tts_model": speech.model_id,
209
+ }
210
+ (scene_dir / "scene.json").write_text(json.dumps(scene, indent=2) + "\n")
211
+ HfApi().sync_bucket(
212
+ source=str(scene_dir),
213
+ dest=f"hf://buckets/{BUCKET_ID}/{RELAY_PREFIX}/{upload_prefix}",
214
+ )
215
+ return {"status": "complete", "scene": scene}
pyproject.toml CHANGED
@@ -6,6 +6,7 @@ readme = "README.md"
6
  requires-python = ">=3.10"
7
  dependencies = [
8
  "gradio>=5.0",
 
9
  "pillow>=10.0",
10
  "pillow-heif>=0.18",
11
  "av>=12.0",
 
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",
requirements.txt CHANGED
@@ -5,5 +5,6 @@ pillow-heif>=0.18
5
  av>=12.0
6
  httpx>=0.27
7
  huggingface-hub>=1.19
 
8
  sentry-sdk>=2.0
9
  soundfile>=0.12
 
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
scripts/publish_hf_relay.py CHANGED
@@ -50,6 +50,11 @@ def parse_args() -> argparse.Namespace:
50
  action="store_true",
51
  help="Publish private scenes too. Use only for an intentional controlled demo.",
52
  )
 
 
 
 
 
53
  parser.add_argument(
54
  "--delete-extra",
55
  action="store_true",
@@ -80,6 +85,7 @@ def publish_once(args: argparse.Namespace) -> None:
80
  stage_dir,
81
  limit=args.limit,
82
  include_private=args.include_private,
 
83
  )
84
  dest = f"hf://buckets/{args.bucket}/{args.prefix.strip('/')}"
85
  if args.dry_run:
 
50
  action="store_true",
51
  help="Publish private scenes too. Use only for an intentional controlled demo.",
52
  )
53
+ parser.add_argument(
54
+ "--source",
55
+ default="",
56
+ help='Optional public provenance marker for staged scenes, e.g. "glasses".',
57
+ )
58
  parser.add_argument(
59
  "--delete-extra",
60
  action="store_true",
 
85
  stage_dir,
86
  limit=args.limit,
87
  include_private=args.include_private,
88
+ source=args.source.strip() or None,
89
  )
90
  dest = f"hf://buckets/{args.bucket}/{args.prefix.strip('/')}"
91
  if args.dry_run:
src/small_cuts/CLAUDE.md CHANGED
@@ -20,8 +20,21 @@ the canonical command list, and the architecture live in the **root `CLAUDE.md`*
20
  one instance per key — do **not** construct backends per call (re-loads 16 GB on the Space).
21
 
22
  ## Viewer modes + layout (`viewer.py`)
23
- - Decided at build time by **`SMALL_CUTS_ENGINE_URL`**: **set** → engine mode (polls `GET /v1/scenes`,
24
- visibility `PATCH` back); **unset** upload mode (the Space's local "go live" dropzone).
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  - **Layout (Review-3 theater):** full-width top bar (Voice-Cut brand mark + upload icon), then a
26
  two-column **theater** — left: 9:16 stage (ratio is a hard invariant) + display-only progress bar +
27
  control **pill** (rewind/forward = **clip-to-clip**; gr.Audio stripped to **play/pause + volume**;
@@ -42,6 +55,9 @@ the canonical command list, and the architecture live in the **root `CLAUDE.md`*
42
  strip-and-couple path instead of the swap, since the swap touches Space file-serving.)
43
 
44
  ## ZeroGPU gotchas (hard-won — see KB `…/space/`)
 
 
 
45
  - `@spaces.GPU` must mark the functions **Gradio binds** (the startup scan walks event handlers);
46
  decorating inner helpers → worker dies `No CUDA GPUs are available`.
47
  - **No torch forward in the main process ever** — TTS runs inside `@spaces.GPU(duration=…)` workers;
@@ -52,3 +68,7 @@ the canonical command list, and the architecture live in the **root `CLAUDE.md`*
52
  ## Design invariant
53
  - The Space is the **view platform + library**, not the capture path. Publishing/visibility happens
54
  **only here**, never on the glasses (D10). Make it feel like a reference live-streaming platform.
 
 
 
 
 
20
  one instance per key — do **not** construct backends per call (re-loads 16 GB on the Space).
21
 
22
  ## Viewer modes + layout (`viewer.py`)
23
+ - Decided at build time by env:
24
+ - **Pure relay mode**: `SMALL_CUTS_RELAY_BUCKET` set and upload sandbox unset. This is the current
25
+ judged-Space posture for
26
+ `build-small-hackathon/small-cuts-live`: viewer-only, CPU Basic, no local model/TTS load, reads a
27
+ finished-scene manifest + media from an HF bucket relay.
28
+ - **Hybrid relay + upload mode**: `SMALL_CUTS_RELAY_BUCKET` and
29
+ `SMALL_CUTS_ENABLE_UPLOAD_SANDBOX=1` set. This is the target final submission posture if judges
30
+ need direct upload verification: relay stays the public library, upload calls Modal for real
31
+ narration/TTS on demand, and the submitted Space should remain CPU Basic. Prove this first through
32
+ the private Modal app `small-cuts-postcut`; promote to the org Space only after cold/warm timing,
33
+ bucket artifact writing, and playback smoke pass.
34
+ - **Engine mode**: `SMALL_CUTS_ENGINE_URL` set. Polls `GET /v1/scenes` from an engine/read-gate
35
+ endpoint. Keep this as a local/ops mode unless the current readiness doc explicitly switches back.
36
+ - **Upload mode**: neither relay nor engine env set. Local "try it" dropzone; useful for development
37
+ and fallback demos, not the active public relay architecture.
38
  - **Layout (Review-3 theater):** full-width top bar (Voice-Cut brand mark + upload icon), then a
39
  two-column **theater** — left: 9:16 stage (ratio is a hard invariant) + display-only progress bar +
40
  control **pill** (rewind/forward = **clip-to-clip**; gr.Audio stripped to **play/pause + volume**;
 
55
  strip-and-couple path instead of the swap, since the swap touches Space file-serving.)
56
 
57
  ## ZeroGPU gotchas (hard-won — see KB `…/space/`)
58
+ - In relay or engine viewer-only mode, the Space must not warm Qwen/Kokoro and should stay on
59
+ `cpu-basic`; ZeroGPU is only a fallback if Modal is ruled out and the Space itself performs
60
+ narration/TTS.
61
  - `@spaces.GPU` must mark the functions **Gradio binds** (the startup scan walks event handlers);
62
  decorating inner helpers → worker dies `No CUDA GPUs are available`.
63
  - **No torch forward in the main process ever** — TTS runs inside `@spaces.GPU(duration=…)` workers;
 
68
  ## Design invariant
69
  - The Space is the **view platform + library**, not the capture path. Publishing/visibility happens
70
  **only here**, never on the glasses (D10). Make it feel like a reference live-streaming platform.
71
+ - Wearer controls stay product-clean: `Action!` starts a take and `Cut!` finalizes it. Do not add a
72
+ third publish button. Glasses-origin cuts are published from the already-generated local scene
73
+ artifacts after `Cut!` and should show a small glasses badge in the Space; browser-uploaded judge
74
+ cuts use Modal and should not get that badge.
src/small_cuts/_icons.py CHANGED
@@ -6,6 +6,9 @@ via `.sc-icbtn` (see VIEWER_CSS). Regenerate from the icon set if it changes.
6
  """
7
 
8
  ICON_CSS = """
 
 
 
9
  .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"); }
10
  .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"); }
11
  .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"); }
 
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"); }
src/small_cuts/engine/library.py CHANGED
@@ -287,7 +287,12 @@ class SceneLibrary:
287
  self._db.close()
288
 
289
 
290
- def _write_clip_mp4(path: Path, frames: list[Image.Image], fps: int = CLIP_MP4_FPS) -> None:
 
 
 
 
 
291
  """Render a small browser-playable MP4 from sampled POV frames."""
292
  import av
293
 
@@ -297,9 +302,7 @@ def _write_clip_mp4(path: Path, frames: list[Image.Image], fps: int = CLIP_MP4_F
297
  # shave one pixel if needed; capture frames are already downscaled upstream.
298
  width = max(H264_MIN_DIMENSION, width - (width % 2))
299
  height = max(H264_MIN_DIMENSION, height - (height % 2))
300
- encode_frames = _smooth_clip_frames(
301
- rgb_frames, blend_steps=CLIP_BLEND_STEPS, size=(width, height)
302
- )
303
 
304
  container = av.open(str(path), "w")
305
  try:
 
287
  self._db.close()
288
 
289
 
290
+ def _write_clip_mp4(
291
+ path: Path,
292
+ frames: list[Image.Image],
293
+ fps: int = CLIP_MP4_FPS,
294
+ blend_steps: int = CLIP_BLEND_STEPS,
295
+ ) -> None:
296
  """Render a small browser-playable MP4 from sampled POV frames."""
297
  import av
298
 
 
302
  # shave one pixel if needed; capture frames are already downscaled upstream.
303
  width = max(H264_MIN_DIMENSION, width - (width % 2))
304
  height = max(H264_MIN_DIMENSION, height - (height % 2))
305
+ encode_frames = _smooth_clip_frames(rgb_frames, blend_steps=blend_steps, size=(width, height))
 
 
306
 
307
  container = av.open(str(path), "w")
308
  try:
src/small_cuts/hf_relay.py CHANGED
@@ -150,6 +150,7 @@ def prepare_relay_snapshot(
150
  *,
151
  limit: int = DEFAULT_SCENE_LIMIT,
152
  include_private: bool = False,
 
153
  client: httpx.Client | None = None,
154
  ) -> RelaySnapshot:
155
  """Stage a bucket-ready manifest + media snapshot from the private engine."""
@@ -165,7 +166,7 @@ def prepare_relay_snapshot(
165
  response.raise_for_status()
166
  scenes = response.json().get("scenes", [])[-limit:]
167
  published = [
168
- _stage_scene_media(base_url, output, scene, http)
169
  for scene in scenes
170
  if _should_publish_scene(scene, include_private=include_private)
171
  ]
@@ -194,8 +195,13 @@ def _stage_scene_media(
194
  output_dir: Path,
195
  scene: dict[str, Any],
196
  client: httpx.Client,
 
 
197
  ) -> dict[str, Any]:
198
  staged = copy.deepcopy(scene)
 
 
 
199
  media = staged.get("media")
200
  if not isinstance(media, dict):
201
  staged["media"] = {}
 
150
  *,
151
  limit: int = DEFAULT_SCENE_LIMIT,
152
  include_private: bool = False,
153
+ source: str | None = None,
154
  client: httpx.Client | None = None,
155
  ) -> RelaySnapshot:
156
  """Stage a bucket-ready manifest + media snapshot from the private engine."""
 
166
  response.raise_for_status()
167
  scenes = response.json().get("scenes", [])[-limit:]
168
  published = [
169
+ _stage_scene_media(base_url, output, scene, http, source=source)
170
  for scene in scenes
171
  if _should_publish_scene(scene, include_private=include_private)
172
  ]
 
195
  output_dir: Path,
196
  scene: dict[str, Any],
197
  client: httpx.Client,
198
+ *,
199
+ source: str | None = None,
200
  ) -> dict[str, Any]:
201
  staged = copy.deepcopy(scene)
202
+ if source:
203
+ staged["source"] = source
204
+ staged["source_icon"] = source
205
  media = staged.get("media")
206
  if not isinstance(media, dict):
207
  staged["media"] = {}
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 = 180.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/viewer.py CHANGED
@@ -39,6 +39,7 @@ from ._icons import ICON_CSS
39
  from .frames import pick_key_frame, sample_frames
40
  from .hf_relay import (
41
  DEFAULT_RELAY_PREFIX,
 
42
  RELAY_BUCKET_ENV,
43
  RELAY_PREFIX_ENV,
44
  BucketRelayError,
@@ -46,6 +47,7 @@ from .hf_relay import (
46
  from .hf_relay import (
47
  BucketSceneClient as _BucketSceneClient,
48
  )
 
49
  from .observability import capture_exception
50
  from .styles import DEFAULT_STYLE_KEY, STYLES
51
  from .title_card import derive_title
@@ -54,6 +56,10 @@ from .ui import THEME as THEME # re-export: app.py launches the viewer with the
54
  from .ui import TITLE, _gpu, _narrate_core, _speak_handler
55
 
56
  ENGINE_URL_ENV = "SMALL_CUTS_ENGINE_URL"
 
 
 
 
57
  # The narrator-as-chat feed is dropped from the default layout (single centered column);
58
  # flip this on to revive it (a future "see transcription" surface for non-live clips).
59
  SHOW_FEED = os.environ.get("SMALL_CUTS_SHOW_FEED", "").strip().lower() not in (
@@ -68,6 +74,17 @@ FEED_LIMIT = 12
68
  SHELF_LIMIT = 60
69
  HTTP_TIMEOUT_S = 5.0
70
  VISIBILITIES = ("private", "shared", "public")
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  EMPTY_STAGE_CAPTION = (
73
  "The narrator clears his throat, looks at the empty screen, and waits. "
@@ -138,6 +155,15 @@ footer { display: none !important; }
138
  width: 100%; height: 100%; object-fit: cover; display: block; }
139
  .sc-stage-empty { width: 100%; height: 100%; display: flex; align-items: center;
140
  justify-content: center; font-size: 3rem; opacity: .35; }
 
 
 
 
 
 
 
 
 
141
  .sc-subtitle { position: absolute; left: 50%; transform: translateX(-50%); bottom: 26px;
142
  width: min(92%, 600px); min-height: 2.7em; display: flex; align-items: center;
143
  justify-content: center; text-align: center; background: rgba(8,8,10,.72);
@@ -176,6 +202,23 @@ footer { display: none !important; }
176
  .sc-dropzone-label { font-family: 'IBM Plex Mono', monospace; font-size: .72rem;
177
  letter-spacing: .14em; color: #8a8894; text-transform: uppercase; }
178
  .sc-shelf { background: transparent !important; border: none !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
  /* --- Review-2 relayout: single centered column, control pill, masked icons --- */
181
  .sc-topbar { display: flex; align-items: flex-start; gap: 12px; }
@@ -289,6 +332,42 @@ VIEWER_CSS += ICON_CSS
289
  # -- scene formatting (pure, both modes) -------------------------------------------
290
 
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  def _style_label(style_key: str) -> str:
293
  style = STYLES.get(style_key)
294
  if style is not None:
@@ -296,6 +375,13 @@ def _style_label(style_key: str) -> str:
296
  return style_key or "off air"
297
 
298
 
 
 
 
 
 
 
 
299
  def _parse_ts(value: str | None) -> datetime | None:
300
  if not value:
301
  return None
@@ -341,6 +427,7 @@ def format_stage(
341
  "duration": None,
342
  "live": False,
343
  "visibility": None,
 
344
  }
345
  base = engine_url.rstrip("/")
346
 
@@ -361,6 +448,7 @@ def format_stage(
361
  "duration": scene.get("duration"),
362
  "live": is_fresh(scene.get("created_at"), now=now),
363
  "visibility": scene.get("visibility"),
 
364
  }
365
 
366
 
@@ -392,6 +480,7 @@ def render_stage_html(
392
  live: bool,
393
  clip_src: str | None = None,
394
  duration: float | None = None,
 
395
  ) -> str:
396
  """The 9:16 stage: the moment (video clip or still frame) + lower-third caption.
397
 
@@ -420,7 +509,16 @@ def render_stage_html(
420
  caption_html = f'<div class="sc-subtitle" id="sc-subtitle"{dur_attr}>{spans}</div>'
421
  else:
422
  caption_html = ""
423
- return f'<div class="sc-stage-shell">{body}{caption_html}</div>'
 
 
 
 
 
 
 
 
 
424
 
425
 
426
  def render_header_html(
@@ -523,10 +621,30 @@ def shelf_items(
523
  media = scene.get("media") or {}
524
  src = client.media_url(media.get("frame_url") or media.get("card_url"))
525
  if src:
526
- items.append((src, scene.get("title", "")))
527
  return items
528
 
529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  def poll_engine(
531
  client: EngineClient | BucketSceneClient,
532
  scenes_prev: list[dict[str, Any]],
@@ -588,6 +706,7 @@ def poll_engine(
588
  live=on_air,
589
  clip_src=payload["clip_src"],
590
  duration=payload["duration"],
 
591
  )
592
  feed = render_feed_html([feed_entry(scene) for scene in scenes[-FEED_LIMIT:]])
593
 
@@ -605,6 +724,21 @@ def poll_engine(
605
  # -- upload mode (the hackathon Space) -------------------------------------------------
606
 
607
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
  def _data_uri(image: Image.Image, max_side: int = 1080) -> str:
609
  thumb = image.copy()
610
  thumb.thumbnail((max_side, max_side))
@@ -635,7 +769,7 @@ def make_local_scene(
635
 
636
 
637
  def local_shelf_items(scenes: list[dict[str, Any]]) -> list[tuple[Image.Image, str]]:
638
- return [(scene["card_thumb"], scene.get("title", "")) for scene in scenes]
639
 
640
 
641
  @_gpu()
@@ -680,6 +814,7 @@ def _go_live_handler(
680
  live=False,
681
  clip_src=payload["clip_src"],
682
  duration=payload["duration"],
 
683
  ),
684
  render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]),
685
  local_shelf_items(scenes),
@@ -713,11 +848,13 @@ def _is_gradio_update(value: Any) -> bool:
713
  def _engine_ui_state(value: Any) -> dict[str, Any]:
714
  data = value if isinstance(value, dict) else {}
715
  scenes = data.get("scenes")
 
716
  return {
717
  "scenes": scenes if isinstance(scenes, list) else [],
718
  "pinned_id": data.get("pinned_id"),
719
  "current_id": data.get("current_id"),
720
  "playing_id": data.get("playing_id"),
 
721
  }
722
 
723
 
@@ -727,6 +864,7 @@ def _pack_engine_ui_state(
727
  current_id: Any,
728
  playing_id: Any,
729
  previous: dict[str, Any] | None = None,
 
730
  ) -> dict[str, Any]:
731
  prev = _engine_ui_state(previous)
732
  return {
@@ -734,9 +872,69 @@ def _pack_engine_ui_state(
734
  "pinned_id": pinned_id,
735
  "current_id": prev["current_id"] if _is_gradio_update(current_id) else current_id,
736
  "playing_id": prev["playing_id"] if _is_gradio_update(playing_id) else playing_id,
 
 
 
737
  }
738
 
739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  def _as_id_set(value: Any) -> set[str]:
741
  if not value:
742
  return set()
@@ -1025,6 +1223,8 @@ def build_viewer_app() -> gr.Blocks:
1025
  else:
1026
  client = None
1027
  seed = _seed_scenes() if client is None else []
 
 
1028
 
1029
  if client:
1030
  boot_header = render_header_html("Tuning the antenna…", "standby", live=False)
@@ -1039,6 +1239,7 @@ def build_viewer_app() -> gr.Blocks:
1039
  live=False,
1040
  clip_src=boot["clip_src"],
1041
  duration=boot["duration"],
 
1042
  )
1043
  boot_audio = _audio_html(boot["audio_src"])
1044
 
@@ -1065,7 +1266,9 @@ def build_viewer_app() -> gr.Blocks:
1065
  "ear lands here as a cut you can keep.</span></div>",
1066
  padding=False,
1067
  )
1068
- if client is None:
 
 
1069
  upload_btn = gr.Button("", elem_classes=["sc-icbtn", "sc-upload", "sc-ico-upload"])
1070
  # Theater layout (Review-3): stage + controls on the left, the library as a side rail on
1071
  # the right. Fills the width and keeps everything in one viewport (no main scroll); the
@@ -1111,10 +1314,10 @@ def build_viewer_app() -> gr.Blocks:
1111
  audio = gr.HTML(boot_audio, elem_classes="sc-audio-host", padding=False)
1112
  # The play tap is handled by PLAYBACK_SYNC_JS as a delegated DOM click so the
1113
  # browser keeps user activation for audio.play().
1114
- if client is None:
1115
  # one signature voice — no director menu; voice-over is on by default
1116
  style = gr.State(DEFAULT_STYLE_KEY)
1117
- else:
1118
  visibility_controls = os.environ.get(
1119
  "SMALL_CUTS_ENABLE_VISIBILITY_CONTROLS", ""
1120
  ).strip().lower() in ("1", "true", "yes")
@@ -1133,7 +1336,7 @@ def build_viewer_app() -> gr.Blocks:
1133
  padding=False,
1134
  visible=SHOW_FEED,
1135
  )
1136
- if client is None:
1137
  # The upload sandbox opens on demand from the top-right icon — off the main
1138
  # view, video-only (the product narrates video, not stills).
1139
  image_none = gr.State(None)
@@ -1167,12 +1370,44 @@ def build_viewer_app() -> gr.Blocks:
1167
  # "Back to live" is now the (clickable) header; this button stays for its un-pin /
1168
  # re-follow-live wiring but is hidden via CSS and triggered by the header click in JS.
1169
  live_btn = gr.Button("⟲ Back to live", elem_classes=["sc-live-btn"])
1170
- if client is None:
1171
  upload_btn.click(lambda: gr.update(open=True, visible=True), outputs=[tryit_panel])
1172
 
1173
  if client is not None:
1174
  engine = client # narrow the type for the closures below
1175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1176
  def _tick(state):
1177
  state = _engine_ui_state(state)
1178
  (
@@ -1192,6 +1427,11 @@ def build_viewer_app() -> gr.Blocks:
1192
  state["playing_id"],
1193
  current_id=state["current_id"],
1194
  )
 
 
 
 
 
1195
  return (
1196
  header_update,
1197
  stage_update,
@@ -1239,6 +1479,7 @@ def build_viewer_app() -> gr.Blocks:
1239
  payload["live"],
1240
  clip_src=payload["clip_src"],
1241
  duration=payload["duration"],
 
1242
  ),
1243
  _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip(),
1244
  _pack_engine_ui_state(
@@ -1247,6 +1488,7 @@ def build_viewer_app() -> gr.Blocks:
1247
  payload["scene_id"],
1248
  payload["scene_id"],
1249
  previous=state,
 
1250
  ),
1251
  gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip(),
1252
  )
@@ -1279,6 +1521,7 @@ def build_viewer_app() -> gr.Blocks:
1279
  payload["live"],
1280
  clip_src=payload["clip_src"],
1281
  duration=payload["duration"],
 
1282
  ),
1283
  _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip(),
1284
  _pack_engine_ui_state(
@@ -1287,6 +1530,7 @@ def build_viewer_app() -> gr.Blocks:
1287
  payload["scene_id"],
1288
  payload["scene_id"],
1289
  previous=state,
 
1290
  ),
1291
  gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip(),
1292
  )
@@ -1312,6 +1556,7 @@ def build_viewer_app() -> gr.Blocks:
1312
  payload["live"],
1313
  clip_src=payload["clip_src"],
1314
  duration=payload["duration"],
 
1315
  ),
1316
  audio_update,
1317
  _pack_engine_ui_state(
@@ -1320,6 +1565,7 @@ def build_viewer_app() -> gr.Blocks:
1320
  payload["scene_id"],
1321
  playing_id,
1322
  previous=state,
 
1323
  ),
1324
  gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip(),
1325
  )
@@ -1376,6 +1622,7 @@ def build_viewer_app() -> gr.Blocks:
1376
  payload["live"],
1377
  clip_src=payload["clip_src"],
1378
  duration=payload["duration"],
 
1379
  ),
1380
  _audio_html(payload["audio_src"]),
1381
  payload["scene_id"],
@@ -1398,6 +1645,7 @@ def build_viewer_app() -> gr.Blocks:
1398
  payload["live"],
1399
  clip_src=payload["clip_src"],
1400
  duration=payload["duration"],
 
1401
  ),
1402
  _audio_html(payload["audio_src"]),
1403
  None,
@@ -1423,6 +1671,7 @@ def build_viewer_app() -> gr.Blocks:
1423
  payload["live"],
1424
  clip_src=payload["clip_src"],
1425
  duration=payload["duration"],
 
1426
  ),
1427
  _audio_html(payload["audio_src"]),
1428
  scene["scene_id"],
 
39
  from .frames import pick_key_frame, sample_frames
40
  from .hf_relay import (
41
  DEFAULT_RELAY_PREFIX,
42
+ MEDIA_KEYS,
43
  RELAY_BUCKET_ENV,
44
  RELAY_PREFIX_ENV,
45
  BucketRelayError,
 
47
  from .hf_relay import (
48
  BucketSceneClient as _BucketSceneClient,
49
  )
50
+ from .modal_upload import ModalUploadClient, ModalUploadError
51
  from .observability import capture_exception
52
  from .styles import DEFAULT_STYLE_KEY, STYLES
53
  from .title_card import derive_title
 
56
  from .ui import TITLE, _gpu, _narrate_core, _speak_handler
57
 
58
  ENGINE_URL_ENV = "SMALL_CUTS_ENGINE_URL"
59
+ MODAL_API_URL_ENV = "SMALL_CUTS_MODAL_API_URL"
60
+ MODAL_API_TOKEN_ENV = "SMALL_CUTS_MODAL_API_TOKEN"
61
+ UPLOAD_SANDBOX_ENV = "SMALL_CUTS_ENABLE_UPLOAD_SANDBOX"
62
+ UPLOAD_MAX_SECONDS_ENV = "SMALL_CUTS_UPLOAD_MAX_SECONDS"
63
  # The narrator-as-chat feed is dropped from the default layout (single centered column);
64
  # flip this on to revive it (a future "see transcription" surface for non-live clips).
65
  SHOW_FEED = os.environ.get("SMALL_CUTS_SHOW_FEED", "").strip().lower() not in (
 
74
  SHELF_LIMIT = 60
75
  HTTP_TIMEOUT_S = 5.0
76
  VISIBILITIES = ("private", "shared", "public")
77
+ _KEEP_UPLOAD_SCENE = object()
78
+ SOURCE_ICON_LABELS = {
79
+ "glasses": "Glasses capture",
80
+ "upload": "Space upload",
81
+ }
82
+ GLASSES_SHELF_PREFIX = "\u2063sc-glasses\u2063"
83
+ UPLOAD_SHELF_PREFIX = "\u2063sc-upload\u2063"
84
+ _SOURCE_SHELF_PREFIXES = {
85
+ "glasses": GLASSES_SHELF_PREFIX,
86
+ "upload": UPLOAD_SHELF_PREFIX,
87
+ }
88
 
89
  EMPTY_STAGE_CAPTION = (
90
  "The narrator clears his throat, looks at the empty screen, and waits. "
 
155
  width: 100%; height: 100%; object-fit: cover; display: block; }
156
  .sc-stage-empty { width: 100%; height: 100%; display: flex; align-items: center;
157
  justify-content: center; font-size: 3rem; opacity: .35; }
158
+ .sc-source-badge { position: absolute; top: 10px; left: 10px; z-index: 4;
159
+ width: 30px; height: 30px; display: inline-flex; align-items: center;
160
+ justify-content: center; border-radius: 999px; color: #f3efe4;
161
+ background: rgba(8,8,10,.68); border: 1px solid rgba(243,239,228,.24);
162
+ box-shadow: 0 3px 12px rgba(0,0,0,.22); backdrop-filter: blur(7px); }
163
+ .sc-source-badge-icon { width: 17px; height: 17px; background: currentColor;
164
+ -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
165
+ -webkit-mask-position: center; mask-position: center;
166
+ -webkit-mask-size: contain; mask-size: contain; }
167
  .sc-subtitle { position: absolute; left: 50%; transform: translateX(-50%); bottom: 26px;
168
  width: min(92%, 600px); min-height: 2.7em; display: flex; align-items: center;
169
  justify-content: center; text-align: center; background: rgba(8,8,10,.72);
 
202
  .sc-dropzone-label { font-family: 'IBM Plex Mono', monospace; font-size: .72rem;
203
  letter-spacing: .14em; color: #8a8894; text-transform: uppercase; }
204
  .sc-shelf { background: transparent !important; border: none !important; }
205
+ .sc-shelf .thumbnail-item { position: relative; }
206
+ .sc-shelf .thumbnail-item:has(img[alt^="\\002063sc-glasses\\002063"])::before,
207
+ .sc-shelf .thumbnail-item:has(img[alt^="\\002063sc-upload\\002063"])::before {
208
+ content: ""; position: absolute; top: 6px; left: 6px; width: 24px; height: 24px;
209
+ border-radius: 999px; background: rgba(8,8,10,.68);
210
+ border: 1px solid rgba(243,239,228,.22); z-index: 3; backdrop-filter: blur(6px);
211
+ box-shadow: 0 2px 8px rgba(0,0,0,.2); }
212
+ .sc-shelf .thumbnail-item:has(img[alt^="\\002063sc-glasses\\002063"])::after,
213
+ .sc-shelf .thumbnail-item:has(img[alt^="\\002063sc-upload\\002063"])::after {
214
+ content: ""; position: absolute; top: 11px; left: 11px; width: 14px; height: 14px;
215
+ background: #f3efe4; z-index: 4; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
216
+ -webkit-mask-position: center; mask-position: center;
217
+ -webkit-mask-size: contain; mask-size: contain; }
218
+ .sc-shelf .thumbnail-item:has(img[alt^="\\002063sc-glasses\\002063"])::after {
219
+ -webkit-mask-image: var(--sc-ico-glasses-mask); mask-image: var(--sc-ico-glasses-mask); }
220
+ .sc-shelf .thumbnail-item:has(img[alt^="\\002063sc-upload\\002063"])::after {
221
+ -webkit-mask-image: var(--sc-ico-upload-mask); mask-image: var(--sc-ico-upload-mask); }
222
 
223
  /* --- Review-2 relayout: single centered column, control pill, masked icons --- */
224
  .sc-topbar { display: flex; align-items: flex-start; gap: 12px; }
 
332
  # -- scene formatting (pure, both modes) -------------------------------------------
333
 
334
 
335
+ def _truthy_env(name: str) -> bool:
336
+ return os.environ.get(name, "").strip().lower() in ("1", "true", "yes")
337
+
338
+
339
+ def upload_sandbox_enabled() -> bool:
340
+ """Relay-mode judge uploads are enabled only when Modal is explicitly configured."""
341
+ return _truthy_env(UPLOAD_SANDBOX_ENV) and bool(os.environ.get(MODAL_API_URL_ENV, "").strip())
342
+
343
+
344
+ def upload_max_seconds() -> float:
345
+ raw = os.environ.get(UPLOAD_MAX_SECONDS_ENV, "60").strip()
346
+ try:
347
+ return max(1.0, float(raw))
348
+ except ValueError:
349
+ return 60.0
350
+
351
+
352
+ def _require_upload_profile(profile: gr.OAuthProfile | None) -> str:
353
+ username = (
354
+ getattr(profile, "username", None)
355
+ or (profile.get("preferred_username") if isinstance(profile, dict) else None)
356
+ or getattr(profile, "name", None)
357
+ )
358
+ if profile is None or not username:
359
+ raise gr.Error("Sign in with Hugging Face to upload a cut.")
360
+ return str(username)
361
+
362
+
363
+ def _modal_upload_client() -> ModalUploadClient:
364
+ base_url = os.environ.get(MODAL_API_URL_ENV, "").strip()
365
+ token = os.environ.get(MODAL_API_TOKEN_ENV, "").strip()
366
+ if not base_url or not token:
367
+ raise gr.Error("Modal upload is not configured.")
368
+ return ModalUploadClient(base_url, token)
369
+
370
+
371
  def _style_label(style_key: str) -> str:
372
  style = STYLES.get(style_key)
373
  if style is not None:
 
375
  return style_key or "off air"
376
 
377
 
378
+ def _source_icon(scene: dict[str, Any] | None) -> str | None:
379
+ if not scene:
380
+ return None
381
+ source = scene.get("source_icon") or scene.get("source")
382
+ return source if isinstance(source, str) and source in SOURCE_ICON_LABELS else None
383
+
384
+
385
  def _parse_ts(value: str | None) -> datetime | None:
386
  if not value:
387
  return None
 
427
  "duration": None,
428
  "live": False,
429
  "visibility": None,
430
+ "source_icon": None,
431
  }
432
  base = engine_url.rstrip("/")
433
 
 
448
  "duration": scene.get("duration"),
449
  "live": is_fresh(scene.get("created_at"), now=now),
450
  "visibility": scene.get("visibility"),
451
+ "source_icon": _source_icon(scene),
452
  }
453
 
454
 
 
480
  live: bool,
481
  clip_src: str | None = None,
482
  duration: float | None = None,
483
+ source_icon: str | None = None,
484
  ) -> str:
485
  """The 9:16 stage: the moment (video clip or still frame) + lower-third caption.
486
 
 
509
  caption_html = f'<div class="sc-subtitle" id="sc-subtitle"{dur_attr}>{spans}</div>'
510
  else:
511
  caption_html = ""
512
+ if source_icon in SOURCE_ICON_LABELS:
513
+ label = html.escape(SOURCE_ICON_LABELS[source_icon], quote=True)
514
+ icon = html.escape(source_icon, quote=True)
515
+ badge_html = (
516
+ f'<span class="sc-source-badge sc-source-{icon}" aria-label="{label}" '
517
+ f'title="{label}"><span class="sc-source-badge-icon sc-ico-{icon}"></span></span>'
518
+ )
519
+ else:
520
+ badge_html = ""
521
+ return f'<div class="sc-stage-shell">{badge_html}{body}{caption_html}</div>'
522
 
523
 
524
  def render_header_html(
 
621
  media = scene.get("media") or {}
622
  src = client.media_url(media.get("frame_url") or media.get("card_url"))
623
  if src:
624
+ items.append((src, _shelf_caption(scene)))
625
  return items
626
 
627
 
628
+ def _shelf_caption(scene: dict[str, Any]) -> str:
629
+ title = scene.get("title", "")
630
+ source_icon = _source_icon(scene)
631
+ return f"{_SOURCE_SHELF_PREFIXES.get(source_icon, '')}{title}"
632
+
633
+
634
+ def _scene_with_media_urls(
635
+ scene: dict[str, Any], client: EngineClient | BucketSceneClient
636
+ ) -> dict[str, Any]:
637
+ hydrated = {**scene}
638
+ media = scene.get("media")
639
+ if not isinstance(media, dict):
640
+ hydrated["media"] = {}
641
+ return hydrated
642
+ hydrated["media"] = {**media}
643
+ for key in MEDIA_KEYS:
644
+ hydrated["media"][key] = client.media_url(media.get(key))
645
+ return hydrated
646
+
647
+
648
  def poll_engine(
649
  client: EngineClient | BucketSceneClient,
650
  scenes_prev: list[dict[str, Any]],
 
706
  live=on_air,
707
  clip_src=payload["clip_src"],
708
  duration=payload["duration"],
709
+ source_icon=payload["source_icon"],
710
  )
711
  feed = render_feed_html([feed_entry(scene) for scene in scenes[-FEED_LIMIT:]])
712
 
 
724
  # -- upload mode (the hackathon Space) -------------------------------------------------
725
 
726
 
727
+ def _video_duration_s(video_path: str | Path) -> float | None:
728
+ try:
729
+ import av
730
+
731
+ with av.open(str(video_path)) as container:
732
+ if container.duration is not None:
733
+ return float(container.duration / 1_000_000)
734
+ stream = container.streams.video[0] if container.streams.video else None
735
+ if stream and stream.duration is not None and stream.time_base is not None:
736
+ return float(stream.duration * stream.time_base)
737
+ except Exception as exc:
738
+ capture_exception(exc)
739
+ return None
740
+
741
+
742
  def _data_uri(image: Image.Image, max_side: int = 1080) -> str:
743
  thumb = image.copy()
744
  thumb.thumbnail((max_side, max_side))
 
769
 
770
 
771
  def local_shelf_items(scenes: list[dict[str, Any]]) -> list[tuple[Image.Image, str]]:
772
+ return [(scene["card_thumb"], _shelf_caption(scene)) for scene in scenes]
773
 
774
 
775
  @_gpu()
 
814
  live=False,
815
  clip_src=payload["clip_src"],
816
  duration=payload["duration"],
817
+ source_icon=payload["source_icon"],
818
  ),
819
  render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]),
820
  local_shelf_items(scenes),
 
848
  def _engine_ui_state(value: Any) -> dict[str, Any]:
849
  data = value if isinstance(value, dict) else {}
850
  scenes = data.get("scenes")
851
+ upload_scene = data.get("upload_scene")
852
  return {
853
  "scenes": scenes if isinstance(scenes, list) else [],
854
  "pinned_id": data.get("pinned_id"),
855
  "current_id": data.get("current_id"),
856
  "playing_id": data.get("playing_id"),
857
+ "upload_scene": upload_scene if isinstance(upload_scene, dict) else None,
858
  }
859
 
860
 
 
864
  current_id: Any,
865
  playing_id: Any,
866
  previous: dict[str, Any] | None = None,
867
+ upload_scene: Any = _KEEP_UPLOAD_SCENE,
868
  ) -> dict[str, Any]:
869
  prev = _engine_ui_state(previous)
870
  return {
 
872
  "pinned_id": pinned_id,
873
  "current_id": prev["current_id"] if _is_gradio_update(current_id) else current_id,
874
  "playing_id": prev["playing_id"] if _is_gradio_update(playing_id) else playing_id,
875
+ "upload_scene": prev["upload_scene"]
876
+ if upload_scene is _KEEP_UPLOAD_SCENE
877
+ else upload_scene,
878
  }
879
 
880
 
881
+ def _submit_modal_upload(
882
+ video_path: str | None,
883
+ style_key: str,
884
+ scene_hint: str,
885
+ state: Any,
886
+ profile: gr.OAuthProfile | None,
887
+ media_client: EngineClient | BucketSceneClient,
888
+ ) -> tuple[Any, ...]:
889
+ if not video_path:
890
+ raise gr.Error("Upload a video clip first.")
891
+
892
+ duration = _video_duration_s(video_path)
893
+ max_seconds = upload_max_seconds()
894
+ if duration is not None and duration > max_seconds + 0.25:
895
+ raise gr.Error(f"Please upload a clip up to {max_seconds:.0f} seconds.")
896
+
897
+ uploader = _require_upload_profile(profile)
898
+ try:
899
+ raw_scene = _modal_upload_client().submit_video(
900
+ video_path,
901
+ uploader_hf_username=uploader,
902
+ style_key=style_key,
903
+ scene_hint=scene_hint,
904
+ )
905
+ except (ModalUploadError, httpx.HTTPError) as exc:
906
+ raise gr.Error(f"Modal upload failed: {exc}") from exc
907
+
908
+ scene = _scene_with_media_urls(raw_scene, media_client)
909
+ current_state = _engine_ui_state(state)
910
+ scenes = [*current_state["scenes"], scene][-SHELF_LIMIT:]
911
+ payload = format_stage(scene)
912
+ scene_id = payload["scene_id"]
913
+ return (
914
+ render_header_html(payload["title"], payload["style_label"], live=False),
915
+ render_stage_html(
916
+ payload["frame_src"],
917
+ payload["caption"],
918
+ live=False,
919
+ clip_src=payload["clip_src"],
920
+ duration=payload["duration"],
921
+ source_icon=payload["source_icon"],
922
+ ),
923
+ render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]),
924
+ _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip(),
925
+ shelf_items(scenes, media_client),
926
+ _pack_engine_ui_state(
927
+ scenes,
928
+ scene_id,
929
+ scene_id,
930
+ scene_id,
931
+ previous=current_state,
932
+ upload_scene=scene,
933
+ ),
934
+ gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip(),
935
+ )
936
+
937
+
938
  def _as_id_set(value: Any) -> set[str]:
939
  if not value:
940
  return set()
 
1223
  else:
1224
  client = None
1225
  seed = _seed_scenes() if client is None else []
1226
+ upload_sandbox = upload_sandbox_enabled()
1227
+ upload_enabled = client is None or upload_sandbox
1228
 
1229
  if client:
1230
  boot_header = render_header_html("Tuning the antenna…", "standby", live=False)
 
1239
  live=False,
1240
  clip_src=boot["clip_src"],
1241
  duration=boot["duration"],
1242
+ source_icon=boot["source_icon"],
1243
  )
1244
  boot_audio = _audio_html(boot["audio_src"])
1245
 
 
1266
  "ear lands here as a cut you can keep.</span></div>",
1267
  padding=False,
1268
  )
1269
+ if upload_sandbox:
1270
+ gr.LoginButton("Sign in", logout_value="Signed in ({})", size="sm")
1271
+ if upload_enabled:
1272
  upload_btn = gr.Button("", elem_classes=["sc-icbtn", "sc-upload", "sc-ico-upload"])
1273
  # Theater layout (Review-3): stage + controls on the left, the library as a side rail on
1274
  # the right. Fills the width and keeps everything in one viewport (no main scroll); the
 
1314
  audio = gr.HTML(boot_audio, elem_classes="sc-audio-host", padding=False)
1315
  # The play tap is handled by PLAYBACK_SYNC_JS as a delegated DOM click so the
1316
  # browser keeps user activation for audio.play().
1317
+ if upload_enabled:
1318
  # one signature voice — no director menu; voice-over is on by default
1319
  style = gr.State(DEFAULT_STYLE_KEY)
1320
+ if client is not None:
1321
  visibility_controls = os.environ.get(
1322
  "SMALL_CUTS_ENABLE_VISIBILITY_CONTROLS", ""
1323
  ).strip().lower() in ("1", "true", "yes")
 
1336
  padding=False,
1337
  visible=SHOW_FEED,
1338
  )
1339
+ if upload_enabled:
1340
  # The upload sandbox opens on demand from the top-right icon — off the main
1341
  # view, video-only (the product narrates video, not stills).
1342
  image_none = gr.State(None)
 
1370
  # "Back to live" is now the (clickable) header; this button stays for its un-pin /
1371
  # re-follow-live wiring but is hidden via CSS and triggered by the header click in JS.
1372
  live_btn = gr.Button("⟲ Back to live", elem_classes=["sc-live-btn"])
1373
+ if upload_enabled:
1374
  upload_btn.click(lambda: gr.update(open=True, visible=True), outputs=[tryit_panel])
1375
 
1376
  if client is not None:
1377
  engine = client # narrow the type for the closures below
1378
 
1379
+ if upload_sandbox:
1380
+
1381
+ def _go_modal_upload_ui(
1382
+ video_path,
1383
+ style_key,
1384
+ scene_hint,
1385
+ state,
1386
+ profile: gr.OAuthProfile | None,
1387
+ ):
1388
+ return _submit_modal_upload(
1389
+ video_path,
1390
+ style_key,
1391
+ scene_hint,
1392
+ state,
1393
+ profile,
1394
+ engine,
1395
+ )
1396
+
1397
+ go.click(
1398
+ _go_modal_upload_ui,
1399
+ inputs=[drop_video, style, hint, scenes_state],
1400
+ outputs=[
1401
+ header,
1402
+ stage,
1403
+ feed,
1404
+ audio,
1405
+ shelf,
1406
+ scenes_state,
1407
+ visibility,
1408
+ ],
1409
+ )
1410
+
1411
  def _tick(state):
1412
  state = _engine_ui_state(state)
1413
  (
 
1427
  state["playing_id"],
1428
  current_id=state["current_id"],
1429
  )
1430
+ if state["upload_scene"] is not None:
1431
+ header_update = gr.skip()
1432
+ stage_update = gr.skip()
1433
+ audio_update = gr.skip()
1434
+ playing_id = state["playing_id"]
1435
  return (
1436
  header_update,
1437
  stage_update,
 
1479
  payload["live"],
1480
  clip_src=payload["clip_src"],
1481
  duration=payload["duration"],
1482
+ source_icon=payload["source_icon"],
1483
  ),
1484
  _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip(),
1485
  _pack_engine_ui_state(
 
1488
  payload["scene_id"],
1489
  payload["scene_id"],
1490
  previous=state,
1491
+ upload_scene=None,
1492
  ),
1493
  gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip(),
1494
  )
 
1521
  payload["live"],
1522
  clip_src=payload["clip_src"],
1523
  duration=payload["duration"],
1524
+ source_icon=payload["source_icon"],
1525
  ),
1526
  _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip(),
1527
  _pack_engine_ui_state(
 
1530
  payload["scene_id"],
1531
  payload["scene_id"],
1532
  previous=state,
1533
+ upload_scene=None,
1534
  ),
1535
  gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip(),
1536
  )
 
1556
  payload["live"],
1557
  clip_src=payload["clip_src"],
1558
  duration=payload["duration"],
1559
+ source_icon=payload["source_icon"],
1560
  ),
1561
  audio_update,
1562
  _pack_engine_ui_state(
 
1565
  payload["scene_id"],
1566
  playing_id,
1567
  previous=state,
1568
+ upload_scene=None,
1569
  ),
1570
  gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip(),
1571
  )
 
1622
  payload["live"],
1623
  clip_src=payload["clip_src"],
1624
  duration=payload["duration"],
1625
+ source_icon=payload["source_icon"],
1626
  ),
1627
  _audio_html(payload["audio_src"]),
1628
  payload["scene_id"],
 
1645
  payload["live"],
1646
  clip_src=payload["clip_src"],
1647
  duration=payload["duration"],
1648
+ source_icon=payload["source_icon"],
1649
  ),
1650
  _audio_html(payload["audio_src"]),
1651
  None,
 
1671
  payload["live"],
1672
  clip_src=payload["clip_src"],
1673
  duration=payload["duration"],
1674
+ source_icon=payload["source_icon"],
1675
  ),
1676
  _audio_html(payload["audio_src"]),
1677
  scene["scene_id"],
tests/test_app_entrypoint.py CHANGED
@@ -4,12 +4,43 @@ import sys
4
  import warnings
5
  from pathlib import Path
6
 
 
7
  from starlette.exceptions import StarletteDeprecationWarning
8
 
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  def test_space_engine_mode_does_not_force_local_backends(monkeypatch):
11
  monkeypatch.setenv("SPACE_ID", "build-small-hackathon/small-cuts")
12
  monkeypatch.setenv("SMALL_CUTS_ENGINE_URL", "http://127.0.0.1:9")
 
13
  monkeypatch.delenv("SMALL_CUTS_BACKEND", raising=False)
14
  monkeypatch.delenv("SMALL_CUTS_TTS_BACKEND", raising=False)
15
  monkeypatch.setitem(sys.modules, "spaces", None)
@@ -24,6 +55,45 @@ def test_space_engine_mode_does_not_force_local_backends(monkeypatch):
24
  assert os.environ.get("SMALL_CUTS_TTS_BACKEND") is None
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  def test_app_filters_gradio_starlette_queue_warning(monkeypatch):
28
  monkeypatch.setenv("SMALL_CUTS_ENGINE_URL", "http://127.0.0.1:9")
29
 
 
4
  import warnings
5
  from pathlib import Path
6
 
7
+ import gradio.oauth
8
  from starlette.exceptions import StarletteDeprecationWarning
9
 
10
 
11
+ def _mock_gradio_oauth(monkeypatch):
12
+ monkeypatch.setattr(
13
+ gradio.oauth,
14
+ "_get_mocked_oauth_info",
15
+ lambda: {
16
+ "access_token": "mock-oauth-token-for-ci",
17
+ "token_type": "bearer",
18
+ "expires_in": 3600,
19
+ "id_token": "AAAAAAAAAAAAAAAAAAAAAAAAAA",
20
+ "scope": "openid profile",
21
+ "expires_at": 9999999999,
22
+ "userinfo": {
23
+ "sub": "11111111111111111111111",
24
+ "name": "CI User",
25
+ "preferred_username": "ci-user",
26
+ "profile": "https://huggingface.co/ci-user",
27
+ "picture": "",
28
+ "website": "",
29
+ "aud": "00000000-0000-0000-0000-000000000000",
30
+ "auth_time": 1691672844,
31
+ "nonce": "aaaaaaaaaaaaaaaaaaa",
32
+ "iat": 1691672844,
33
+ "exp": 1691676444,
34
+ "iss": "https://huggingface.co",
35
+ },
36
+ },
37
+ )
38
+
39
+
40
  def test_space_engine_mode_does_not_force_local_backends(monkeypatch):
41
  monkeypatch.setenv("SPACE_ID", "build-small-hackathon/small-cuts")
42
  monkeypatch.setenv("SMALL_CUTS_ENGINE_URL", "http://127.0.0.1:9")
43
+ monkeypatch.delenv("SMALL_CUTS_RELAY_BUCKET", raising=False)
44
  monkeypatch.delenv("SMALL_CUTS_BACKEND", raising=False)
45
  monkeypatch.delenv("SMALL_CUTS_TTS_BACKEND", raising=False)
46
  monkeypatch.setitem(sys.modules, "spaces", None)
 
55
  assert os.environ.get("SMALL_CUTS_TTS_BACKEND") is None
56
 
57
 
58
+ def test_space_bucket_relay_mode_does_not_force_local_backends(monkeypatch):
59
+ monkeypatch.setenv("SPACE_ID", "build-small-hackathon/small-cuts-live")
60
+ monkeypatch.delenv("SMALL_CUTS_ENGINE_URL", raising=False)
61
+ monkeypatch.setenv("SMALL_CUTS_RELAY_BUCKET", "build-small-hackathon/small-cuts-scenes")
62
+ monkeypatch.delenv("SMALL_CUTS_BACKEND", raising=False)
63
+ monkeypatch.delenv("SMALL_CUTS_TTS_BACKEND", raising=False)
64
+ monkeypatch.setitem(sys.modules, "spaces", None)
65
+
66
+ app_path = Path(__file__).resolve().parents[1] / "app.py"
67
+ spec = importlib.util.spec_from_file_location("_small_cuts_test_app_relay", app_path)
68
+ assert spec is not None and spec.loader is not None
69
+ module = importlib.util.module_from_spec(spec)
70
+ spec.loader.exec_module(module)
71
+
72
+ assert os.environ.get("SMALL_CUTS_BACKEND") is None
73
+ assert os.environ.get("SMALL_CUTS_TTS_BACKEND") is None
74
+
75
+
76
+ def test_space_relay_with_modal_upload_does_not_force_local_backends(monkeypatch):
77
+ monkeypatch.setenv("SPACE_ID", "build-small-hackathon/small-cuts-live")
78
+ monkeypatch.delenv("SMALL_CUTS_ENGINE_URL", raising=False)
79
+ monkeypatch.setenv("SMALL_CUTS_RELAY_BUCKET", "build-small-hackathon/small-cuts-scenes")
80
+ monkeypatch.setenv("SMALL_CUTS_ENABLE_UPLOAD_SANDBOX", "1")
81
+ monkeypatch.setenv("SMALL_CUTS_MODAL_API_URL", "https://example.modal.run")
82
+ monkeypatch.delenv("SMALL_CUTS_BACKEND", raising=False)
83
+ monkeypatch.delenv("SMALL_CUTS_TTS_BACKEND", raising=False)
84
+ monkeypatch.setitem(sys.modules, "spaces", None)
85
+ _mock_gradio_oauth(monkeypatch)
86
+
87
+ app_path = Path(__file__).resolve().parents[1] / "app.py"
88
+ spec = importlib.util.spec_from_file_location("_small_cuts_test_app_modal", app_path)
89
+ assert spec is not None and spec.loader is not None
90
+ module = importlib.util.module_from_spec(spec)
91
+ spec.loader.exec_module(module)
92
+
93
+ assert os.environ.get("SMALL_CUTS_BACKEND") is None
94
+ assert os.environ.get("SMALL_CUTS_TTS_BACKEND") is None
95
+
96
+
97
  def test_app_filters_gradio_starlette_queue_warning(monkeypatch):
98
  monkeypatch.setenv("SMALL_CUTS_ENGINE_URL", "http://127.0.0.1:9")
99
 
tests/test_engine_library.py CHANGED
@@ -28,6 +28,7 @@ from small_cuts.engine.library import ( # noqa: E402
28
  RGB_MODE,
29
  SceneLibrary,
30
  _smooth_clip_frames,
 
31
  )
32
  from small_cuts.title_card import derive_title # noqa: E402
33
  from test_engine_session import Reader, make_envelope # noqa: E402
@@ -156,6 +157,18 @@ def test_store_writes_clip_url_and_title_for_multiframe_scene(tmp_path):
156
  assert clip.content == clip_path.read_bytes()
157
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  def test_store_prefers_generated_title_when_present(tmp_path):
160
  lib = SceneLibrary(tmp_path / "lib")
161
  stored = lib.store(
 
28
  RGB_MODE,
29
  SceneLibrary,
30
  _smooth_clip_frames,
31
+ _write_clip_mp4,
32
  )
33
  from small_cuts.title_card import derive_title # noqa: E402
34
  from test_engine_session import Reader, make_envelope # noqa: E402
 
157
  assert clip.content == clip_path.read_bytes()
158
 
159
 
160
+ def test_write_clip_mp4_can_disable_blends(tmp_path):
161
+ av = pytest.importorskip("av")
162
+ path = tmp_path / "clip.mp4"
163
+ frames = [Image.new("RGB", (64, 96), (i * 30, 20, 20)) for i in range(4)]
164
+
165
+ _write_clip_mp4(path, frames, fps=8, blend_steps=0)
166
+
167
+ with av.open(path) as container:
168
+ assert float(container.streams.video[0].average_rate) == pytest.approx(8)
169
+ assert sum(1 for _ in container.decode(video=0)) == 4
170
+
171
+
172
  def test_store_prefers_generated_title_when_present(tmp_path):
173
  lib = SceneLibrary(tmp_path / "lib")
174
  stored = lib.store(
tests/test_hf_relay.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ import httpx
4
+
5
+ from small_cuts import hf_relay
6
+ from test_contracts import GOLDEN
7
+
8
+ ENGINE_URL = "http://engine.test:8077"
9
+
10
+
11
+ def test_prepare_relay_snapshot_writes_manifest_and_media(tmp_path):
12
+ scene = {
13
+ **GOLDEN["narrated-scene.schema.json"],
14
+ "visibility": "public",
15
+ "media": {
16
+ "frame_url": "/media/9f1c7e4a/frame.jpg",
17
+ "card_url": "/media/9f1c7e4a/card.webp",
18
+ "audio_url": "/media/9f1c7e4a/voice.wav",
19
+ "clip_url": "/media/9f1c7e4a/clip.mp4",
20
+ },
21
+ }
22
+ served = {
23
+ "/v1/scenes": httpx.Response(200, json={"scenes": [scene]}),
24
+ "/media/9f1c7e4a/frame.jpg": httpx.Response(200, content=b"frame"),
25
+ "/media/9f1c7e4a/card.webp": httpx.Response(200, content=b"card"),
26
+ "/media/9f1c7e4a/voice.wav": httpx.Response(200, content=b"voice"),
27
+ "/media/9f1c7e4a/clip.mp4": httpx.Response(200, content=b"clip"),
28
+ }
29
+
30
+ def handler(request):
31
+ return served[request.url.path]
32
+
33
+ snapshot = hf_relay.prepare_relay_snapshot(
34
+ ENGINE_URL,
35
+ tmp_path,
36
+ client=httpx.Client(transport=httpx.MockTransport(handler)),
37
+ )
38
+
39
+ assert snapshot.scene_count == 1
40
+ manifest = json.loads((tmp_path / "manifest.json").read_text())
41
+ (published,) = manifest["scenes"]
42
+ assert published["media"]["frame_url"] == "media/9f1c7e4a/frame.jpg"
43
+ assert (tmp_path / "media/9f1c7e4a/frame.jpg").read_bytes() == b"frame"
44
+ assert (tmp_path / "media/9f1c7e4a/voice.wav").read_bytes() == b"voice"
45
+ assert manifest["source_engine"] == ENGINE_URL
46
+
47
+
48
+ def test_prepare_relay_snapshot_skips_private_scenes_by_default(tmp_path):
49
+ private_scene = {**GOLDEN["narrated-scene.schema.json"], "visibility": "private"}
50
+
51
+ def handler(request):
52
+ assert request.url.path == "/v1/scenes"
53
+ return httpx.Response(200, json={"scenes": [private_scene]})
54
+
55
+ snapshot = hf_relay.prepare_relay_snapshot(
56
+ ENGINE_URL,
57
+ tmp_path,
58
+ client=httpx.Client(transport=httpx.MockTransport(handler)),
59
+ )
60
+
61
+ assert snapshot.scene_count == 0
62
+ assert json.loads((tmp_path / "manifest.json").read_text())["scenes"] == []
63
+
64
+
65
+ def test_prepare_relay_snapshot_can_include_private_scenes(tmp_path):
66
+ private_scene = {
67
+ **GOLDEN["narrated-scene.schema.json"],
68
+ "visibility": "private",
69
+ "media": {"frame_url": "/media/9f1c7e4a/frame.jpg"},
70
+ }
71
+
72
+ def handler(request):
73
+ if request.url.path == "/v1/scenes":
74
+ return httpx.Response(200, json={"scenes": [private_scene]})
75
+ assert request.url.path == "/media/9f1c7e4a/frame.jpg"
76
+ return httpx.Response(200, content=b"frame")
77
+
78
+ snapshot = hf_relay.prepare_relay_snapshot(
79
+ ENGINE_URL,
80
+ tmp_path,
81
+ include_private=True,
82
+ client=httpx.Client(transport=httpx.MockTransport(handler)),
83
+ )
84
+
85
+ assert snapshot.scene_count == 1
86
+ assert (tmp_path / "media/9f1c7e4a/frame.jpg").exists()
87
+
88
+
89
+ def test_prepare_relay_snapshot_can_mark_scene_source(tmp_path):
90
+ scene = {
91
+ **GOLDEN["narrated-scene.schema.json"],
92
+ "visibility": "public",
93
+ "media": {"frame_url": "/media/9f1c7e4a/frame.jpg"},
94
+ }
95
+
96
+ def handler(request):
97
+ if request.url.path == "/v1/scenes":
98
+ return httpx.Response(200, json={"scenes": [scene]})
99
+ assert request.url.path == "/media/9f1c7e4a/frame.jpg"
100
+ return httpx.Response(200, content=b"frame")
101
+
102
+ hf_relay.prepare_relay_snapshot(
103
+ ENGINE_URL,
104
+ tmp_path,
105
+ source="glasses",
106
+ client=httpx.Client(transport=httpx.MockTransport(handler)),
107
+ )
108
+
109
+ manifest = json.loads((tmp_path / "manifest.json").read_text())
110
+ (published,) = manifest["scenes"]
111
+ assert published["source"] == "glasses"
112
+ assert published["source_icon"] == "glasses"
tests/test_modal_upload.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from small_cuts.modal_upload import ModalUploadClient, ModalUploadError
7
+
8
+
9
+ def test_modal_client_submits_and_polls_result(tmp_path):
10
+ video = tmp_path / "clip.mp4"
11
+ video.write_bytes(b"fake")
12
+ calls = []
13
+
14
+ def handler(request: httpx.Request) -> httpx.Response:
15
+ calls.append((request.method, request.url.path))
16
+ if request.method == "POST":
17
+ assert request.headers["authorization"] == "Bearer secret"
18
+ return httpx.Response(200, json={"job_id": "job-1"})
19
+ if request.url.path.endswith("/job-1") and len(calls) == 2:
20
+ return httpx.Response(202, json={"status": "running"})
21
+ return httpx.Response(200, json={"status": "complete", "scene": {"scene_id": "s1"}})
22
+
23
+ client = ModalUploadClient(
24
+ "https://modal.example",
25
+ "secret",
26
+ http_client=httpx.Client(transport=httpx.MockTransport(handler)),
27
+ poll_interval_s=0,
28
+ )
29
+
30
+ assert client.submit_video(video, uploader_hf_username="alice")["scene_id"] == "s1"
31
+ assert calls == [
32
+ ("POST", "/v1/cuts"),
33
+ ("GET", "/v1/cuts/job-1"),
34
+ ("GET", "/v1/cuts/job-1"),
35
+ ]
36
+
37
+
38
+ def test_modal_client_rejects_missing_scene(tmp_path):
39
+ video = tmp_path / "clip.mp4"
40
+ video.write_bytes(b"fake")
41
+
42
+ def handler(request: httpx.Request) -> httpx.Response:
43
+ if request.method == "POST":
44
+ return httpx.Response(200, json={"job_id": "job-1"})
45
+ return httpx.Response(200, json={"status": "complete"})
46
+
47
+ client = ModalUploadClient(
48
+ "https://modal.example",
49
+ "secret",
50
+ http_client=httpx.Client(transport=httpx.MockTransport(handler)),
51
+ poll_interval_s=0,
52
+ )
53
+
54
+ with pytest.raises(ModalUploadError, match="scene"):
55
+ client.submit_video(video, uploader_hf_username="alice")
tests/test_observability.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from small_cuts import observability
2
+
3
+
4
+ class FakeSentrySdk:
5
+ def __init__(self):
6
+ self.init_calls = []
7
+ self.captured = []
8
+
9
+ def init(self, **kwargs):
10
+ self.init_calls.append(kwargs)
11
+
12
+ def capture_exception(self, exc):
13
+ self.captured.append(exc)
14
+
15
+
16
+ def test_sentry_init_is_noop_without_dsn(monkeypatch):
17
+ fake = FakeSentrySdk()
18
+ monkeypatch.delenv(observability.SENTRY_DSN_ENV, raising=False)
19
+ observability.reset_for_tests()
20
+
21
+ assert observability.init_sentry(sdk=fake) is False
22
+ assert fake.init_calls == []
23
+
24
+
25
+ def test_sentry_init_scrubs_request_payload(monkeypatch):
26
+ fake = FakeSentrySdk()
27
+ monkeypatch.setenv(observability.SENTRY_DSN_ENV, "https://example@sentry.invalid/1")
28
+ monkeypatch.setenv("SPACE_ID", "build-small-hackathon/small-cuts-live")
29
+ observability.reset_for_tests()
30
+
31
+ assert observability.init_sentry(sdk=fake) is True
32
+ (kwargs,) = fake.init_calls
33
+ assert kwargs["send_default_pii"] is False
34
+ assert kwargs["traces_sample_rate"] == 0.0
35
+ event = {
36
+ "request": {
37
+ "data": "frame bytes",
38
+ "cookies": "session",
39
+ "headers": {"authorization": "secret", "content-type": "application/json"},
40
+ "url": "https://example.test/v1/scenes",
41
+ }
42
+ }
43
+
44
+ scrubbed = kwargs["before_send"](event, {})
45
+
46
+ assert "data" not in scrubbed["request"]
47
+ assert "cookies" not in scrubbed["request"]
48
+ assert scrubbed["request"]["headers"] == {"content-type": "application/json"}
49
+
50
+
51
+ def test_capture_exception_requires_initialized_sentry(monkeypatch):
52
+ fake = FakeSentrySdk()
53
+ observability.reset_for_tests()
54
+ monkeypatch.delenv(observability.SENTRY_DSN_ENV, raising=False)
55
+
56
+ exc = RuntimeError("boom")
57
+ observability.capture_exception(exc, sdk=fake)
58
+ assert fake.captured == []
59
+
60
+ monkeypatch.setenv(observability.SENTRY_DSN_ENV, "https://example@sentry.invalid/1")
61
+ observability.capture_exception(exc, sdk=fake)
62
+ assert fake.captured == [exc]
tests/test_viewer.py CHANGED
@@ -8,6 +8,8 @@ viewer's formatter is pinned to the same shape the contract suite enforces.
8
  import json
9
  from datetime import datetime, timedelta, timezone
10
  from pathlib import Path
 
 
11
 
12
  import gradio as gr
13
  import httpx
@@ -47,6 +49,207 @@ def test_build_viewer_app_engine_mode_needs_no_live_engine(monkeypatch):
47
  assert isinstance(viewer.build_viewer_app(), gr.Blocks)
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  # -- scene-poll formatter --------------------------------------------------------------
51
 
52
 
@@ -74,6 +277,7 @@ def test_format_stage_no_scene_is_off_air():
74
  assert payload["live"] is False
75
  assert payload["frame_src"] is None
76
  assert payload["scene_id"] is None
 
77
 
78
 
79
  def test_stage_html_escapes_caption_and_has_no_rec_chip():
@@ -84,6 +288,48 @@ def test_stage_html_escapes_caption_and_has_no_rec_chip():
84
  assert "REC" not in out
85
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def test_playback_js_uses_trusted_dom_click_for_audio():
88
  # Browser audio user activation is tied to the real click call stack. Routing play through
89
  # gr.Button.click(js=...) can run too late and trigger NotAllowedError on the Space.
 
8
  import json
9
  from datetime import datetime, timedelta, timezone
10
  from pathlib import Path
11
+ from types import SimpleNamespace
12
+ from urllib.parse import unquote
13
 
14
  import gradio as gr
15
  import httpx
 
49
  assert isinstance(viewer.build_viewer_app(), gr.Blocks)
50
 
51
 
52
+ def test_build_viewer_app_bucket_relay_mode_needs_no_live_engine(monkeypatch):
53
+ monkeypatch.delenv(viewer.ENGINE_URL_ENV, raising=False)
54
+ monkeypatch.setenv(viewer.RELAY_BUCKET_ENV, "build-small-hackathon/small-cuts-scenes")
55
+ assert isinstance(viewer.build_viewer_app(), gr.Blocks)
56
+
57
+
58
+ def test_upload_sandbox_requires_modal_url(monkeypatch):
59
+ monkeypatch.setenv("SMALL_CUTS_ENABLE_UPLOAD_SANDBOX", "1")
60
+ monkeypatch.delenv("SMALL_CUTS_MODAL_API_URL", raising=False)
61
+
62
+ assert viewer.upload_sandbox_enabled() is False
63
+
64
+ monkeypatch.setenv("SMALL_CUTS_MODAL_API_URL", "https://example.modal.run")
65
+
66
+ assert viewer.upload_sandbox_enabled() is True
67
+
68
+
69
+ def test_upload_requires_hf_profile():
70
+ with pytest.raises(gr.Error, match="Sign in"):
71
+ viewer._require_upload_profile(None)
72
+
73
+
74
+ def test_upload_profile_uses_hf_username():
75
+ profile = SimpleNamespace(name="Alice Example", username="alice")
76
+
77
+ assert viewer._require_upload_profile(profile) == "alice"
78
+
79
+
80
+ def test_uploaded_scene_is_preserved_in_engine_state():
81
+ upload_scene = {
82
+ "scene_id": "modal-upload-1",
83
+ "title": "A Finished Judge Upload",
84
+ "source": "upload",
85
+ }
86
+
87
+ state = viewer._pack_engine_ui_state(
88
+ scenes=[],
89
+ pinned_id=None,
90
+ current_id=None,
91
+ playing_id=None,
92
+ previous={"upload_scene": upload_scene},
93
+ )
94
+
95
+ assert state["upload_scene"]["scene_id"] == "modal-upload-1"
96
+
97
+
98
+ def test_upload_video_cap_defaults_to_sixty_seconds(monkeypatch):
99
+ monkeypatch.delenv("SMALL_CUTS_UPLOAD_MAX_SECONDS", raising=False)
100
+
101
+ assert viewer.upload_max_seconds() == 60.0
102
+
103
+
104
+ def test_modal_scene_can_drive_uploaded_stage():
105
+ scene = {
106
+ "scene_id": "modal-1",
107
+ "title": "A sentence.",
108
+ "narration": "A sentence.",
109
+ "style_key": "deadpan",
110
+ "created_at": "2026-06-15T12:00:00+00:00",
111
+ "media": {
112
+ "frame_url": "/gradio_api/file=/tmp/frame.jpg",
113
+ "clip_url": "/gradio_api/file=/tmp/clip.mp4",
114
+ "audio_url": "/gradio_api/file=/tmp/voice.wav",
115
+ },
116
+ "duration": 12.5,
117
+ }
118
+
119
+ payload = viewer.format_stage(scene)
120
+
121
+ assert payload["clip_src"].endswith("clip.mp4")
122
+ assert payload["audio_src"].endswith("voice.wav")
123
+ assert payload["duration"] == 12.5
124
+
125
+
126
+ def test_format_stage_marks_source_icons():
127
+ scene = {**GOLDEN_SCENE, "source": "glasses"}
128
+ upload_scene = {**GOLDEN_SCENE, "source": "upload"}
129
+
130
+ glasses_payload = viewer.format_stage(scene, ENGINE_URL)
131
+ upload_payload = viewer.format_stage(upload_scene, ENGINE_URL)
132
+
133
+ assert glasses_payload["source_icon"] == "glasses"
134
+ assert upload_payload["source_icon"] == "upload"
135
+
136
+
137
+ def test_submit_modal_upload_rejects_over_duration(monkeypatch):
138
+ monkeypatch.setenv("SMALL_CUTS_UPLOAD_MAX_SECONDS", "60")
139
+ monkeypatch.setattr(viewer, "_video_duration_s", lambda _path: 61.0)
140
+
141
+ with pytest.raises(gr.Error, match="up to 60 seconds"):
142
+ viewer._submit_modal_upload(
143
+ "clip.mp4",
144
+ "deadpan",
145
+ "",
146
+ viewer._pack_engine_ui_state([], None, None, None),
147
+ SimpleNamespace(name="Alice Example", username="alice"),
148
+ fake_client(lambda _request: httpx.Response(200, json={"scenes": []})),
149
+ )
150
+
151
+
152
+ def test_submit_modal_upload_pins_returned_scene(monkeypatch):
153
+ scene = {
154
+ "scene_id": "modal-1",
155
+ "title": "A Modal Scene",
156
+ "narration": "The clip now has a real voice.",
157
+ "style_key": "deadpan",
158
+ "created_at": "2026-06-15T12:00:00+00:00",
159
+ "visibility": "public",
160
+ "media": {
161
+ "frame_url": "uploads/modal-1/media/frame.jpg",
162
+ "card_url": "uploads/modal-1/media/card.webp",
163
+ "clip_url": "uploads/modal-1/media/clip.mp4",
164
+ "audio_url": "uploads/modal-1/media/voice.wav",
165
+ },
166
+ "duration": 7.5,
167
+ "source": "upload",
168
+ }
169
+ calls = []
170
+
171
+ class FakeModalUploadClient:
172
+ def submit_video(self, video_path, *, uploader_hf_username, style_key, scene_hint):
173
+ calls.append((video_path, uploader_hf_username, style_key, scene_hint))
174
+ return scene
175
+
176
+ class FakeMediaClient:
177
+ base_url = ""
178
+
179
+ def media_url(self, path):
180
+ return f"/gradio_api/file=/tmp/{Path(path).name}" if path else None
181
+
182
+ monkeypatch.setattr(viewer, "_video_duration_s", lambda _path: 7.5)
183
+ monkeypatch.setattr(viewer, "_modal_upload_client", lambda: FakeModalUploadClient())
184
+
185
+ header, stage, feed, audio, shelf, state, visibility = viewer._submit_modal_upload(
186
+ "clip.mp4",
187
+ "deadpan",
188
+ "show the ending",
189
+ viewer._pack_engine_ui_state([], None, None, None),
190
+ SimpleNamespace(name="Alice Example", username="alice"),
191
+ FakeMediaClient(),
192
+ )
193
+
194
+ assert calls == [("clip.mp4", "alice", "deadpan", "show the ending")]
195
+ assert "A Modal Scene" in header
196
+ assert "clip.mp4" in stage
197
+ assert "sc-ico-upload" in stage
198
+ assert "real voice" in feed
199
+ assert "voice.wav" in audio
200
+ assert shelf == [
201
+ ("/gradio_api/file=/tmp/frame.jpg", f"{viewer.UPLOAD_SHELF_PREFIX}A Modal Scene")
202
+ ]
203
+ assert state["upload_scene"]["scene_id"] == "modal-1"
204
+ assert state["current_id"] == "modal-1"
205
+ assert state["playing_id"] == "modal-1"
206
+ assert visibility["value"] == "public"
207
+
208
+
209
+ def test_bucket_scene_client_reads_manifest_and_caches_media(tmp_path, monkeypatch):
210
+ class FakeBucketFs:
211
+ def __init__(self, files):
212
+ self.files = files
213
+ self.seen = []
214
+
215
+ def cat(self, path):
216
+ self.seen.append(path)
217
+ return self.files[path]
218
+
219
+ media = {
220
+ "frame_url": "media/9f1c7e4a/frame.jpg",
221
+ "card_url": "media/9f1c7e4a/card.webp",
222
+ "audio_url": "media/9f1c7e4a/voice.wav",
223
+ "clip_url": "media/9f1c7e4a/clip.mp4",
224
+ }
225
+ scene = {**GOLDEN_SCENE, "media": media}
226
+ root = "hf://buckets/build-small-hackathon/small-cuts-scenes/relay"
227
+ fake_fs = FakeBucketFs(
228
+ {
229
+ f"{root}/manifest.json": json.dumps({"scenes": [scene]}).encode(),
230
+ f"{root}/media/9f1c7e4a/frame.jpg": b"frame",
231
+ f"{root}/media/9f1c7e4a/card.webp": b"card",
232
+ f"{root}/media/9f1c7e4a/voice.wav": b"voice",
233
+ f"{root}/media/9f1c7e4a/clip.mp4": b"clip",
234
+ }
235
+ )
236
+ monkeypatch.setattr(viewer.gr, "set_static_paths", lambda _paths: None)
237
+
238
+ client = viewer.BucketSceneClient(
239
+ "build-small-hackathon/small-cuts-scenes",
240
+ prefix="relay",
241
+ fs=fake_fs,
242
+ cache_dir=tmp_path,
243
+ )
244
+
245
+ (hydrated,) = client.list_scenes()
246
+ frame_url = hydrated["media"]["frame_url"]
247
+ assert frame_url.startswith("/gradio_api/file=")
248
+ assert Path(unquote(frame_url.removeprefix("/gradio_api/file="))).read_bytes() == b"frame"
249
+ assert (tmp_path / "media/9f1c7e4a/voice.wav").read_bytes() == b"voice"
250
+ assert f"{root}/manifest.json" in fake_fs.seen
251
+
252
+
253
  # -- scene-poll formatter --------------------------------------------------------------
254
 
255
 
 
277
  assert payload["live"] is False
278
  assert payload["frame_src"] is None
279
  assert payload["scene_id"] is None
280
+ assert payload["source_icon"] is None
281
 
282
 
283
  def test_stage_html_escapes_caption_and_has_no_rec_chip():
 
288
  assert "REC" not in out
289
 
290
 
291
+ def test_stage_html_shows_source_badges():
292
+ glasses = viewer.render_stage_html(
293
+ "http://x/f.jpg", "caption", live=False, source_icon="glasses"
294
+ )
295
+ upload = viewer.render_stage_html("http://x/f.jpg", "caption", live=False, source_icon="upload")
296
+
297
+ assert "sc-source-badge" in glasses
298
+ assert "sc-ico-glasses" in glasses
299
+ assert "Glasses capture" in glasses
300
+ assert "sc-source-badge" in upload
301
+ assert "sc-ico-upload" in upload
302
+ assert "Space upload" in upload
303
+
304
+ assert "sc-source-badge" not in viewer.render_stage_html(
305
+ "http://x/f.jpg", "caption", live=False, source_icon=None
306
+ )
307
+
308
+
309
+ def test_shelf_items_marks_source_tiles():
310
+ class FakeMediaClient:
311
+ def media_url(self, path):
312
+ return f"/media/{path}" if path else None
313
+
314
+ glasses_scene = {
315
+ **GOLDEN_SCENE,
316
+ "source": "glasses",
317
+ "title": "A Glasses Scene",
318
+ "media": {"frame_url": "frame.jpg"},
319
+ }
320
+ upload_scene = {
321
+ **GOLDEN_SCENE,
322
+ "source": "upload",
323
+ "title": "An Upload Scene",
324
+ "media": {"frame_url": "upload.jpg"},
325
+ }
326
+
327
+ items = viewer.shelf_items([glasses_scene, upload_scene], FakeMediaClient())
328
+
329
+ assert items[0] == ("/media/frame.jpg", f"{viewer.GLASSES_SHELF_PREFIX}A Glasses Scene")
330
+ assert items[1] == ("/media/upload.jpg", f"{viewer.UPLOAD_SHELF_PREFIX}An Upload Scene")
331
+
332
+
333
  def test_playback_js_uses_trusted_dom_click_for_audio():
334
  # Browser audio user activation is tied to the real click call stack. Routing play through
335
  # gr.Button.click(js=...) can run too late and trigger NotAllowedError on the Space.