"""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 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, 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" # 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 VISIBILITIES = ("private", "shared", "public") _KEEP_UPLOAD_SCENE = object() SOURCE_ICON_LABELS = { "glasses": "Glasses capture", "upload": "Space upload", } GLASSES_SHELF_PREFIX = "\u2063sc-glasses\u2063" UPLOAD_SHELF_PREFIX = "\u2063sc-upload\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^="\\002063sc-glasses\\002063"])::before, .sc-shelf .thumbnail-item:has(img[alt^="\\002063sc-upload\\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^="\\002063sc-glasses\\002063"])::after, .sc-shelf .thumbnail-item:has(img[alt^="\\002063sc-upload\\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^="\\002063sc-glasses\\002063"])::after { -webkit-mask-image: var(--sc-ico-glasses-mask); mask-image: var(--sc-ico-glasses-mask); } .sc-shelf .thumbnail-item:has(img[alt^="\\002063sc-upload\\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-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; } /* custom file-backed player (Review-3): the master clock is a hidden