"""Small Cuts P1 viewer — the narrator as a live-streaming channel (#28). One page, two operating modes decided at build time by ``SMALL_CUTS_ENGINE_URL``: - **Engine mode** (env set): polls the home-node engine's scene library (``GET /v1/scenes``) every couple of seconds and replays it as a live channel — newest scene on the 9:16 stage, narration lines in a chat-style feed, and library shelf as a VOD rail. - **Upload mode** (env unset — the hackathon Space): the same chrome, fed by the local pipeline from ``ui.py``. A "go live" dropzone under the chat feed narrates a moment straight onto the stage; scenes accumulate in session state only. No custom frontend: plain Gradio blocks, de-Gradio'd with CSS, so the page runs on the hackathon Space unchanged. """ from __future__ import annotations import base64 import html import io import os import sys import tempfile import uuid import warnings from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from urllib.parse import unquote import gradio as gr import httpx import soundfile as sf from PIL import Image from . import demo_seed from ._icons import ICON_CSS from .frames import pick_key_frame, sample_frames from .hf_relay import ( DEFAULT_RELAY_PREFIX, GRADIO_FILE_ROUTE, MEDIA_KEYS, RELAY_BUCKET_ENV, RELAY_PREFIX_ENV, BucketRelayError, ) from .hf_relay import ( BucketSceneClient as _BucketSceneClient, ) from .modal_upload import ModalUploadClient, ModalUploadError from .observability import capture_exception from .styles import DEFAULT_STYLE_KEY, STYLES from .title_card import derive_title from .tts import speak from .ui import THEME as THEME # re-export: app.py launches the viewer with the Off-Brand theme from .ui import TITLE, _gpu, _narrate_core, _speak_handler ENGINE_URL_ENV = "SMALL_CUTS_ENGINE_URL" MODAL_API_URL_ENV = "SMALL_CUTS_MODAL_API_URL" MODAL_API_TOKEN_ENV = "SMALL_CUTS_MODAL_API_TOKEN" UPLOAD_SANDBOX_ENV = "SMALL_CUTS_ENABLE_UPLOAD_SANDBOX" UPLOAD_MAX_SECONDS_ENV = "SMALL_CUTS_UPLOAD_MAX_SECONDS" UPLOAD_MAX_BYTES = 80 * 1024 * 1024 UPLOAD_FORMAT_LABEL = "MP4, MOV, WebM, M4V" UPLOAD_ALLOWED_SUFFIXES = {".mp4", ".mov", ".webm", ".m4v"} # The narrator-as-chat feed is dropped from the default layout (single centered column); # flip this on to revive it (a future "see transcription" surface for non-live clips). SHOW_FEED = os.environ.get("SMALL_CUTS_SHOW_FEED", "").strip().lower() not in ( "", "0", "false", "no", ) POLL_SECONDS = 2.0 LIVE_WINDOW_S = 60.0 # ●REC reads LIVE when the newest scene is younger than this FEED_LIMIT = 12 SHELF_LIMIT = 60 HTTP_TIMEOUT_S = 5.0 UPLOAD_QUEUE_MAX_SIZE = 8 UPLOAD_CONCURRENCY_ID = "small-cuts-modal-upload" VISIBILITIES = ("private", "shared", "public") _KEEP_UPLOAD_SCENE = object() SOURCE_ICON_LABELS = { "glasses": "Glasses capture", "upload": "Space upload", } GLASSES_SHELF_PREFIX = "\u2062" UPLOAD_SHELF_PREFIX = "\u2063" _SOURCE_SHELF_PREFIXES = { "glasses": GLASSES_SHELF_PREFIX, "upload": UPLOAD_SHELF_PREFIX, } EMPTY_STAGE_CAPTION = ( "The narrator clears his throat, looks at the empty screen, and waits. " "Some scenes, after all, require a scene." ) EMPTY_VIDEO_CAPTION = ( "The narrator squints at the projector. Nothing. He has narrated " "blank screens before, but never by choice." ) # Brand marks (Review-3): the "Voice Cut" app icon, inlined. The brand mark sits in the # top bar (replacing the generic clapperboard emoji); the same motif is the favicon (injected # in PLAYBACK_SYNC_JS). The rail mark (film-cut glyph, currentColor) heads the library. BRAND_MARK_SVG = ( '' ) RAIL_MARK_SVG = ( '' '' '' '' '' ) # De-Gradio CSS: the Off-Brand theme stays the base; this layer turns blocks # into a streaming-platform page (portrait stage, chat feed, VOD shelf). VIEWER_CSS = """ footer { display: none !important; } .sc-plain, .sc-plain .block { border: none !important; background: transparent !important; box-shadow: none !important; padding: 0 !important; } .sc-brand { font-family: 'IBM Plex Mono', monospace; font-size: .72rem; letter-spacing: .22em; color: #8a8894; text-transform: uppercase; padding: 2px 4px 0; } .sc-soul { display: block; font-family: 'Spectral', serif; font-style: italic; text-transform: none; letter-spacing: normal; font-size: .82rem; color: #6f6d78; margin-top: 3px; } .sc-header { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; padding: 2px 4px 10px; border-bottom: 1px solid #2A292F; } .sc-header-title { font-family: 'Spectral', serif; font-size: 1.35rem; color: #E8E4D8; } .sc-header-channel { font-family: 'IBM Plex Mono', monospace; font-size: .78rem; color: #D4AF37; letter-spacing: .08em; text-transform: uppercase; } .sc-live-hint { display: inline-block; margin-left: 8px; font-family: 'IBM Plex Mono', monospace; font-size: .72rem; letter-spacing: .08em; color: #D4AF37; text-transform: uppercase; } .sc-live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #D4AF37; margin-right: 9px; vertical-align: middle; animation: sc-pulse 1.4s ease-in-out infinite; } .sc-stage-shell { position: relative; height: min(70vh, 640px); aspect-ratio: 9 / 16; margin: 0 auto; border-radius: 18px; overflow: hidden; background: #000; border: 1px solid #2A292F; } .sc-stage-shell img, .sc-stage-shell video { width: 100%; height: 100%; object-fit: cover; display: block; } .sc-stage-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 3rem; opacity: .35; } .sc-source-badge { position: absolute; top: 10px; left: 10px; z-index: 4; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; color: #f3efe4; background: rgba(8,8,10,.68); border: 1px solid rgba(243,239,228,.24); box-shadow: 0 3px 12px rgba(0,0,0,.22); backdrop-filter: blur(7px); } .sc-source-badge-icon { width: 17px; height: 17px; background: currentColor; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; -webkit-mask-size: contain; mask-size: contain; } .sc-subtitle { position: absolute; left: 50%; transform: translateX(-50%); bottom: 26px; width: min(92%, 600px); min-height: 2.7em; display: flex; align-items: center; justify-content: center; text-align: center; background: rgba(8,8,10,.72); color: #f3efe4; border-radius: 9px; padding: 11px 16px; font-family: 'Spectral', serif; font-size: 1.04rem; line-height: 1.38; text-shadow: 0 1px 2px rgba(0,0,0,.85); } .sc-subtitle .sc-sub-line[hidden] { display: none; } .sc-rec { position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center; gap: 7px; background: rgba(16,16,20,.78); color: #D4AF37; padding: 4px 11px; border-radius: 999px; border: 1px solid rgba(212,175,55,.45); font-family: 'IBM Plex Mono', monospace; font-size: .7rem; letter-spacing: .14em; } .sc-rec-dot { width: 8px; height: 8px; border-radius: 50%; background: #D4AF37; animation: sc-pulse 1.4s ease-in-out infinite; } .sc-rec.standby { color: #8a8894; border-color: #2A292F; } .sc-rec.standby .sc-rec-dot { background: #55545e; animation: none; } @keyframes sc-pulse { 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(212,175,55,.55); } 50% { opacity: .5; box-shadow: 0 0 0 7px rgba(212,175,55,0); } } .sc-feed { display: flex; flex-direction: column-reverse; height: 430px; overflow-y: auto; background: #16161C; border: 1px solid #2A292F; border-radius: 14px; padding: 6px 0; } .sc-feed-line { padding: 9px 14px; border-bottom: 1px solid #1f1f26; font-size: .9rem; line-height: 1.4; } .sc-feed-line:first-child { border-bottom: none; } .sc-chat-time { color: #6f6e78; font-family: 'IBM Plex Mono', monospace; font-size: .7rem; } .sc-chat-name { color: #D4AF37; font-weight: 600; margin-right: 4px; } .sc-chat-text { color: #E8E4D8; } .sc-feed-empty { padding: 14px; color: #6f6e78; font-style: italic; } .sc-actionbar { align-items: center; } .sc-audio { max-width: 430px; } .sc-dropzone { background: #16161C; border: 1px dashed #2A292F; border-radius: 14px; padding: 10px !important; margin-top: 10px; gap: 8px !important; } .sc-dropzone .block { background: transparent !important; border: none !important; } .sc-dropzone-label { font-family: 'IBM Plex Mono', monospace; font-size: .72rem; letter-spacing: .14em; color: #8a8894; text-transform: uppercase; } .sc-shelf { background: transparent !important; border: none !important; } .sc-shelf .thumbnail-item { position: relative; } .sc-shelf .thumbnail-item:has(img[alt^="\\002062"])::before, .sc-shelf .thumbnail-item:has(img[alt^="\\002063"])::before { content: ""; position: absolute; top: 6px; left: 6px; width: 24px; height: 24px; border-radius: 999px; background: rgba(8,8,10,.68); border: 1px solid rgba(243,239,228,.22); z-index: 3; backdrop-filter: blur(6px); box-shadow: 0 2px 8px rgba(0,0,0,.2); } .sc-shelf .thumbnail-item:has(img[alt^="\\002062"])::after, .sc-shelf .thumbnail-item:has(img[alt^="\\002063"])::after { content: ""; position: absolute; top: 11px; left: 11px; width: 14px; height: 14px; background: #f3efe4; z-index: 4; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; -webkit-mask-size: contain; mask-size: contain; } .sc-shelf .thumbnail-item:has(img[alt^="\\002062"])::after { -webkit-mask-image: var(--sc-ico-glasses-mask); mask-image: var(--sc-ico-glasses-mask); } .sc-shelf .thumbnail-item:has(img[alt^="\\002063"])::after { -webkit-mask-image: var(--sc-ico-upload-mask); mask-image: var(--sc-ico-upload-mask); } /* --- Review-2 relayout: single centered column, control pill, masked icons --- */ .sc-topbar { display: flex; align-items: flex-start; gap: 12px; } .sc-topbar .sc-brand { flex: 1 1 auto; } .sc-upload-auth { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; display: inline-flex !important; align-items: center !important; justify-content: flex-end !important; gap: 8px !important; align-self: flex-start !important; margin-left: auto !important; } /* R1: the sign-in affordance is the compact pill ONLY — the gr.LoginButton must never render as a full-width blue bar. We restyle it (button + any /.lg wrappers) into the same charcoal pill the rest of the chrome uses; the OAuth mechanism underneath is untouched. */ .sc-upload-signin { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; display: inline-flex !important; } /* Hide the login pill on sign-in via this wrapper Column (gr.LoginButton ignores visible). */ .sc-upload-signin-box { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; padding: 0 !important; display: inline-flex !important; } .sc-iframe-warn { font-family: 'IBM Plex Mono', monospace !important; font-size: .68rem !important; color: #D4AF37 !important; text-decoration: underline !important; margin-right: 8px; white-space: nowrap; } .sc-iframe-warn:hover { color: #fff5d5 !important; } .sc-upload-signin button, .sc-upload-signin a, .sc-upload-signin .lg { width: auto !important; min-width: 0 !important; max-width: max-content !important; height: 30px !important; padding: 0 12px !important; border: 1px solid #2A292F !important; border-radius: 999px !important; background: transparent !important; color: #E8E4D8 !important; background-image: none !important; box-shadow: none !important; font-size: .72rem !important; letter-spacing: 0 !important; font-family: 'IBM Plex Mono', monospace !important; white-space: nowrap !important; } .sc-upload-signin button:hover, .sc-upload-signin a:hover { border-color: #D4AF37 !important; color: #fff5d5 !important; } /* "Signed in (user)" reads as a subtle gold confirmation rather than an action. */ .sc-upload-signin.sc-signed-in button, .sc-upload-signin.sc-signed-in a { color: #D4AF37 !important; cursor: default !important; } /* R1: signed-OUT upload icon is visibly DISABLED (dimmed + not-allowed), not hidden — it is the primary affordance, so it always shows. Gradio marks a non-interactive Button with .disabled / [disabled]; we also dim the whole top-right cluster's icon when gated. */ .sc-upload.sc-icbtn:disabled, .sc-upload.sc-icbtn.disabled, .sc-upload.sc-icbtn[disabled] { opacity: .55 !important; cursor: not-allowed !important; background-color: #8a8894 !important; } .sc-upload.sc-icbtn:disabled:hover, .sc-upload.sc-icbtn.disabled:hover, .sc-upload.sc-icbtn[disabled]:hover { background-color: #8a8894 !important; } .sc-upload.sc-icbtn:not(:disabled):not(.disabled) { cursor: pointer !important; } .sc-header { justify-content: center; text-align: center; } .sc-progress { max-width: 560px; height: 4px; margin: 12px auto 2px; border-radius: 3px; background: #2A292F; overflow: hidden; } .sc-progress-fill { height: 100%; width: 0%; background: #D4AF37; transition: width .12s linear; } .sc-controls { display: flex; align-items: center; justify-content: center; gap: 12px; max-width: 560px; margin: 4px auto 0; padding: 6px 16px; background: linear-gradient(180deg,#1c1d22,#141419); border: 1px solid #2A292F; border-radius: 999px; } /* custom file-backed player styles (audio host + volume slider) live below (Review-3). */ .sc-meta { display: flex; align-items: center; justify-content: center; gap: 18px; max-width: 560px; margin: 8px auto 0; } .sc-icbtn { min-width: 0 !important; width: 42px !important; height: 42px !important; padding: 0 !important; border: none !important; box-shadow: none !important; background-image: none !important; background-color: #aaa798 !important; color: transparent !important; flex: 0 0 auto; border-radius: 0 !important; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; -webkit-mask-size: 22px 22px; mask-size: 22px 22px; transition: background-color .15s ease; } .sc-icbtn:hover { background-color: #fff5d5 !important; } .sc-upload { width: 36px !important; height: 36px !important; -webkit-mask-size: 24px; mask-size: 24px; background-color: #8a8894 !important; } .sc-ico-like-filled.sc-icbtn, .sc-ico-flag-filled.sc-icbtn { background-color: #D4AF37 !important; } #sc-upload-popover { position: fixed !important; top: 52px; right: 28px; z-index: 900; width: min(338px, calc(100vw - 32px)); min-height: 0 !important; height: auto !important; overflow: visible !important; padding: 0 !important; background: transparent !important; border: none !important; box-shadow: none !important; } #sc-upload-popover > #sc-upload-popover { position: static !important; width: 100% !important; min-height: 0 !important; height: auto !important; padding: 0 !important; background: transparent !important; border: none !important; box-shadow: none !important; overflow: visible !important; } #sc-upload-popover > .styler, #sc-upload-popover > #sc-upload-popover > .styler { padding: 14px !important; background: rgba(17,17,22,.97) !important; border: 1px solid #2A292F !important; border-radius: 8px !important; box-shadow: 0 18px 50px rgba(0,0,0,.38) !important; backdrop-filter: blur(10px); overflow: visible !important; } #sc-upload-popover .block { background: transparent !important; border: none !important; box-shadow: none !important; } .sc-upload-help-title { font-family: 'Spectral', serif; color: #E8E4D8; font-size: 1rem; line-height: 1.2; margin-bottom: 5px; } .sc-upload-help-meta { color: #8a8894; font-family: 'IBM Plex Mono', monospace; font-size: .68rem; line-height: 1.5; text-transform: uppercase; letter-spacing: .08em; } /* R2: a generous, centered "Drop Video Here" zone — dashed border that brightens on hover/drag, full popover width, theme-dark. Gradio's gr.Video upload renders a .upload-container with a dashed drop target; we own its look and center its contents. */ .sc-upload-video { margin: 10px 0 10px !important; width: 100% !important; } .sc-upload-video > .block, .sc-upload-video .image-frame, .sc-upload-video .upload-container { width: 100% !important; } .sc-upload-video .wrap, .sc-upload-video .upload-container, .sc-upload-video [data-testid="video"] > div:first-child { min-height: 132px !important; display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; gap: 6px !important; width: 100% !important; padding: 18px 14px !important; border-radius: 10px !important; border: 1.5px dashed #3a3942 !important; background: #0f0f14 !important; color: #8a8894 !important; text-align: center !important; transition: border-color .15s ease, background .15s ease, color .15s ease; } .sc-upload-video .wrap:hover, .sc-upload-video .upload-container:hover, .sc-upload-video .wrap.drag, .sc-upload-video .drag { border-color: #D4AF37 !important; background: #15151b !important; color: #E8E4D8 !important; } /* dropzone label: replace Gradio's terse copy with the cinematic two-line affordance */ .sc-upload-video .wrap .or, .sc-upload-video .upload-container .or { color: #6f6e78 !important; font-family: 'IBM Plex Mono', monospace !important; font-size: .68rem !important; letter-spacing: .12em !important; text-transform: uppercase; } .sc-upload-video svg { color: #D4AF37 !important; opacity: .85; } /* once a clip is in, let the preview/player fill the zone without the dashed frame */ .sc-upload-video video { width: 100% !important; border-radius: 10px !important; background: #000 !important; } /* the optional scene-hint sits below the zone; the Narrate button is full-width below that. */ .sc-upload-hint textarea, .sc-upload-hint input { background: #0f0f14 !important; border: 1px solid #2A292F !important; border-radius: 8px !important; color: #E8E4D8 !important; font-size: .82rem !important; } .sc-upload-hint label span, .sc-upload-hint .sc-upload-help-meta { color: #8a8894 !important; } #sc-upload-popover .sc-narrate-btn button, #sc-upload-popover .sc-narrate-btn { width: 100% !important; min-height: 38px !important; } #sc-upload-popover button { width: 100% !important; min-height: 26px !important; } .sc-upload-status { min-height: 22px; display: flex; align-items: center; gap: 8px; color: #8a8894; font-size: .78rem; line-height: 1.3; } .sc-upload-status.running { color: #E8E4D8; } .sc-upload-status.complete { color: #D4AF37; } /* R5: ONE loader — a director's clapperboard. The top clapper arm is hinged at its LEFT end and swings open ~30deg then snaps shut on a ~1.1s loop (ease-out open, fast snap closed, tiny settle). Only transform:rotate is animated; transform-origin is pinned at the hinge (8,30 in the 0..120 x 0..96 viewBox). */ .sc-clap-arm { transform-origin: 8px 30px; animation: sc-clap-swing 1.1s ease-in-out infinite; } @keyframes sc-clap-swing { 0% { transform: rotate(0deg); } /* shut */ 10% { transform: rotate(0deg); } /* hold shut */ 55% { transform: rotate(-30deg); } /* ease open */ 70% { transform: rotate(-2deg); } /* fast snap toward shut */ 80% { transform: rotate(-6deg); } /* tiny rebound */ 90% { transform: rotate(0deg); } /* settle */ 100% { transform: rotate(0deg); } } /* the full result-box overlay: clapperboard centered over the (not-yet-revealed) result video */ .sc-clap-loader { position: absolute; inset: 0; z-index: 6; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: radial-gradient(circle at 50% 42%, #16161c 0%, #0a0a0d 100%); } .sc-clap-loader .sc-clap { filter: drop-shadow(0 6px 16px rgba(0,0,0,.45)); } .sc-clap-caption { font-family: 'IBM Plex Mono', monospace; font-size: .74rem; letter-spacing: .18em; text-transform: uppercase; color: #D4AF37; min-height: 1em; } /* cycle the caption text without JS: 3 phases over the same 1.1s feel (3.3s full cycle) */ .sc-clap-loader .sc-clap-caption { animation: sc-clap-cap 3.3s steps(1) infinite; } @keyframes sc-clap-cap { 0%, 33% { content: "Rolling..."; } 34%, 66% { content: "Action..."; } 67%, 100% { content: "Cutting..."; } } /* the inline (status-line) clapperboard is tiny and shares the same swing animation */ .sc-clap-mini { display: inline-flex; width: 18px; height: 15px; } .sc-clap-mini .sc-clap { width: 18px; height: 15px; } @media (prefers-reduced-motion: reduce) { .sc-clap-arm { animation: none; } .sc-clap { animation: sc-clap-pulse 1.4s ease-in-out infinite; } .sc-clap-loader .sc-clap-caption { animation: none; } @keyframes sc-clap-pulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } } } /* R5: suppress Gradio's own progress/spinner overlays inside the upload popover AND the result stage, so the clapperboard is the only motion during generation. */ #sc-upload-popover .progress-text, #sc-upload-popover .wrap.default, #sc-upload-popover .meta-text, #sc-upload-popover .meta-text-center, #sc-upload-popover .progress-bar, #sc-upload-popover .eta-bar, .sc-stage-block .progress-text, .sc-stage-block .wrap.default, .sc-stage-block .meta-text, .sc-stage-block .progress-bar, .sc-stage-block .eta-bar, #sc-upload-popover .generating, #sc-upload-popover .loading, #sc-upload-popover .loading-overlay, #sc-upload-popover .loading-spinner, .sc-stage-block .generating, .sc-stage-block .loading, .sc-stage-block .loading-overlay, .sc-stage-block .loading-spinner { display: none !important; } /* hide Gradio's spinner/loader overlay (svelte .wrap) scoped to these blocks only */ #sc-upload-popover .wrap.generating, .sc-stage-block .wrap.generating, #sc-upload-popover .wrap.translucent, .sc-stage-block .wrap.translucent { opacity: 0 !important; background: transparent !important; } /* During generation (body gets .sc-generating from __scGenerating) the clapperboard over the stage is the ONLY loader: hide Gradio's queue status + spinner on EVERY output app-wide (header, feed, shelf, audio), not just the stage/popover. */ body.sc-generating .progress-text, body.sc-generating .meta-text, body.sc-generating .meta-text-center, body.sc-generating .progress-bar, body.sc-generating .eta-bar, body.sc-generating .wrap.default, body.sc-generating .generating, body.sc-generating .loading, body.sc-generating .loading-overlay, body.sc-generating .loading-spinner, body.sc-generating .spinner, body.sc-generating .show-progress { display: none !important; } body.sc-generating .wrap.generating, body.sc-generating .wrap.translucent { opacity: 0 !important; background: transparent !important; } /* custom file-backed player (Review-3): the master clock is a hidden 🔗 Open Direct Link'; authContainer.insertAdjacentHTML('afterbegin', warnHtml); } }; checkIframeAuth(); // Re-check periodically or on DOM changes to ensure it is added when the top bar renders const topbarObs = new MutationObserver(checkIframeAuth); topbarObs.observe(document.body, { childList: true, subtree: true }); } } """ PLAYBACK_SYNC_JS = PLAYBACK_SYNC_JS.replace( "__SC_CLAP_LOADER_HTML__", "`" + render_clapperboard_html() + "`" ) RELAY_EVENT_BRIDGE_JS = """ if (window.__scRelayPush) return; window.__scRelayPush = true; try { const events = new EventSource('/small-cuts/events'); events.addEventListener('relay-scene', (event) => { let payload = {}; try { payload = event.data ? JSON.parse(event.data) : {}; } catch (e) {} trigger('relay_scene', payload); }); } catch (e) {} """ def build_viewer_app() -> gr.Blocks: """The P1 viewer page. Mode is decided once, at build time, from the env.""" engine_url = os.environ.get(ENGINE_URL_ENV, "").strip() if not engine_url and not os.environ.get("SPACE_ID"): # Auto-detect local engine running on port 8077 (for dev/tailnet live loops) try: with httpx.Client(timeout=0.25) as c: resp = c.get("http://127.0.0.1:8077/v1/scenes") if resp.status_code == 200: engine_url = "http://127.0.0.1:8077" except Exception: pass relay_bucket = os.environ.get(RELAY_BUCKET_ENV, "").strip() relay_prefix = os.environ.get(RELAY_PREFIX_ENV, DEFAULT_RELAY_PREFIX).strip() if engine_url: client: EngineClient | BucketSceneClient | None = EngineClient(engine_url) elif relay_bucket: client = BucketSceneClient(relay_bucket, prefix=relay_prefix or DEFAULT_RELAY_PREFIX) else: client = None seed = _seed_scenes() if client is None else [] upload_sandbox = upload_sandbox_enabled() upload_enabled = client is None or upload_sandbox if client: boot_header = render_header_html("Tuning the antenna…", "standby", live=False) boot_stage = render_stage_html(None, "Waiting for the engine's first scene.", live=False) boot_audio = _audio_html(None) else: boot = format_stage(seed[-1] if seed else None) boot_header = render_header_html(boot["title"], boot["style_label"], live=False) boot_stage = render_stage_html( boot["frame_src"], boot["caption"], live=False, clip_src=boot["clip_src"], duration=boot["duration"], source_icon=boot["source_icon"], ) boot_audio = _audio_html(boot["audio_src"]) with warnings.catch_warnings(): # Gradio 6 moved `css` to launch(), but the constructor value is kept # as the launch-time fallback — passing it here keeps the de-Gradio # CSS attached however the Space launches the demo. warnings.filterwarnings("ignore", message=".*moved from the Blocks constructor.*") blocks = gr.Blocks(title=TITLE, css=VIEWER_CSS) with blocks as demo: scenes_state = gr.State( _pack_engine_ui_state([], None, None, None) if client is not None else seed ) pinned_state = gr.State(None) # scene_id pinned from the shelf, None = follow live liked_state = gr.State(set()) # upload-mode session-local likes, keyed by scene_id reported_state = gr.State(set()) # upload-mode session-local reports, keyed by scene_id upload_auth_state = gr.State({}) if upload_sandbox else None with gr.Row(elem_classes="sc-topbar"): gr.HTML( f'
{BRAND_MARK_SVG}' " Small Cuts · always rolling" 'Born on the glasses — what the narrator says in your ' "ear lands here as a cut you can keep.
", padding=False, ) if upload_sandbox: # R1: the cloud icon is the affordance and is ALWAYS shown. It boots DISABLED # (dimmed, not-allowed) and the compact pill is the only sign-in control — never a # full-width LoginButton bar. `_upload_auth_ui` flips the icon to enabled on load # if the visitor already has a Hugging Face session (no layout jump either way). with gr.Row(elem_classes="sc-upload-auth"): # The LoginButton sits in a Column we can reliably hide on sign-in; hiding it # when signed in removes the logout affordance that caused the sign-in loop. with gr.Column( visible=True, min_width=0, elem_classes="sc-upload-signin-box" ) as upload_login_box: gr.LoginButton( "🤗 Sign in to upload", logout_value="Signed in ({})", size="sm", elem_classes=["sc-upload-signin"], ) upload_btn = gr.Button( "", interactive=False, elem_classes=_upload_icon_classes(enabled=False), ) elif upload_enabled: upload_btn = gr.Button("", elem_classes=["sc-icbtn", "sc-upload", "sc-ico-upload"]) # Theater layout (Review-3): stage + controls on the left, the library as a side rail on # the right. Fills the width and keeps everything in one viewport (no main scroll); the # media query in VIEWER_CSS collapses it to a single column + horizontal rail on mobile. with gr.Row(elem_classes="sc-theater"): with gr.Column(elem_classes="sc-stage-col"): header = gr.HTML(boot_header, elem_classes="sc-plain", padding=False) stage = gr.HTML( boot_stage, elem_classes=["sc-plain", "sc-stage-block"], padding=False ) gr.HTML( '
' '
', elem_classes="sc-plain", padding=False, ) with gr.Row(elem_classes="sc-controls"): # Custom file-backed player (Review-3): gr.Audio can't be the clock — it plays # via wavesurfer, leaving its