Deploy hybrid Modal upload
Browse files- README.md +22 -12
- app.py +5 -3
- docs/demo-readiness.md +229 -0
- docs/field-notes-small-cuts.md +20 -10
- docs/submission-assets-2026-06-15.md +222 -0
- modal_app/small_cuts_postcut.py +215 -0
- pyproject.toml +1 -0
- requirements.txt +1 -0
- scripts/publish_hf_relay.py +6 -0
- src/small_cuts/CLAUDE.md +22 -2
- src/small_cuts/_icons.py +3 -0
- src/small_cuts/engine/library.py +7 -4
- src/small_cuts/hf_relay.py +7 -1
- src/small_cuts/modal_upload.py +87 -0
- src/small_cuts/viewer.py +257 -8
- tests/test_app_entrypoint.py +70 -0
- tests/test_engine_library.py +13 -0
- tests/test_hf_relay.py +112 -0
- tests/test_modal_upload.py +55 -0
- tests/test_observability.py +62 -0
- tests/test_viewer.py +246 -0
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title: Small Cuts
|
| 3 |
emoji: 🎬
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: purple
|
|
@@ -17,7 +17,7 @@ tags:
|
|
| 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,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
|
| 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
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
and library thumbnail.
|
| 53 |
|
| 54 |
-
**One
|
| 55 |
-
|
| 56 |
-
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 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 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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"],
|
| 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
|
|
|
|
|
|
|
| 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
|
| 1115 |
# one signature voice — no director menu; voice-over is on by default
|
| 1116 |
style = gr.State(DEFAULT_STYLE_KEY)
|
| 1117 |
-
|
| 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
|
| 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
|
| 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.
|