"""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 json import math import os import sys import tempfile import time 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 config, 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 .narrate_v2 import PERSONA_DEFAULT_KEY, PERSONA_LABELS, persona_choices, resolve_persona_steer 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 from .upload_budget import BudgetDecision, DailyProcessingBudget from .upload_library import LocalUploadLibrary ENGINE_URL_ENV = "SMALL_CUTS_ENGINE_URL" DISABLE_ENGINE_AUTODETECT_ENV = "SMALL_CUTS_DISABLE_ENGINE_AUTODETECT" MODAL_API_URL_ENV = "SMALL_CUTS_MODAL_API_URL" MODAL_API_TOKEN_ENV = config.MODAL_API_TOKEN_ENV MODAL_API_VERSION_ENV = "SMALL_CUTS_MODAL_API_VERSION" UPLOAD_SANDBOX_ENV = "SMALL_CUTS_ENABLE_UPLOAD_SANDBOX" UPLOAD_MAX_BYTES = config.MAX_UPLOAD_BYTES # shared with Modal; see small_cuts.config 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() _KEEP_UPLOAD_ERROR = 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; } /* CC captions default OFF (voice-first thesis); shown only when the viewer opts in. The gate lives on .sc-theater (NOT
) so Gradio's CSS prefixer (.gradio-container… .contain) can't break it: is an ancestor of .contain, so a body-rooted selector never matches after prefixing. .sc-theater is inside .contain, persists across stage re-renders, and is an ancestor of both #sc-subtitle and .sc-cc-btn. */ .sc-theater:not(.sc-cc-on) .sc-subtitle { display: none; } /* CC matches the other pill controls: gold glyph on the transparent dark pill (OFF), gold fill when ON. Was dark-text-on-a-cream-box, which read as a foreign element in the control row. */ .sc-cc-btn.sc-icbtn { color: #D4AF37 !important; background-color: transparent !important; font-size: .72rem !important; font-weight: 700; letter-spacing: .06em; display: flex !important; align-items: center; justify-content: center; -webkit-mask-image: none !important; mask-image: none !important; } .sc-cc-btn.sc-icbtn:hover { background-color: #fff5d5 !important; color: #1a1a1f !important; } .sc-theater.sc-cc-on .sc-cc-btn.sc-icbtn { background-color: #D4AF37 !important; color: #1a1a1f !important; } .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; } /* Three structured lines per thumbnail: title / language / narrator style. The caption string carries real newlines (see _shelf_caption); `white-space: pre-line` renders them as breaks. The narrator-style line can wrap to a 4th line, so the caption sizes to its content (no fixed line-clamp that would crop it): the bottom-anchored overlay grows up the image and stays within the 146px card. padding-bottom lifts the last line off the card's clip edge so descenders are not shaved. */ .sc-shelf .caption-label { white-space: pre-line !important; text-overflow: clip !important; display: block !important; overflow: visible !important; padding-bottom: 5px !important; line-height: 1.2 !important; font-size: 11px !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); } .sc-playback-events { display: none !important; } /* --- 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-entry { flex: 0 0 auto !important; width: auto !important; min-width: 0 !important; display: inline-flex !important; flex-direction: column !important; align-items: center !important; gap: 3px !important; margin-left: auto !important; padding: 0 !important; } .sc-upload-entry .block { width: auto !important; min-width: 0 !important; } .sc-upload.sc-icbtn { cursor: pointer !important; margin-left: 0 !important; } .sc-upload.sc-icbtn { font-size: 0 !important; line-height: 0 !important; overflow: hidden !important; } .sc-upload-cta { display: block; color: #8a8894; font-family: 'IBM Plex Mono', monospace; font-size: .58rem; line-height: 1; letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; } @media (min-width: 861px) { .sc-topbar.sc-hf-header-present { padding-top: 48px !important; } #sc-upload-popover.sc-hf-header-present { top: 100px !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(372px, 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:has(.form.hidden):has(.sc-narrate-btn.hidden) { display: 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: .64rem; line-height: 1.45; text-transform: uppercase; letter-spacing: .04em; overflow-wrap: anywhere; } /* 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.sc-narrate-btn:disabled, #sc-upload-popover button.sc-narrate-btn.sc-upload-submit-locked, #sc-upload-popover .sc-narrate-btn button:disabled, #sc-upload-popover .sc-narrate-btn button.sc-upload-submit-locked { opacity: .58 !important; cursor: not-allowed !important; filter: saturate(.72) !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; } .sc-upload-status.blocked { color: #D4AF37; display: block; } /* 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 hidden