Spaces:
Running
Running
| """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 = ( | |
| '<svg class="sc-brand-mark" viewBox="0 0 64 64" width="20" height="20" aria-hidden="true">' | |
| "<defs>" | |
| '<linearGradient id="scb" x1="6" y1="4" x2="58" y2="60" gradientUnits="userSpaceOnUse">' | |
| '<stop offset="0" stop-color="#26272c"/><stop offset="1" stop-color="#0d0e11"/>' | |
| "</linearGradient>" | |
| '<linearGradient id="scg" x1="20" y1="9" x2="45" y2="56" gradientUnits="userSpaceOnUse">' | |
| '<stop offset="0" stop-color="#e1c98b"/><stop offset="1" stop-color="#8e7845"/>' | |
| "</linearGradient></defs>" | |
| '<rect width="64" height="64" rx="14" fill="url(#scb)"/>' | |
| '<rect x="21" y="8" width="22" height="48" rx="7" fill="#17181b" stroke="url(#scg)" ' | |
| 'stroke-width="5"/>' | |
| '<path d="M23 41 41 23" stroke="url(#scg)" stroke-width="8" stroke-linecap="round"/>' | |
| '<path d="M25 48c3 0 3-5 6-5s3 5 6 5" stroke="#d8d4c7" stroke-width="3.2" fill="none" ' | |
| 'stroke-linecap="round"/></svg>' | |
| ) | |
| RAIL_MARK_SVG = ( | |
| '<svg class="sc-rail-mark" viewBox="0 0 24 24" width="16" height="16" fill="none" ' | |
| 'aria-hidden="true">' | |
| '<g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" ' | |
| 'stroke-linejoin="round">' | |
| '<rect x="4" y="5" width="16" height="14" rx="2"/>' | |
| '<path d="M8 5v14M16 5v14"/><path d="M4 9h4M16 9h4M4 15h4M16 15h4"/>' | |
| '<path d="M8.5 17 15.5 7"/></g></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 <body>) so Gradio's CSS prefixer (.gradio-container… .contain) can't | |
| break it: <body> 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 <audio id="sc-voice"> carries narration. | |
| Browser media events drive the pill/progress/captions; the decorative muted video free-runs. */ | |
| .sc-audio-host { display: none !important; } | |
| .sc-vol-ctl { display: inline-flex; align-items: center; flex: 0 0 auto; } | |
| .sc-vol { -webkit-appearance: none; appearance: none; width: 62px; height: 4px; border-radius: 3px; | |
| background: #3a3942; outline: none; cursor: pointer; } | |
| .sc-vol::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 12px; | |
| height: 12px; border-radius: 50%; background: #D4AF37; cursor: pointer; } | |
| .sc-vol::-moz-range-thumb { width: 12px; height: 12px; border: none; border-radius: 50%; | |
| background: #D4AF37; cursor: pointer; } | |
| /* --- Review-3 theater layout: fit one viewport (no scroll), stage + library carousel --- */ | |
| /* Lock the page to a single viewport so the main container never scrolls (#4). The library | |
| lives in a bounded horizontal rail beside the player, so a growing shelf never pushes the | |
| active clip farther away. */ | |
| html, body { overflow: hidden; height: 100%; } | |
| .gradio-container { height: 100dvh !important; } | |
| .gradio-container .main.fillable.app { max-height: 100dvh !important; overflow: hidden !important; | |
| padding-top: 8px !important; padding-bottom: 10px !important; } | |
| .gradio-container .main.fillable, .gradio-container .main.fillable > .wrap, | |
| .gradio-container .wrap > .contain, | |
| .gradio-container .contain > .column { min-height: 0 !important; } | |
| .sc-soul { display: none; } /* the poetic subline costs vertical budget; brand stays */ | |
| .sc-theater { flex-direction: row !important; flex-wrap: nowrap !important; | |
| flex: 1 1 auto !important; min-height: 0 !important; align-items: stretch !important; | |
| gap: 22px !important; max-width: 1180px; margin: 6px auto 0 !important; width: 100%; } | |
| .sc-stage-col { display: flex !important; flex-direction: column; min-height: 0 !important; | |
| align-items: center; flex: 1 1 auto !important; gap: 4px !important; } | |
| /* collapse Gradio's default inter-block gaps; centering comes from align-items + each row's | |
| own max-width, so zeroing block margins is safe. */ | |
| .sc-stage-col > * { width: 100%; margin: 0 !important; } | |
| .sc-stage-block { flex: 0 0 auto; min-height: 0; display: flex !important; | |
| justify-content: center; } | |
| /* Bind the stage to the viewport HEIGHT (chrome reserved), width derived from 9:16 — so the | |
| ratio is preserved and the controls below it always stay on-screen. The aspect-ratio must | |
| NOT drive height off the column width (that overflowed the viewport). */ | |
| .sc-stage-block .sc-stage-shell { height: clamp(300px, 48dvh, 430px) !important; | |
| max-height: 430px; width: auto; max-width: min(100%, calc(100vw - 620px)); | |
| flex: 0 0 auto; } | |
| .sc-rail-col { flex: 0 0 auto !important; width: clamp(360px, 44vw, 520px) !important; | |
| min-width: 0 !important; min-height: 0 !important; display: flex !important; | |
| flex-direction: column; overflow: hidden !important; } | |
| .sc-rail-head { display: flex; align-items: center; gap: 4px; color: #8a8894; | |
| font-family: 'IBM Plex Mono', monospace; font-size: .72rem; letter-spacing: .16em; | |
| text-transform: uppercase; padding: 0 2px 8px; } | |
| .sc-rail-col .sc-shelf { flex: 0 0 auto !important; width: 100% !important; | |
| height: 154px !important; min-height: 0 !important; overflow: hidden !important; } | |
| .sc-rail-col .sc-shelf .gallery-container .grid-wrap.fixed-height { | |
| width: 100% !important; max-width: 100% !important; | |
| height: 154px !important; min-height: 0 !important; max-height: 154px !important; | |
| overflow-x: auto !important; overflow-y: hidden !important; | |
| scroll-snap-type: x mandatory; overscroll-behavior-x: contain; | |
| -webkit-overflow-scrolling: touch; padding-bottom: 8px !important; | |
| scrollbar-width: thin; scrollbar-color: rgba(212,175,55,.75) transparent; } | |
| .sc-rail-col .sc-shelf .gallery-container .grid-wrap.fixed-height::-webkit-scrollbar { | |
| height: 6px; } | |
| .sc-rail-col .sc-shelf .gallery-container .grid-wrap.fixed-height::-webkit-scrollbar-track { | |
| background: transparent; } | |
| .sc-rail-col .sc-shelf .gallery-container .grid-wrap.fixed-height::-webkit-scrollbar-thumb { | |
| background: rgba(212,175,55,.75); border-radius: 999px; } | |
| .sc-rail-col .sc-shelf .grid-wrap .grid-container { | |
| display: grid !important; grid-template-columns: none !important; | |
| grid-auto-flow: column !important; grid-auto-columns: minmax(150px, 168px) !important; | |
| grid-template-rows: 1fr !important; grid-auto-rows: 1fr !important; | |
| width: max-content !important; min-width: 100% !important; | |
| height: 146px !important; max-height: 146px !important; } | |
| .sc-rail-col .sc-shelf .gallery-item { width: 100% !important; height: 146px !important; | |
| scroll-snap-align: start; } | |
| .sc-rail-col .sc-shelf .thumbnail-item { width: 100% !important; height: 100% !important; } | |
| /* header doubles as the "back to live" affordance (the standalone button is hidden) */ | |
| .sc-header { cursor: pointer; } | |
| .sc-live-btn { display: none !important; } | |
| .sc-brand-line { display: inline-flex; align-items: center; white-space: nowrap; } | |
| .sc-brand-mark { margin-right: 5px; flex: 0 0 auto; } | |
| /* mobile: view-only mode. The library becomes a horizontal swipe rail under the stage; | |
| upload stays hidden because native apps own capture/upload on phones. */ | |
| @media (max-width: 860px) { | |
| html, body { overflow: hidden; height: 100%; } | |
| .gradio-container { height: 100dvh !important; } | |
| .gradio-container .main.fillable.app { max-height: 100dvh !important; | |
| overflow: hidden !important; padding-bottom: 10px !important; } | |
| .sc-theater { flex-direction: column !important; flex-wrap: nowrap !important; | |
| gap: 6px !important; } | |
| .sc-header { padding: 0 4px 4px !important; gap: 6px !important; } | |
| .sc-header-title { font-size: 1.12rem !important; } | |
| .sc-stage-col { flex: 0 0 auto !important; width: 100% !important; gap: 4px !important; } | |
| .sc-stage-block { flex: 0 0 auto !important; } | |
| .sc-stage-block .sc-stage-shell { width: min(50vw, 196px) !important; | |
| max-width: min(50vw, 196px) !important; height: auto !important; | |
| max-height: 40vh !important; } | |
| .sc-upload-entry, #sc-upload-popover { display: none !important; } | |
| .sc-rail-col { flex: 0 0 auto !important; width: 100% !important; min-width: 0 !important; | |
| overflow: hidden !important; } | |
| .sc-rail-head { padding: 0 2px 8px !important; } | |
| .sc-rail-col .sc-shelf { width: 100% !important; height: 134px !important; | |
| flex: 0 0 auto !important; overflow: hidden !important; } | |
| .sc-rail-col .sc-shelf .gallery-container .grid-wrap.fixed-height { | |
| width: 100% !important; max-width: 100% !important; | |
| height: 134px !important; min-height: 0 !important; max-height: 134px !important; | |
| overflow-x: auto !important; overflow-y: hidden !important; | |
| scroll-snap-type: x mandatory; overscroll-behavior-x: contain; | |
| -webkit-overflow-scrolling: touch; padding-bottom: 8px !important; } | |
| .sc-rail-col .sc-shelf .grid-wrap .grid-container { | |
| display: grid !important; grid-template-columns: none !important; | |
| grid-auto-flow: column !important; grid-auto-columns: minmax(142px, 42vw) !important; | |
| grid-template-rows: 1fr !important; grid-auto-rows: 1fr !important; | |
| width: max-content !important; min-width: 100% !important; | |
| height: 126px !important; max-height: 126px !important; } | |
| .sc-rail-col .sc-shelf .gallery-item { width: 100% !important; height: 126px !important; | |
| scroll-snap-align: start; } | |
| .sc-rail-col .sc-shelf .thumbnail-item { width: 100% !important; height: 100% !important; } | |
| } | |
| """ | |
| VIEWER_CSS += ICON_CSS | |
| # -- scene formatting (pure, both modes) ------------------------------------------- | |
| def _truthy_env(name: str) -> bool: | |
| return os.environ.get(name, "").strip().lower() in ("1", "true", "yes") | |
| def _engine_autodetect_enabled() -> bool: | |
| return not _truthy_env(DISABLE_ENGINE_AUTODETECT_ENV) | |
| def upload_sandbox_enabled() -> bool: | |
| """Relay-mode judge uploads are enabled only when Modal is explicitly configured.""" | |
| return _truthy_env(UPLOAD_SANDBOX_ENV) and bool(os.environ.get(MODAL_API_URL_ENV, "").strip()) | |
| def upload_max_seconds() -> float: | |
| return config.MAX_UPLOAD_SECONDS | |
| def upload_max_mb() -> int: | |
| return UPLOAD_MAX_BYTES // (1024 * 1024) | |
| def _video_size_bytes(video_path: str | Path) -> int | None: | |
| try: | |
| return Path(video_path).stat().st_size | |
| except OSError: | |
| return None | |
| def render_upload_panel_help_html(max_seconds: float | None = None) -> str: | |
| seconds = max_seconds if max_seconds is not None else upload_max_seconds() | |
| return ( | |
| '<div class="sc-upload-help">' | |
| '<div class="sc-upload-help-title">Drop or browse your video</div>' | |
| '<div class="sc-upload-help-meta">' | |
| f"Up to {seconds:.0f} seconds · {upload_max_mb()} MB max · {UPLOAD_FORMAT_LABEL}" | |
| "</div>" | |
| '<div class="sc-upload-help-meta">' | |
| "Saved to this demo library after a successful cut" | |
| "</div></div>" | |
| ) | |
| def render_upload_cta_html() -> str: | |
| return '<span class="sc-upload-cta">Upload a clip</span>' | |
| def render_clapperboard_svg() -> str: | |
| """A director's clapperboard (claqueta) loader. | |
| The top *clapper bar* (``.sc-clap-arm``) is hinged at its LEFT end: CSS rotates it open ~30° | |
| and snaps it shut on a ~1.1s loop (animation lives in VIEWER_CSS). Only ``transform:rotate`` | |
| is animated (``transform-origin`` pinned at the hinge), so it stays cheap and jank-free; a | |
| ``prefers-reduced-motion`` fallback in the CSS swaps the swing for an opacity pulse. The | |
| diagonal black/white stripes are baked into the static markup. viewBox 0 0 120 96; the arm is | |
| drawn flush along the slate's top edge and pivots about (8, 30).""" | |
| stripes = "".join( | |
| # alternating slate teeth along the clapper edges (parallelograms => the classic stripes) | |
| f'<path d="M{x} 0 l12 0 -10 16 -12 0 z" fill="{"#f3efe4" if i % 2 else "#16161c"}"/>' | |
| for i, x in enumerate(range(8, 116, 11)) | |
| ) | |
| return ( | |
| '<svg class="sc-clap" viewBox="0 0 120 96" width="96" height="78" ' | |
| 'role="img" aria-label="Generating your cut" xmlns="http://www.w3.org/2000/svg">' | |
| # slate body (the board itself) | |
| '<rect x="6" y="30" width="108" height="58" rx="6" fill="#2a2b31" ' | |
| 'stroke="#D4AF37" stroke-width="2.5"/>' | |
| '<g stroke="#6b6962" stroke-width="1.4" opacity=".5">' | |
| '<line x1="18" y1="46" x2="102" y2="46"/><line x1="18" y1="62" x2="102" y2="62"/>' | |
| '<line x1="18" y1="78" x2="102" y2="78"/></g>' | |
| # hinged clapper arm: a stripe-edged bar pivoting about its left end (8,30) | |
| '<g class="sc-clap-arm">' | |
| '<rect x="6" y="14" width="108" height="18" rx="3" fill="#1d1e24" ' | |
| 'stroke="#D4AF37" stroke-width="2.5"/>' | |
| f'<g class="sc-clap-teeth" transform="translate(0 14)">{stripes}</g>' | |
| "</g></svg>" | |
| ) | |
| def render_clapperboard_html(caption: str = "Rolling...") -> str: | |
| """Full result-box overlay: the animating clapperboard + a cycling caption. | |
| Used as the single generation loader (R5). The caption (``Rolling.../Action.../Cutting...``) | |
| cycles via CSS ``::before`` content steps so no JS timer is needed.""" | |
| return ( | |
| '<div class="sc-clap-loader" role="status" aria-live="polite">' | |
| f"{render_clapperboard_svg()}" | |
| f'<div class="sc-clap-caption">{html.escape(caption)}</div>' | |
| "</div>" | |
| ) | |
| def render_upload_status_html(state: str = "idle", message: str | None = None) -> str: | |
| if state == "running": | |
| # one loader, the clapperboard (R5) — replaces the old border-spinner. | |
| body = ( | |
| '<span class="sc-clap-mini" aria-hidden="true">' | |
| f"{render_clapperboard_svg()}</span>Generating your cut..." | |
| ) | |
| elif state == "complete": | |
| body = "Cut ready." | |
| elif state == "blocked": | |
| body = html.escape(message or "Upload is not available right now.") | |
| else: | |
| body = "" | |
| return f'<div class="sc-upload-status {html.escape(state, quote=True)}">{body}</div>' | |
| def _upload_status_is_running(status_html: Any) -> bool: | |
| return isinstance(status_html, str) and "sc-upload-status running" in status_html | |
| def _toggle_upload_panel(is_open: bool) -> tuple[Any, ...]: | |
| next_open = not bool(is_open) | |
| if next_open: | |
| return ( | |
| True, | |
| gr.update(visible=True), | |
| render_upload_status_html(), | |
| gr.update(interactive=False), | |
| gr.skip(), | |
| gr.skip(), | |
| ) | |
| return ( | |
| False, | |
| gr.update(visible=False), | |
| render_upload_status_html(), | |
| gr.update(interactive=False), | |
| gr.update(value=None), | |
| gr.update(value=""), | |
| ) | |
| def _sync_upload_submit_ready_ui(video_path: str | None) -> tuple[Any, str]: | |
| if video_path: | |
| return gr.update(interactive=True), render_upload_status_html() | |
| return ( | |
| gr.update(interactive=False), | |
| render_upload_status_html("blocked", "Upload a video clip first."), | |
| ) | |
| def _upload_budget_warning(decision: BudgetDecision) -> str: | |
| return decision.message or "Demo daily upload limit reached. Uploads reopen tomorrow." | |
| def _merge_scene_lists(*scene_lists: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| by_id: dict[str, dict[str, Any]] = {} | |
| for scenes in scene_lists: | |
| for scene in scenes: | |
| scene_id = str(scene.get("scene_id") or "") | |
| if scene_id: | |
| by_id[scene_id] = scene | |
| return sorted( | |
| by_id.values(), | |
| key=lambda scene: ( | |
| _parse_ts(scene.get("created_at")) or datetime.min.replace(tzinfo=timezone.utc), | |
| str(scene.get("scene_id") or ""), | |
| ), | |
| )[-SHELF_LIMIT:] | |
| def _library_scenes(scenes: list[dict[str, Any]] | None) -> list[dict[str, Any]]: | |
| return sorted( | |
| scenes or [], | |
| key=lambda scene: ( | |
| _parse_ts(scene.get("created_at")) or datetime.min.replace(tzinfo=timezone.utc), | |
| str(scene.get("scene_id") or ""), | |
| ), | |
| reverse=True, | |
| ) | |
| def _modal_upload_client() -> ModalUploadClient: | |
| base_url = os.environ.get(MODAL_API_URL_ENV, "").strip() | |
| token = os.environ.get(MODAL_API_TOKEN_ENV, "").strip() | |
| if not base_url or not token: | |
| raise ModalUploadError("Modal upload is not configured.") | |
| return ModalUploadClient(base_url, token) | |
| def _modal_api_is_v2() -> bool: | |
| """True when the upload front door targets the v2 ``/v2/narrate`` pipeline (mid-cuts). | |
| Env-gated (default ``v1``) so the live Space's v1 ``/v1/cuts`` upload path stays unchanged. | |
| """ | |
| return os.environ.get(MODAL_API_VERSION_ENV, "v1").strip().lower() == "v2" | |
| def _style_label(style_key: str) -> str: | |
| style = STYLES.get(style_key) | |
| if style is not None: | |
| return style.label | |
| return style_key or "off air" | |
| def _source_icon(scene: dict[str, Any] | None) -> str | None: | |
| if not scene: | |
| return None | |
| source = scene.get("source_icon") or scene.get("source") | |
| return source if isinstance(source, str) and source in SOURCE_ICON_LABELS else None | |
| def _parse_ts(value: str | None) -> datetime | None: | |
| if not value: | |
| return None | |
| try: | |
| ts = datetime.fromisoformat(value.replace("Z", "+00:00")) | |
| except ValueError: | |
| return None | |
| return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc) | |
| def is_fresh( | |
| created_at: str | None, | |
| now: datetime | None = None, | |
| window_s: float = LIVE_WINDOW_S, | |
| ) -> bool: | |
| """LIVE vs STANDBY: did this scene arrive within the freshness window?""" | |
| ts = _parse_ts(created_at) | |
| if ts is None: | |
| return False | |
| now = now or datetime.now(timezone.utc) | |
| return (now - ts).total_seconds() <= window_s | |
| def format_stage( | |
| scene: dict[str, Any] | None, | |
| engine_url: str = "", | |
| now: datetime | None = None, | |
| ) -> dict[str, Any]: | |
| """NarratedScene JSON (or a local session scene) -> what the stage needs. | |
| Engine scenes carry relative media URLs; they come back absolute against | |
| `engine_url`. Local scenes carry `frame_src` data URIs directly. | |
| """ | |
| if not scene: | |
| return { | |
| "scene_id": None, | |
| "title": "No signal", | |
| "style_label": "off air", | |
| "caption": "", | |
| "frame_src": None, | |
| "clip_src": None, | |
| "audio_src": None, | |
| "duration": None, | |
| "live": False, | |
| "visibility": None, | |
| "source_icon": None, | |
| "timed_captions": None, | |
| "language": None, | |
| } | |
| base = engine_url.rstrip("/") | |
| def _absolute(path: str | None) -> str | None: | |
| if not path: | |
| return None | |
| return path if path.startswith("http") else f"{base}{path}" | |
| media = scene.get("media") or {} | |
| return { | |
| "scene_id": scene.get("scene_id"), | |
| "title": scene.get("title") or "Untitled Scene", | |
| "style_label": ( | |
| persona_display(scene.get("persona"))[1] or _style_label(scene.get("style_key", "")) | |
| ), | |
| "language": scene.get("language"), | |
| "caption": scene.get("narration", ""), | |
| "frame_src": scene.get("frame_src") or _absolute(media.get("frame_url")), | |
| "clip_src": scene.get("clip_src") or _absolute(media.get("clip_url")), | |
| "audio_src": scene.get("audio_src") or _absolute(media.get("audio_url")), | |
| "duration": scene.get("duration"), | |
| "live": is_fresh(scene.get("created_at"), now=now), | |
| "visibility": scene.get("visibility"), | |
| "source_icon": _source_icon(scene), | |
| "timed_captions": scene.get("timed_captions"), | |
| } | |
| # -- HTML renderers (pure) ----------------------------------------------------------- | |
| def _subtitle_chunks(text: str, max_words: int = 5) -> list[str]: | |
| """Short subtitle lines — broken at clause punctuation, capped at max_words. | |
| Real-time narration arrives in small recent-past pieces; the captions mirror that | |
| cadence (a few words at a time) rather than dumping a whole sentence at once — so they | |
| can advance fast enough to track the voice instead of lagging behind it. | |
| """ | |
| chunks: list[str] = [] | |
| cur: list[str] = [] | |
| for word in text.split(): | |
| cur.append(word) | |
| if word[-1:] in ",.;:!?" or len(cur) >= max_words: | |
| chunks.append(" ".join(cur)) | |
| cur = [] | |
| if cur: | |
| chunks.append(" ".join(cur)) | |
| return chunks or [text.strip()] | |
| def caption_cues(text: str, duration: float | str | None) -> list[tuple[float, float, str]]: | |
| """Evenly distribute subtitle chunks across a known media duration.""" | |
| if not text.strip(): | |
| return [] | |
| try: | |
| total = float(duration) | |
| except (TypeError, ValueError): | |
| return [] | |
| if not math.isfinite(total) or total <= 0: | |
| return [] | |
| chunks = [chunk for chunk in _subtitle_chunks(text.strip()) if chunk.strip()] | |
| if not chunks: | |
| return [] | |
| window = total / len(chunks) | |
| cues: list[tuple[float, float, str]] = [] | |
| for index, chunk in enumerate(chunks): | |
| start = index * window | |
| end = total if index == len(chunks) - 1 else (index + 1) * window | |
| cues.append((start, end, chunk)) | |
| return cues | |
| def _caption_data_attr(value: Any) -> str: | |
| """JSON compacted + HTML-escaped for safe embedding in a `data-*` attribute. | |
| Caption cue/chunk lists ride on the subtitle div's data attributes rather than an inert | |
| `<script>` island: Gradio warns about (and may not surface) `<script>` tags inside gr.HTML, but | |
| attributes always survive innerHTML insertion and sanitization. | |
| """ | |
| return html.escape(json.dumps(value, separators=(",", ":")), quote=True) | |
| def render_stage_html( | |
| frame_src: str | None, | |
| caption: str, | |
| live: bool, | |
| clip_src: str | None = None, | |
| duration: float | None = None, | |
| source_icon: str | None = None, | |
| timed_captions: list[dict[str, Any]] | None = None, | |
| ) -> str: | |
| """The 9:16 stage: the moment (video clip or still frame) + lower-third caption. | |
| `live` is retained for signature stability — the live/finished state now lives in the | |
| header ("Happening now" vs. the auto-title), not a REC chip on the stage. | |
| `timed_captions` are the aligner's real word-timed cues (object-shaped {t_start,t_end,text}). | |
| When present they are converted to the painter's tuple shape [start,end,text] and preferred | |
| over the even-window `caption_cues` derivation (used as the fallback for seed/v1 scenes). | |
| """ | |
| if clip_src: | |
| poster = f' poster="{html.escape(frame_src, quote=True)}"' if frame_src else "" | |
| # No `autoplay`: the trusted play gesture starts/stops this decorative b-roll. | |
| # It loops on its own native clock; narration audio is not allowed to snap its currentTime. | |
| body = ( | |
| f'<video src="{html.escape(clip_src, quote=True)}"{poster} ' | |
| 'muted loop playsinline preload="auto" fetchpriority="high"></video>' | |
| ) | |
| elif frame_src: | |
| body = f'<img src="{html.escape(frame_src, quote=True)}" alt="" fetchpriority="high">' | |
| else: | |
| body = '<div class="sc-stage-empty">🎬</div>' | |
| if caption and caption.strip(): | |
| chunks = [chunk for chunk in _subtitle_chunks(caption.strip()) if chunk.strip()] | |
| if timed_captions: | |
| # Real aligner cues → tuple shape [start, end, text] the JS painter (cue[0/1/2]) reads. | |
| cues = [[c["t_start"], c["t_end"], c["text"]] for c in timed_captions] | |
| else: | |
| cues = caption_cues(caption, duration) | |
| first_caption = cues[0][2] if cues else (chunks[0] if chunks else caption.strip()) | |
| dur_attr = f' data-duration="{float(duration):.1f}"' if duration else "" | |
| # Timed cues (when the scene's duration is known server-side) are preferred by the painter; | |
| # the chunk list is ALWAYS embedded so captions still advance off the browser's live clock | |
| # for scenes whose duration is only known in the browser (e.g. seed scenes). | |
| cues_attr = f' data-sc-cues="{_caption_data_attr(cues)}"' if cues else "" | |
| chunks_attr = f' data-sc-chunks="{_caption_data_attr(chunks)}"' if chunks else "" | |
| caption_html = ( | |
| f'<div class="sc-subtitle" id="sc-subtitle"{dur_attr}{cues_attr}{chunks_attr}>' | |
| f'<span class="sc-sub-line">{html.escape(first_caption)}</span>' | |
| "</div>" | |
| ) | |
| else: | |
| caption_html = "" | |
| if source_icon in SOURCE_ICON_LABELS: | |
| label = html.escape(SOURCE_ICON_LABELS[source_icon], quote=True) | |
| icon = html.escape(source_icon, quote=True) | |
| badge_html = ( | |
| f'<span class="sc-source-badge sc-source-{icon}" aria-label="{label}" ' | |
| f'title="{label}"><span class="sc-source-badge-icon sc-ico-{icon}"></span></span>' | |
| ) | |
| else: | |
| badge_html = "" | |
| return f'<div class="sc-stage-shell">{badge_html}{body}{caption_html}</div>' | |
| def render_header_html( | |
| title: str, | |
| style_label: str, | |
| live: bool, | |
| notice: str | None = None, | |
| language: str | None = None, | |
| ) -> str: | |
| # One unnamed signature voice — the channel is Small Cuts, not a per-cut director. | |
| # style_label is retained in the call signature for future per-owner channels. | |
| # Live capture reads "Happening now"; a finished cut shows its auto-generated title. | |
| state = "live" if live else "standby" | |
| if notice: | |
| headline = ( | |
| f'<span class="sc-live-dot"></span>{html.escape(notice)}' | |
| '<span class="sc-live-hint">Tap to watch</span>' | |
| ) | |
| else: | |
| headline = '<span class="sc-live-dot"></span>Happening now' if live else html.escape(title) | |
| abbr = lang_abbr(language) | |
| if abbr and style_label and not live: | |
| right = f"{abbr} · {html.escape(style_label)}" | |
| else: | |
| right = "Small Cuts" | |
| return ( | |
| f'<div class="sc-header sc-{state}">' | |
| f'<span class="sc-header-title">{headline}</span>' | |
| f'<span class="sc-header-channel">{right}</span>' | |
| "</div>" | |
| ) | |
| def feed_entry(scene: dict[str, Any]) -> dict[str, str]: | |
| """One chat line: the narrator is the chatter, the narration the message. | |
| One signature voice — the author is always "Narrator", never a per-style name. | |
| """ | |
| ts = _parse_ts(scene.get("created_at")) | |
| return { | |
| "author": "Narrator", | |
| "text": scene.get("narration", ""), | |
| "time": ts.strftime("%H:%M") if ts else "", | |
| } | |
| def render_feed_html(entries: list[dict[str, str]]) -> str: | |
| """The narrator-as-chat column. CSS column-reverse pins the scroll to the | |
| newest line, so entries render newest-first in the DOM.""" | |
| if not entries: | |
| rows = '<div class="sc-feed-empty">The narrator is quiet. For now.</div>' | |
| else: | |
| rows = "".join( | |
| '<div class="sc-feed-line">' | |
| f'<span class="sc-chat-time">{html.escape(entry["time"])}</span> ' | |
| f'<span class="sc-chat-name">{html.escape(entry["author"])}</span>' | |
| f'<span class="sc-chat-text">{html.escape(entry["text"])}</span>' | |
| "</div>" | |
| for entry in reversed(entries) | |
| ) | |
| return f'<div class="sc-feed">{rows}</div>' | |
| # -- engine mode ---------------------------------------------------------------------- | |
| class EngineClient: | |
| """Thin sync client for the engine's viewer-facing REST surface (D6/D7).""" | |
| def __init__(self, base_url: str, client: httpx.Client | None = None) -> None: | |
| self.base_url = base_url.rstrip("/") | |
| self._client = client or httpx.Client(timeout=HTTP_TIMEOUT_S) | |
| def list_scenes(self, limit: int = SHELF_LIMIT) -> list[dict[str, Any]]: | |
| response = self._client.get(f"{self.base_url}/v1/scenes") | |
| response.raise_for_status() | |
| return response.json()["scenes"][-limit:] | |
| def set_visibility(self, scene_id: str, visibility: str) -> dict[str, Any]: | |
| response = self._client.patch( | |
| f"{self.base_url}/v1/scenes/{scene_id}", json={"visibility": visibility} | |
| ) | |
| response.raise_for_status() | |
| return response.json() | |
| def media_url(self, path: str | None) -> str | None: | |
| if not path: | |
| return None | |
| return path if path.startswith("http") else f"{self.base_url}{path}" | |
| class BucketSceneClient(_BucketSceneClient): | |
| """Bucket relay client wired to Gradio's static file serving.""" | |
| def __init__(self, *args, **kwargs) -> None: | |
| kwargs.setdefault("register_static_paths", gr.set_static_paths) | |
| super().__init__(*args, **kwargs) | |
| def shelf_items( | |
| scenes: list[dict[str, Any]], client: EngineClient | BucketSceneClient | None = None | |
| ) -> list[tuple[str, str]]: | |
| """Gallery payload: POV frame thumbnails captioned with the generated scene title.""" | |
| items = [] | |
| for scene in _library_scenes(scenes): | |
| media = scene.get("media") or {} | |
| src = media.get("frame_url") or media.get("card_url") | |
| if ( | |
| client is not None | |
| and src | |
| and not src.startswith(("http://", "https://", "data:", GRADIO_FILE_ROUTE)) | |
| ): | |
| src = client.media_url(src) | |
| if src: | |
| items.append((_gallery_media_src(src), _shelf_caption(scene))) | |
| return items | |
| def _gallery_media_src(src: str) -> str: | |
| if src.startswith(GRADIO_FILE_ROUTE): | |
| return unquote(src[len(GRADIO_FILE_ROUTE) :]) | |
| return src | |
| _LANG_ABBR = {"English": "EN", "Spanish": "ES", "French": "FR"} | |
| def lang_abbr(language: str | None) -> str: | |
| """Two-letter abbreviation for a known language; "" otherwise.""" | |
| return _LANG_ABBR.get(language or "", "") | |
| def persona_display(persona: str | None) -> tuple[str, str]: | |
| """(archetype, full label) for a persona key; ("", "") if unknown.""" | |
| label = PERSONA_LABELS.get(persona or "") | |
| if not label: | |
| return ("", "") | |
| return (label.split(" · ", 1)[0], label) | |
| def _shelf_caption(scene: dict[str, Any]) -> str: | |
| """Three lines, one piece of information each: title · language · narrator style. | |
| The style label is resolved through the SAME path as ``format_stage`` (persona | |
| label wins, then the style_key label) so the shelf and the stage header never | |
| disagree. Each thumbnail therefore shows the same structure at the same level, | |
| regardless of whether a cut was seeded (style_key only) or uploaded via v2 | |
| (persona + language). The invisible source-icon prefix stays glued to the title | |
| line so the CSS source-badge still lights up. | |
| """ | |
| title = scene.get("title", "") | |
| source_icon = _source_icon(scene) | |
| title_line = f"{_SOURCE_SHELF_PREFIXES.get(source_icon, '')}{title}" | |
| lang_line = lang_abbr(scene.get("language")) or "\u2014" # em dash when unknown | |
| style_line = persona_display(scene.get("persona"))[1] or _style_label( | |
| scene.get("style_key", "") | |
| ) | |
| return f"{title_line}\n{lang_line}\n{style_line}" | |
| def _scene_with_media_urls( | |
| scene: dict[str, Any], client: EngineClient | BucketSceneClient | |
| ) -> dict[str, Any]: | |
| hydrated = {**scene} | |
| media = scene.get("media") | |
| if not isinstance(media, dict): | |
| hydrated["media"] = {} | |
| return hydrated | |
| hydrated["media"] = {**media} | |
| for key in MEDIA_KEYS: | |
| hydrated["media"][key] = client.media_url(media.get(key)) | |
| return hydrated | |
| def poll_engine( | |
| client: EngineClient | BucketSceneClient, | |
| scenes_prev: list[dict[str, Any]], | |
| pinned_id: str | None, | |
| playing_id: str | None, | |
| current_id: str | None = None, | |
| now: datetime | None = None, | |
| ) -> tuple[Any, ...]: | |
| """One timer tick: GET /v1/scenes -> header, stage, feed, audio, shelf, states. | |
| Output order: (header, stage, feed, audio, shelf, scenes_state, current_id, | |
| playing_id, visibility). The list endpoint orders by captured_at ascending, | |
| so the newest scene is the LAST element (limit=1 would return the oldest). | |
| """ | |
| try: | |
| scenes = client.list_scenes(limit=SHELF_LIMIT) | |
| except (httpx.HTTPError, BucketRelayError, KeyError, ValueError) as exc: | |
| capture_exception(exc) | |
| kind = type(exc).__name__ | |
| print( | |
| f"small_cuts.viewer: engine poll failed for {client.base_url}: {kind}: {exc!r}", | |
| file=sys.stderr, | |
| flush=True, | |
| ) | |
| title = "Signal lost — engine unreachable" | |
| if os.environ.get("SMALL_CUTS_DEBUG_ENGINE_ERRORS", "").strip(): | |
| title = f"Signal lost — {kind}: {str(exc)[:140]}" | |
| header = render_header_html(title, "off air", live=False) | |
| return ( | |
| header, | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| scenes_prev, | |
| gr.skip(), # keep the visibility target across a transient blip | |
| playing_id, | |
| gr.skip(), | |
| ) | |
| if not scenes: | |
| header = render_header_html("Waiting for the first scene", "standby", live=False) | |
| stage = render_stage_html(None, "The narrator is watching. Nothing yet.", live=False) | |
| return (header, stage, render_feed_html([]), gr.skip(), [], [], None, playing_id, gr.skip()) | |
| newest = scenes[-1] | |
| channel_live = is_fresh(newest.get("created_at"), now=now) | |
| by_id = {scene.get("scene_id"): scene for scene in scenes} | |
| current = by_id.get(pinned_id) or by_id.get(current_id) or newest | |
| current_for_stage = _scene_with_media_urls(current, client) | |
| payload = format_stage(current_for_stage, client.base_url, now=now) | |
| on_air = channel_live and current.get("scene_id") == newest.get("scene_id") | |
| notice = "New cut available" if channel_live and not on_air else None | |
| header = render_header_html( | |
| payload["title"], | |
| payload["style_label"], | |
| live=channel_live, | |
| notice=notice, | |
| language=payload.get("language"), | |
| ) | |
| stage = render_stage_html( | |
| payload["frame_src"], | |
| payload["caption"], | |
| live=on_air, | |
| clip_src=payload["clip_src"], | |
| duration=payload["duration"], | |
| source_icon=payload["source_icon"], | |
| timed_captions=payload.get("timed_captions"), | |
| ) | |
| feed = render_feed_html([feed_entry(scene) for scene in scenes[-FEED_LIMIT:]]) | |
| prev_ids = [scene.get("scene_id") for scene in scenes_prev] | |
| ids = [scene.get("scene_id") for scene in scenes] | |
| shelf = shelf_items(scenes, client) if ids != prev_ids else gr.skip() | |
| if payload["audio_src"] and payload["scene_id"] != playing_id: | |
| audio, playing_id = _audio_html(payload["audio_src"]), payload["scene_id"] | |
| else: | |
| audio = gr.skip() | |
| visibility = gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip() | |
| return (header, stage, feed, audio, shelf, scenes, payload["scene_id"], playing_id, visibility) | |
| # -- upload mode (the hackathon Space) ------------------------------------------------- | |
| def _video_duration_s(video_path: str | Path) -> float | None: | |
| try: | |
| import av | |
| with av.open(str(video_path)) as container: | |
| if container.duration is not None: | |
| return float(container.duration / 1_000_000) | |
| stream = container.streams.video[0] if container.streams.video else None | |
| if stream and stream.duration is not None and stream.time_base is not None: | |
| return float(stream.duration * stream.time_base) | |
| except Exception as exc: | |
| capture_exception(exc) | |
| return None | |
| def _data_uri(image: Image.Image, max_side: int = 1080) -> str: | |
| thumb = image.copy() | |
| thumb.thumbnail((max_side, max_side)) | |
| buffer = io.BytesIO() | |
| thumb.convert("RGB").save(buffer, "JPEG", quality=88) | |
| return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode() | |
| def make_local_scene( | |
| frame: Image.Image | None, | |
| card: Image.Image, | |
| narration: str, | |
| style_key: str, | |
| source_video_path: str | Path | None = None, | |
| ) -> dict[str, Any]: | |
| """Session-state scene for upload mode: same keys format_stage understands.""" | |
| stage_image = frame if frame is not None else card | |
| thumb = stage_image.copy() | |
| thumb.thumbnail((400, 540)) | |
| scene = { | |
| "scene_id": f"local-{uuid.uuid4().hex[:12]}", | |
| "title": derive_title(narration), | |
| "narration": narration, | |
| "style_key": style_key, | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| "frame_src": _data_uri(stage_image), | |
| "card_thumb": thumb, | |
| "source": "upload", | |
| "source_icon": "upload", | |
| } | |
| if source_video_path: | |
| scene["clip_src"] = str(source_video_path) | |
| return scene | |
| def local_shelf_items(scenes: list[dict[str, Any]]) -> list[tuple[Any, str]]: | |
| items = [] | |
| for scene in _library_scenes(scenes): | |
| thumb = scene.get("card_thumb") | |
| if thumb is None: | |
| media = scene.get("media") if isinstance(scene.get("media"), dict) else {} | |
| thumb = scene.get("frame_src") or media.get("card_url") or media.get("frame_url") | |
| if isinstance(thumb, str): | |
| thumb = _gallery_media_src(thumb) | |
| if thumb is not None: | |
| items.append((thumb, _shelf_caption(scene))) | |
| return items | |
| def _go_live_handler( | |
| image: Image.Image | None, | |
| video_path: str | None, | |
| style_key: str, | |
| scene_hint: str, | |
| scenes: list[dict[str, Any]], | |
| ) -> tuple[Any, ...]: | |
| """Upload-mode 'go live': narrate the moment straight onto the stage. | |
| Returns (header, stage, feed, shelf, scenes_state, pinned_id). Decorated | |
| with ui._gpu so ZeroGPU's startup scan finds the mark on the function | |
| Gradio binds. | |
| """ | |
| frame = image | |
| empty_caption = EMPTY_STAGE_CAPTION | |
| if frame is None and video_path: | |
| frame = pick_key_frame(sample_frames(video_path)) | |
| empty_caption = EMPTY_VIDEO_CAPTION | |
| card, narration = _narrate_core(frame, style_key, scene_hint or "", empty_caption) | |
| scene = make_local_scene(frame, card, narration, style_key, source_video_path=video_path) | |
| # Voice-over is on by default — narrate, then voice. A TTS hiccup must not crash the stage; | |
| # the voice is written to a served WAV so the hidden <audio> can replay it from the shelf. | |
| try: | |
| speech = speak(narration) | |
| scene["duration"] = len(speech.audio) / speech.sample_rate if speech.sample_rate else None | |
| scene["audio_src"] = _write_voice(speech.audio, speech.sample_rate, scene["scene_id"]) | |
| except Exception as exc: | |
| capture_exception(exc) | |
| scene["audio_src"] = None | |
| scenes = [*(scenes or []), scene][-SHELF_LIMIT:] | |
| payload = format_stage(scene) | |
| return ( | |
| # An upload is a finished, processed cut — show its auto-title, not "Happening now" | |
| # (that headline is reserved for live engine-mode capture). | |
| render_header_html( | |
| payload["title"], payload["style_label"], live=False, language=payload.get("language") | |
| ), | |
| render_stage_html( | |
| payload["frame_src"], | |
| payload["caption"], | |
| live=False, | |
| clip_src=payload["clip_src"], | |
| duration=payload["duration"], | |
| source_icon=payload["source_icon"], | |
| timed_captions=payload.get("timed_captions"), | |
| ), | |
| render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]), | |
| local_shelf_items(scenes), | |
| _audio_html(scene["audio_src"]), | |
| scenes, | |
| None, # a fresh scene un-pins the stage: back to live | |
| ) | |
| def _current_scene(scenes: list[dict[str, Any]], pinned_id: str | None) -> dict[str, Any] | None: | |
| if not scenes: | |
| return None | |
| by_id = {scene.get("scene_id"): scene for scene in scenes} | |
| return by_id.get(pinned_id, scenes[-1]) | |
| def _library_scene_at_index( | |
| scenes: list[dict[str, Any]], index: int | tuple[int, ...] | list[int] | |
| ) -> dict[str, Any] | None: | |
| ordered = _library_scenes(scenes) | |
| if not ordered: | |
| return None | |
| raw_index = index[0] if isinstance(index, (tuple, list)) else index | |
| return ordered[_clamp_index(int(raw_index), len(ordered))] | |
| def _stepped_scene( | |
| scenes: list[dict[str, Any]], current_id: str | None, delta: int | |
| ) -> dict[str, Any] | None: | |
| if not scenes: | |
| return None | |
| ids = [scene.get("scene_id") for scene in scenes] | |
| idx = ids.index(current_id) if current_id in ids else len(scenes) - 1 | |
| return scenes[(idx + delta) % len(scenes)] | |
| def _is_gradio_update(value: Any) -> bool: | |
| return isinstance(value, dict) and value.get("__type__") == "update" | |
| def _engine_ui_state(value: Any) -> dict[str, Any]: | |
| data = value if isinstance(value, dict) else {} | |
| scenes = data.get("scenes") | |
| upload_scene = data.get("upload_scene") | |
| upload_error_message = data.get("upload_error_message") | |
| return { | |
| "scenes": scenes if isinstance(scenes, list) else [], | |
| "pinned_id": data.get("pinned_id"), | |
| "current_id": data.get("current_id"), | |
| "playing_id": data.get("playing_id"), | |
| "upload_scene": upload_scene if isinstance(upload_scene, dict) else None, | |
| "upload_error_message": upload_error_message | |
| if isinstance(upload_error_message, str) | |
| else "", | |
| } | |
| def _pack_engine_ui_state( | |
| scenes: Any, | |
| pinned_id: str | None, | |
| current_id: Any, | |
| playing_id: Any, | |
| previous: dict[str, Any] | None = None, | |
| upload_scene: Any = _KEEP_UPLOAD_SCENE, | |
| upload_error_message: Any = _KEEP_UPLOAD_ERROR, | |
| ) -> dict[str, Any]: | |
| prev = _engine_ui_state(previous) | |
| return { | |
| "scenes": prev["scenes"] if _is_gradio_update(scenes) else scenes, | |
| "pinned_id": pinned_id, | |
| "current_id": prev["current_id"] if _is_gradio_update(current_id) else current_id, | |
| "playing_id": prev["playing_id"] if _is_gradio_update(playing_id) else playing_id, | |
| "upload_scene": prev["upload_scene"] | |
| if upload_scene is _KEEP_UPLOAD_SCENE | |
| else upload_scene, | |
| "upload_error_message": prev["upload_error_message"] | |
| if upload_error_message is _KEEP_UPLOAD_ERROR | |
| else (upload_error_message if isinstance(upload_error_message, str) else ""), | |
| } | |
| def _engine_scene_control_outputs( | |
| scene: dict[str, Any] | None, | |
| engine: EngineClient | BucketSceneClient, | |
| scenes: list[dict[str, Any]], | |
| state: Any, | |
| *, | |
| pinned_id: str | None, | |
| restart_audio: bool, | |
| empty_on_missing: bool = False, | |
| ) -> tuple[Any, ...]: | |
| state_payload = _engine_ui_state(state) | |
| if scene is None and not empty_on_missing: | |
| return (gr.skip(),) * 5 | |
| scene_for_stage = _scene_with_media_urls(scene, engine) if scene is not None else None | |
| payload = format_stage(scene_for_stage, engine.base_url) | |
| playing_id = state_payload["playing_id"] | |
| if restart_audio: | |
| audio_update = _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip() | |
| if payload["audio_src"]: | |
| playing_id = payload["scene_id"] | |
| elif payload["audio_src"] and payload["scene_id"] != playing_id: | |
| audio_update, playing_id = _audio_html(payload["audio_src"]), payload["scene_id"] | |
| else: | |
| audio_update = gr.skip() | |
| return ( | |
| render_header_html( | |
| payload["title"], | |
| payload["style_label"], | |
| payload["live"], | |
| language=payload.get("language"), | |
| ), | |
| render_stage_html( | |
| payload["frame_src"], | |
| payload["caption"], | |
| payload["live"], | |
| clip_src=payload["clip_src"], | |
| duration=payload["duration"], | |
| source_icon=payload["source_icon"], | |
| timed_captions=payload.get("timed_captions"), | |
| ), | |
| audio_update, | |
| _pack_engine_ui_state( | |
| scenes, | |
| pinned_id, | |
| payload["scene_id"], | |
| playing_id, | |
| previous=state_payload, | |
| upload_scene=None, | |
| ), | |
| gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip(), | |
| ) | |
| def _modal_upload_warning_response(message: str, state: Any) -> tuple[Any, ...]: | |
| gr.Warning(message) | |
| state_payload = _engine_ui_state(state) | |
| state_payload["upload_error_message"] = message | |
| return ( | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| state_payload, | |
| gr.skip(), | |
| ) | |
| def _effective_upload_context( | |
| persona_key: str, scene_hint: str, language: str, *, is_v2: bool | |
| ) -> str: | |
| """The value sent as `context`/`scene_hint` to Modal. | |
| v2: resolve the chosen persona to its curated manner steer (default → ""). | |
| v1: pass the free-text scene hint through unchanged (byte-identical legacy).""" | |
| if is_v2: | |
| return resolve_persona_steer(persona_key, language) | |
| return scene_hint | |
| def _effective_style_key(persona_key: str, style_key: str, *, is_v2: bool) -> str: | |
| """v2 carries the persona key in style_key (for display); v1 keeps its director style.""" | |
| return persona_key if is_v2 else style_key | |
| def _submit_modal_upload( | |
| video_path: str | None, | |
| style_key: str, | |
| scene_hint: str, | |
| state: Any, | |
| media_client: EngineClient | BucketSceneClient, | |
| *, | |
| language: str = "English", | |
| ) -> tuple[Any, ...]: | |
| if not video_path: | |
| return _modal_upload_warning_response("Upload a video clip first.", state) | |
| suffix = Path(video_path).suffix.lower() | |
| if suffix and suffix not in UPLOAD_ALLOWED_SUFFIXES: | |
| return _modal_upload_warning_response( | |
| f"Please upload one of: {UPLOAD_FORMAT_LABEL}.", state | |
| ) | |
| size_bytes = _video_size_bytes(video_path) | |
| if size_bytes is not None and size_bytes > UPLOAD_MAX_BYTES: | |
| return _modal_upload_warning_response( | |
| f"Please upload a clip up to {upload_max_mb()} MB.", state | |
| ) | |
| duration = _video_duration_s(video_path) | |
| max_seconds = upload_max_seconds() | |
| if duration is not None and duration > max_seconds + 0.25: | |
| return _modal_upload_warning_response( | |
| f"Please upload a clip up to {max_seconds:.0f} seconds.", state | |
| ) | |
| try: | |
| if _modal_api_is_v2(): | |
| raw_scene = _modal_upload_client().submit_video_v2( | |
| video_path, | |
| style_key=style_key, | |
| language=language, | |
| context=scene_hint, | |
| ) | |
| else: | |
| raw_scene = _modal_upload_client().submit_video( | |
| video_path, | |
| style_key=style_key, | |
| scene_hint=scene_hint, | |
| ) | |
| except (ModalUploadError, httpx.HTTPError) as exc: | |
| capture_exception(exc) | |
| return _modal_upload_warning_response(f"Modal upload failed: {exc}", state) | |
| except Exception as exc: | |
| capture_exception(exc) | |
| return _modal_upload_warning_response( | |
| "Upload failed before processing. Please try again.", state | |
| ) | |
| scene = _scene_with_media_urls(raw_scene, media_client) | |
| current_state = _engine_ui_state(state) | |
| scenes = [*current_state["scenes"], scene][-SHELF_LIMIT:] | |
| payload = format_stage(scene) | |
| scene_id = payload["scene_id"] | |
| return ( | |
| render_header_html( | |
| payload["title"], payload["style_label"], live=False, language=payload.get("language") | |
| ), | |
| render_stage_html( | |
| payload["frame_src"], | |
| payload["caption"], | |
| live=False, | |
| clip_src=payload["clip_src"], | |
| duration=payload["duration"], | |
| source_icon=payload["source_icon"], | |
| timed_captions=payload.get("timed_captions"), | |
| ), | |
| render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]), | |
| _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip(), | |
| shelf_items(scenes, media_client), | |
| _pack_engine_ui_state( | |
| scenes, | |
| scene_id, | |
| scene_id, | |
| scene_id, | |
| previous=current_state, | |
| upload_scene=scene, | |
| upload_error_message=None, | |
| ), | |
| gr.update(value=payload["visibility"]) if payload["visibility"] else gr.skip(), | |
| ) | |
| def _as_id_set(value: Any) -> set[str]: | |
| if not value: | |
| return set() | |
| return {str(item) for item in value if item} | |
| def _scene_id(scene: dict[str, Any] | None) -> str | None: | |
| value = scene.get("scene_id") if scene else None | |
| return str(value) if value else None | |
| def _scene_action_classes( | |
| scene_id: str | None, liked_ids: Any, reported_ids: Any | |
| ) -> tuple[list[str], list[str]]: | |
| liked = bool(scene_id) and scene_id in _as_id_set(liked_ids) | |
| reported = bool(scene_id) and scene_id in _as_id_set(reported_ids) | |
| return ( | |
| ["sc-icbtn", "sc-ico-like-filled" if liked else "sc-ico-like", "sc-like-btn"], | |
| ["sc-icbtn", "sc-ico-flag-filled" if reported else "sc-ico-flag", "sc-report-btn"], | |
| ) | |
| def _scene_action_updates(scene_id: str | None, liked_ids: Any, reported_ids: Any) -> tuple: | |
| like_classes, report_classes = _scene_action_classes(scene_id, liked_ids, reported_ids) | |
| return gr.update(elem_classes=like_classes), gr.update(elem_classes=report_classes) | |
| def _toggle_scene_like(scene_id: str | None, liked_ids: Any, reported_ids: Any) -> tuple: | |
| liked = _as_id_set(liked_ids) | |
| if scene_id: | |
| if scene_id in liked: | |
| liked.remove(scene_id) | |
| else: | |
| liked.add(scene_id) | |
| like_update, _report_update = _scene_action_updates(scene_id, liked, reported_ids) | |
| return liked, like_update | |
| def _toggle_scene_report(scene_id: str | None, liked_ids: Any, reported_ids: Any) -> tuple: | |
| reported = _as_id_set(reported_ids) | |
| if scene_id: | |
| reported.add(scene_id) | |
| _like_update, report_update = _scene_action_updates(scene_id, liked_ids, reported) | |
| return reported, report_update | |
| def _voice_handler(scenes: list[dict[str, Any]], pinned_id: str | None): | |
| """Upload-mode voice-over: TTS the staged scene's narration on demand.""" | |
| scene = _current_scene(scenes or [], pinned_id) | |
| if scene is None: | |
| return None | |
| return _speak_handler(scene["narration"]) | |
| def _seed_scenes() -> list[dict[str, Any]]: | |
| """Curated 'hero' library — upload mode boots with these so the Space isn't empty. | |
| Dated into the past so they read as finished cuts (STANDBY), not a live moment. | |
| """ | |
| GENERATED_AUDIO_DIR.mkdir(parents=True, exist_ok=True) | |
| gr.set_static_paths([demo_seed.SEED_DIR, GENERATED_AUDIO_DIR]) | |
| base = datetime.now(timezone.utc) - timedelta(hours=6) | |
| scenes: list[dict[str, Any]] = [] | |
| for offset, (clip, poster, title, narration, visibility) in enumerate(demo_seed.SEED): | |
| poster_img = demo_seed.load_poster(poster) | |
| thumb = poster_img.copy() | |
| thumb.thumbnail((400, 540)) | |
| voice = demo_seed.clip_path(clip[:-4] + ".mp3") | |
| scenes.append( | |
| { | |
| "scene_id": f"seed-{offset}", | |
| "title": title, | |
| "narration": narration, | |
| "style_key": demo_seed.STYLE_KEY, | |
| "language": "English", # the seed narration is English -> "EN" on the shelf | |
| "created_at": (base + timedelta(minutes=offset * 7)).isoformat(), | |
| "clip_src": f"/gradio_api/file={demo_seed.clip_path(clip)}", | |
| "audio_src": voice, | |
| "duration": _audio_duration(voice), | |
| "frame_src": _data_uri(poster_img), | |
| "card_thumb": thumb, | |
| "visibility": visibility, | |
| } | |
| ) | |
| return scenes | |
| # -- the page -------------------------------------------------------------------------- | |
| def _clamp_index(evt_index: Any, length: int) -> int: | |
| index = evt_index[0] if isinstance(evt_index, list | tuple) else evt_index | |
| return max(0, min(int(index), length - 1)) | |
| # Generated voice-overs are written here as served WAV files (ephemeral, not the library) so the | |
| # custom <audio> narration element can stream them — see _write_voice. Registered via | |
| # set_static_paths. | |
| GENERATED_AUDIO_DIR = Path(tempfile.gettempdir()) / "small_cuts_voice" | |
| def _audio_url(src: str | None) -> str | None: | |
| """A browser-loadable URL for the voice-over. Engine scenes already carry http(s) or | |
| `/gradio_api` URLs; local file paths are served through Gradio's static-file route (the seed | |
| dir + GENERATED_AUDIO_DIR are registered via gr.set_static_paths).""" | |
| if not src: | |
| return None | |
| if src.startswith(("http://", "https://", "/gradio_api/")): | |
| return src | |
| return f"/gradio_api/file={src}" | |
| def _audio_html(src: str | None) -> str: | |
| """The hidden narration `<audio>` element — no native controls. | |
| Re-rendered into its host on each scene change so the source swaps with the cut. gr.Audio can't | |
| serve this role: it plays via wavesurfer, leaving its `<audio>` empty. | |
| """ | |
| url = _audio_url(src) | |
| if not url: | |
| return '<audio id="sc-voice" preload="auto" fetchpriority="high"></audio>' | |
| return ( | |
| f'<audio id="sc-voice" src="{html.escape(url, quote=True)}" ' | |
| 'preload="auto" fetchpriority="high"></audio>' | |
| ) | |
| def _write_voice(samples: Any, sample_rate: int, scene_id: str) -> str | None: | |
| """Persist generated TTS to a served 16-bit PCM WAV (universal browser support) so the master | |
| clock can stream it. Ephemeral — temp dir, not the library (Try-it audio is 'not saved').""" | |
| if sample_rate <= 0: | |
| return None | |
| try: | |
| GENERATED_AUDIO_DIR.mkdir(parents=True, exist_ok=True) | |
| path = str(GENERATED_AUDIO_DIR / f"{scene_id}.wav") | |
| sf.write(path, samples, sample_rate, subtype="PCM_16") | |
| _evict_generated_audio() | |
| return path | |
| except Exception: | |
| return None | |
| def _evict_generated_audio() -> None: | |
| files = sorted( | |
| GENERATED_AUDIO_DIR.glob("*.wav"), | |
| key=lambda path: (path.stat().st_mtime, path.name), | |
| ) | |
| for old in files[:-SHELF_LIMIT]: | |
| old.unlink(missing_ok=True) | |
| def _audio_duration(path: str | None) -> float | None: | |
| """Voice-over length in seconds — the exact clock the captions advance against.""" | |
| if not path: | |
| return None | |
| try: | |
| return float(sf.info(path).duration) | |
| except Exception: | |
| return None | |
| # Browser-native playback wiring for the stage. Narration audio is authoritative when it has | |
| # a source; otherwise the muted looping video is authoritative. Captions/progress/icon state | |
| # are painted from media events, while the b-roll free-runs on its own native clock. | |
| # Also injects the Voice-Cut favicon and wires the header as the "back to live" affordance. | |
| PLAYBACK_SYNC_JS = """ | |
| () => { | |
| if (window.__scInit) return; | |
| window.__scInit = true; | |
| // favicon: replace Gradio's default with the Small Cuts Voice-Cut mark | |
| try { | |
| const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">` | |
| + `<defs>` | |
| + `<linearGradient id="b" x1="6" y1="4" x2="58" y2="60" gradientUnits="userSpaceOnUse">` | |
| + `<stop offset="0" stop-color="#26272c"/>` | |
| + `<stop offset="1" stop-color="#0d0e11"/></linearGradient>` | |
| + `<linearGradient id="g" x1="20" y1="9" x2="45" y2="56" gradientUnits="userSpaceOnUse">` | |
| + `<stop offset="0" stop-color="#e1c98b"/>` | |
| + `<stop offset="1" stop-color="#8e7845"/></linearGradient></defs>` | |
| + `<rect width="64" height="64" rx="14" fill="url(#b)"/>` | |
| + `<rect x="21" y="8" width="22" height="48" rx="7" fill="#17181b" ` | |
| + `stroke="url(#g)" stroke-width="5"/>` | |
| + `<path d="M23 41 41 23" stroke="url(#g)" stroke-width="8" stroke-linecap="round"/>` | |
| + `<path d="M25 48c3 0 3-5 6-5s3 5 6 5" stroke="#d8d4c7" stroke-width="3.2" ` | |
| + `fill="none" stroke-linecap="round"/></svg>`; | |
| let link = document.querySelector("link[rel~='icon']"); | |
| if (!link) { | |
| link = document.createElement('link'); link.rel = 'icon'; | |
| document.head.appendChild(link); | |
| } | |
| link.type = 'image/svg+xml'; | |
| link.href = 'data:image/svg+xml,' + encodeURIComponent(svg); | |
| } catch (e) {} | |
| const scSyncHfHeaderSafeZone = () => { | |
| const hasHeader = !!document.querySelector('#huggingface-space-header'); | |
| document.querySelectorAll('.sc-topbar, #sc-upload-popover').forEach((node) => { | |
| node.classList.toggle('sc-hf-header-present', hasHeader); | |
| }); | |
| }; | |
| scSyncHfHeaderSafeZone(); | |
| if (!window.__scHfHeaderObs && document.body) { | |
| window.__scHfHeaderObs = new MutationObserver(scSyncHfHeaderSafeZone); | |
| window.__scHfHeaderObs.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| // header click = back to live (un-pin / re-follow); forwards to the hidden Gradio button | |
| document.addEventListener('click', (e) => { | |
| if (e.target.closest && e.target.closest('.sc-header')) { | |
| const b = document.querySelector('.sc-live-btn button') | |
| || document.querySelector('.sc-live-btn'); | |
| if (b) b.click(); | |
| } | |
| }, true); | |
| // volume slider -> the voice clock's volume (delegated; survives audio re-renders) | |
| document.addEventListener('input', (e) => { | |
| if (e.target && e.target.classList && e.target.classList.contains('sc-vol')) { | |
| const a = document.querySelector('#sc-voice'); | |
| if (a) a.volume = parseFloat(e.target.value); | |
| } | |
| }, true); | |
| const scFixUploadFormFields = () => { | |
| const panel = document.querySelector('#sc-upload-popover'); | |
| if (!panel) return; | |
| const file = panel.querySelector('input[type="file"][data-testid="file-upload"]'); | |
| if (file) { | |
| if (!file.id) file.id = 'sc-upload-video-file'; | |
| if (!file.getAttribute('name')) file.setAttribute('name', 'small_cuts_video'); | |
| } | |
| const fileLabel = panel.querySelector('label[data-testid="block-label"]'); | |
| if (file && fileLabel) { | |
| const currentFor = fileLabel.getAttribute('for') || ''; | |
| if (!currentFor || !document.getElementById(currentFor)) { | |
| fileLabel.setAttribute('for', file.id); | |
| } | |
| } | |
| const hint = panel.querySelector('textarea[data-testid="textbox"]'); | |
| if (hint) { | |
| if (!hint.id) hint.id = 'sc-upload-hint-text'; | |
| if (!hint.getAttribute('name')) hint.setAttribute('name', 'small_cuts_hint'); | |
| if (!hint.getAttribute('aria-label')) { | |
| hint.setAttribute('aria-label', 'Whisper context to the narrator'); | |
| } | |
| if (!hint.getAttribute('autocomplete')) hint.setAttribute('autocomplete', 'off'); | |
| } | |
| }; | |
| scFixUploadFormFields(); | |
| if (!window.__scUploadFormObs && document.body) { | |
| window.__scUploadFormObs = new MutationObserver(scFixUploadFormFields); | |
| window.__scUploadFormObs.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| // a11y: the icon-only control buttons have no visible text. Stamp aria-labels so screen | |
| // readers can announce them. Mirrors the scFixUploadFormFields pattern (delegated, survives | |
| // Gradio's per-scene re-render). Play/Pause is swapped dynamically in scSetPlayIcon below. | |
| const scLabelButtons = () => { | |
| const labels = { | |
| '.sc-ico-rewind': 'Previous clip', | |
| '.sc-play-btn': 'Play', | |
| '.sc-ico-forward': 'Next clip', | |
| '.sc-cc-btn': 'Toggle captions', | |
| '.sc-upload.sc-icbtn': 'Upload a clip', | |
| }; | |
| for (const [cls, label] of Object.entries(labels)) { | |
| const el = document.querySelector(cls); | |
| if (el && el.getAttribute('aria-label') !== label) { | |
| el.setAttribute('aria-label', label); | |
| } | |
| } | |
| }; | |
| scLabelButtons(); | |
| if (!window.__scA11yObs && document.body) { | |
| window.__scA11yObs = new MutationObserver(scLabelButtons); | |
| window.__scA11yObs.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| if (typeof window.__scUploadSubmitLocked !== 'boolean') { | |
| window.__scUploadSubmitLocked = false; | |
| } | |
| if (typeof window.__scUserWantsPlayback !== 'boolean') { | |
| window.__scUserWantsPlayback = false; | |
| } | |
| const scUploadSubmitButton = () => ( | |
| document.querySelector('#sc-upload-popover button.sc-narrate-btn') | |
| || document.querySelector('#sc-upload-popover .sc-narrate-btn button') | |
| ); | |
| const scUploadPreviewReady = () => { | |
| const video = document.querySelector('#sc-upload-popover .sc-upload-video video'); | |
| if (!video) return false; | |
| const source = video.currentSrc || video.getAttribute('src') | |
| || (video.querySelector('source') && video.querySelector('source').src); | |
| return !!source && video.readyState >= 1; | |
| }; | |
| const scUploadRunning = () => ( | |
| !!document.querySelector('#sc-upload-popover .sc-upload-status.running') | |
| ); | |
| const scSetUploadSubmitDisabled = (disabled) => { | |
| const button = scUploadSubmitButton(); | |
| if (!button) return; | |
| const ariaDisabled = disabled ? 'true' : 'false'; | |
| if (button.disabled !== disabled) button.disabled = disabled; | |
| if (button.getAttribute('aria-disabled') !== ariaDisabled) { | |
| button.setAttribute('aria-disabled', ariaDisabled); | |
| } | |
| if (button.classList.contains('sc-upload-submit-locked') !== disabled) { | |
| button.classList.toggle('sc-upload-submit-locked', disabled); | |
| } | |
| }; | |
| const scSyncUploadSubmitState = () => { | |
| scSetUploadSubmitDisabled( | |
| window.__scUploadSubmitLocked || scUploadRunning() || !scUploadPreviewReady() | |
| ); | |
| }; | |
| window.__scSyncUploadSubmitState = scSyncUploadSubmitState; | |
| scSyncUploadSubmitState(); | |
| if (!window.__scUploadSubmitObs && document.body) { | |
| window.__scUploadSubmitObs = new MutationObserver(scSyncUploadSubmitState); | |
| window.__scUploadSubmitObs.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| attributes: true, | |
| attributeFilter: ['src'], | |
| }); | |
| } | |
| document.addEventListener('loadedmetadata', (e) => { | |
| if (e.target && e.target.closest && e.target.closest('#sc-upload-popover .sc-upload-video')) { | |
| scSyncUploadSubmitState(); | |
| } | |
| }, true); | |
| document.addEventListener('click', (e) => { | |
| const button = e.target.closest | |
| && e.target.closest( | |
| '#sc-upload-popover button.sc-narrate-btn, #sc-upload-popover .sc-narrate-btn button' | |
| ); | |
| if (!button) return; | |
| if (window.__scUploadSubmitLocked || scUploadRunning() || !scUploadPreviewReady()) { | |
| e.preventDefault(); | |
| e.stopImmediatePropagation(); | |
| scSyncUploadSubmitState(); | |
| return; | |
| } | |
| window.__scUploadSubmitLocked = true; | |
| setTimeout(scSyncUploadSubmitState, 0); | |
| }, true); | |
| const scHasMediaSrc = (el) => !!(el && (el.currentSrc || el.getAttribute('src'))); | |
| const scAuthoritativeMedia = () => { | |
| const audio = document.querySelector('#sc-voice'); | |
| const video = document.querySelector('.sc-stage-shell video'); | |
| const hasAudio = scHasMediaSrc(audio); | |
| const clock = hasAudio ? audio : video; | |
| return { audio, video, hasAudio, clock }; | |
| }; | |
| const scSetPlayIcon = (playing) => { | |
| const playBtn = document.querySelector('.sc-play-btn'); | |
| if (!playBtn) return; | |
| playBtn.classList.toggle('sc-ico-pause', playing); | |
| playBtn.classList.toggle('sc-ico-play', !playing); | |
| const label = playing ? 'Pause' : 'Play'; | |
| if (playBtn.getAttribute('aria-label') !== label) { | |
| playBtn.setAttribute('aria-label', label); | |
| } | |
| }; | |
| const scSubtitleData = (key) => { | |
| const sub = document.querySelector('#sc-subtitle'); | |
| const raw = sub && sub.dataset ? sub.dataset[key] : ''; | |
| if (!raw) return []; | |
| try { | |
| const parsed = JSON.parse(raw); | |
| return Array.isArray(parsed) ? parsed : []; | |
| } catch (err) { | |
| return []; | |
| } | |
| }; | |
| const scCaptionCues = () => scSubtitleData('scCues'); | |
| const scCaptionChunks = () => scSubtitleData('scChunks'); | |
| const scCueText = (cues, t) => { | |
| let active = null; | |
| cues.some((cue, index) => { | |
| const start = Number(cue[0]); | |
| const end = Number(cue[1]); | |
| if (!Number.isFinite(start) || !Number.isFinite(end)) return false; | |
| const isLast = index === cues.length - 1; | |
| if (t >= start && (t < end || (isLast && t <= end + 0.25))) { | |
| active = cue; | |
| return true; | |
| } | |
| return false; | |
| }); | |
| const lastCue = cues[cues.length - 1]; | |
| if (!active && lastCue && t >= Number(lastCue[1])) active = lastCue; | |
| return active ? (active[2] || '') : null; | |
| }; | |
| // Captions advance on the authoritative clock. Server cues (with timings, when the scene's | |
| // duration is known) win; otherwise evenly index the chunk list by the LIVE media duration, so | |
| // captions still track scenes whose duration is only known in the browser (e.g. seed scenes). | |
| const scPaintCaption = (time, duration) => { | |
| const sub = document.querySelector('#sc-subtitle'); | |
| if (!sub) return; | |
| const line = sub.querySelector('.sc-sub-line'); | |
| if (!line) return; | |
| const t = Number.isFinite(time) ? time : 0; | |
| const cues = scCaptionCues(); | |
| let text = null; | |
| if (cues.length) { | |
| text = scCueText(cues, t); | |
| } else { | |
| const chunks = scCaptionChunks(); | |
| if (chunks.length) { | |
| if (Number.isFinite(duration) && duration > 0) { | |
| const p = Math.max(0, Math.min(1, t / duration)); | |
| text = chunks[Math.min(chunks.length - 1, Math.floor(p * chunks.length))]; | |
| } else { | |
| text = chunks[0]; | |
| } | |
| } | |
| } | |
| // Write ONLY on change. scRefreshPlaybackChrome runs from a MutationObserver watching | |
| // childList/subtree, and setting textContent is itself a childList mutation — writing | |
| // unconditionally re-fires the observer in an infinite loop that pegs the main thread and | |
| // hangs the tab. The change-guards below make every paint idempotent and self-limiting. | |
| if (text != null) { | |
| if (line.textContent !== text) line.textContent = text; | |
| if (line.hidden) line.hidden = false; | |
| } else if (!line.hidden) { | |
| line.hidden = true; | |
| } | |
| }; | |
| const scPaintProgressAndCaption = (clock) => { | |
| const fill = document.querySelector('#sc-progress-fill'); | |
| let progress = 0; | |
| let time = 0; | |
| let duration = 0; | |
| if (clock) { | |
| time = Number.isFinite(clock.currentTime) ? clock.currentTime : 0; | |
| duration = Number.isFinite(clock.duration) ? clock.duration : 0; | |
| if (duration > 0) { | |
| progress = Math.max(0, Math.min(1, time / duration)); | |
| } | |
| } | |
| const widthPct = (progress * 100).toFixed(1) + '%'; | |
| if (fill && fill.style.width !== widthPct) fill.style.width = widthPct; | |
| scPaintCaption(time, duration); | |
| }; | |
| const scRefreshPlaybackChrome = () => { | |
| const media = scAuthoritativeMedia(); | |
| const playing = !!(media.clock && !media.clock.paused && !media.clock.ended); | |
| scSetPlayIcon(playing); | |
| scPaintProgressAndCaption(media.clock); | |
| }; | |
| // play/pause must be bound directly to the trusted DOM gesture. A gr.Button.click(js=...) | |
| // callback runs through Gradio's event layer, which can lose browser user activation and | |
| // make audio.play() fail with NotAllowedError even though the user tapped the button. Touch | |
| // devices get pointerdown; mouse/keyboard use click. The guard prevents touch pointerdown's | |
| // follow-up click from immediately pausing the scene. | |
| const togglePlayback = (e) => { | |
| const playBtn = e.target.closest && e.target.closest('.sc-play-btn'); | |
| if (!playBtn) return; | |
| const media = scAuthoritativeMedia(); | |
| const { audio, video, hasAudio, clock } = media; | |
| if (!clock) return; | |
| if (clock.paused || clock.ended) { | |
| window.__scUserWantsPlayback = true; | |
| if (hasAudio && audio) { | |
| audio.play().catch((err) => { | |
| console.warn('small_cuts.viewer: audio play blocked', err); | |
| }); | |
| if (video) video.play().catch(() => {}); | |
| } else if (video) { | |
| video.play().catch(() => {}); | |
| } | |
| } else { | |
| window.__scUserWantsPlayback = false; | |
| clock.pause(); | |
| if (hasAudio && video) video.pause(); | |
| } | |
| }; | |
| document.addEventListener('play', (e) => { | |
| const media = scAuthoritativeMedia(); | |
| if (!media.clock || e.target !== media.clock) return; | |
| window.__scUserWantsPlayback = true; | |
| scSetPlayIcon(true); | |
| if (media.hasAudio && media.video && media.video.paused) { | |
| media.video.play().catch(() => {}); | |
| } | |
| }, true); | |
| document.addEventListener('pause', (e) => { | |
| const media = scAuthoritativeMedia(); | |
| if (!media.clock || e.target !== media.clock) return; | |
| window.__scUserWantsPlayback = false; | |
| scSetPlayIcon(false); | |
| if (media.hasAudio && media.video && !media.video.paused) { | |
| media.video.pause(); | |
| } | |
| scPaintProgressAndCaption(media.clock); | |
| }, true); | |
| document.addEventListener('timeupdate', (e) => { | |
| const media = scAuthoritativeMedia(); | |
| if (!media.clock || e.target !== media.clock) return; | |
| scPaintProgressAndCaption(media.clock); | |
| }, true); | |
| document.addEventListener('loadedmetadata', (e) => { | |
| const media = scAuthoritativeMedia(); | |
| if (media.clock && e.target === media.clock) scRefreshPlaybackChrome(); | |
| }, true); | |
| document.addEventListener('durationchange', (e) => { | |
| const media = scAuthoritativeMedia(); | |
| if (media.clock && e.target === media.clock) scRefreshPlaybackChrome(); | |
| }, true); | |
| if (!window.__scPlaybackChromeObs && document.body) { | |
| window.__scPlaybackChromeObs = new MutationObserver(scRefreshPlaybackChrome); | |
| window.__scPlaybackChromeObs.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| attributes: true, | |
| attributeFilter: ['src'], | |
| }); | |
| } | |
| scRefreshPlaybackChrome(); | |
| document.addEventListener('pointerdown', (e) => { | |
| if (e.pointerType === 'mouse') return; | |
| if (!(e.target.closest && e.target.closest('.sc-play-btn'))) return; | |
| window.__scLastTouchPlayAt = Date.now(); | |
| togglePlayback(e); | |
| }, true); | |
| document.addEventListener('click', (e) => { | |
| if (!(e.target.closest && e.target.closest('.sc-play-btn'))) return; | |
| if (Date.now() - (window.__scLastTouchPlayAt || 0) < 500) return; | |
| togglePlayback(e); | |
| }, true); | |
| // CC captions: a persisted, voice-first-OFF toggle. The button has no Gradio handler — flip a | |
| // class on .sc-theater (inside .contain, survives per-scene re-render of #sc-subtitle) and | |
| // remember the choice in localStorage. CSS hides .sc-subtitle unless .sc-theater has .sc-cc-on. | |
| // (.sc-theater, not <body>: Gradio's CSS prefixer prepends .contain… to every rule, so a | |
| // body-rooted selector never matches — <body> is an ancestor of .contain, not a descendant.) | |
| const scReadCcPref = () => { | |
| try { return window.localStorage.getItem('scCc') === '1'; } catch (e) { return false; } | |
| }; | |
| const scCcTarget = () => document.querySelector('.sc-theater') || document.body; | |
| if (scReadCcPref()) scCcTarget().classList.add('sc-cc-on'); | |
| document.addEventListener('click', (e) => { | |
| if (!(e.target.closest && e.target.closest('.sc-cc-btn'))) return; | |
| const on = scCcTarget().classList.toggle('sc-cc-on'); | |
| try { window.localStorage.setItem('scCc', on ? '1' : '0'); } catch (err) {} | |
| }, true); | |
| // R4 + R5: between "Make the cut" and the buffered reveal the user sees ONLY the | |
| // clapperboard loader. We mount the loader over the stage when the Narrate button is tapped, | |
| // keep it (re-mounting it when Gradio swaps in the result stage) until the result <video> is | |
| // fully buffered (canplaythrough / readyState>=3) or a timeout fires, then reveal + remove it. | |
| const SC_CLAP_HTML = __SC_CLAP_LOADER_HTML__; | |
| // Hang-backstop ONLY: a Modal cold cut takes ~40s, far longer than any fixed reveal timer | |
| // can safely assume, so an early fixed timer (the old 12s) would strip body.sc-generating | |
| // mid-generation and let Gradio's spinners snap back. The real reveal is event-driven by the | |
| // result <video> buffering (scArmReveal via the stage observer) and collapsed to a short grace | |
| // window by __scFinishGeneration when the server actually responds; this only fires if the | |
| // server never responds at all. | |
| const SC_REVEAL_TIMEOUT_MS = 120000; | |
| const SC_FINISH_GRACE_MS = 8000; | |
| const scStageHost = () => document.querySelector('.sc-stage-block .sc-stage-shell'); | |
| const scMountLoader = () => { | |
| const shell = scStageHost(); | |
| if (!shell) return; | |
| let loader = shell.querySelector('.sc-clap-loader'); | |
| if (!loader) { | |
| shell.insertAdjacentHTML('beforeend', SC_CLAP_HTML); | |
| loader = shell.querySelector('.sc-clap-loader'); | |
| } | |
| // hold the (possibly half-buffered) result hidden behind the loader; never autoplay it | |
| const video = shell.querySelector('video'); | |
| if (video) { video.style.visibility = 'hidden'; try { video.pause(); } catch (e) {} } | |
| }; | |
| const scRevealResult = () => { | |
| const shell = scStageHost(); | |
| if (shell) { | |
| const video = shell.querySelector('video'); | |
| if (video) video.style.visibility = ''; | |
| const loader = shell.querySelector('.sc-clap-loader'); | |
| if (loader) loader.remove(); | |
| } | |
| window.__scGenerating = false; | |
| window.__scUploadSubmitLocked = false; | |
| if (window.__scSyncUploadSubmitState) window.__scSyncUploadSubmitState(); | |
| document.body.classList.remove('sc-generating'); | |
| if (window.__scRevealTimer) { | |
| clearTimeout(window.__scRevealTimer); | |
| window.__scRevealTimer = 0; | |
| } | |
| }; | |
| const scArmReveal = (video) => { | |
| if (!video || video.__scArmed) return; | |
| video.__scArmed = true; | |
| if (video.readyState >= 3) { scRevealResult(); return; } | |
| const onReady = () => scRevealResult(); | |
| video.addEventListener('canplaythrough', onReady, { once: true }); | |
| video.addEventListener('loadeddata', () => { | |
| if (video.readyState >= 3) scRevealResult(); | |
| }, { once: true }); | |
| try { video.load(); } catch (e) {} | |
| }; | |
| // Deterministic completion signal: the upload click-chain's final .then(js) calls this when | |
| // the server has finished the cut (success OR soft-fail). The buffered-reveal above reveals the | |
| // real result the moment it can play; this just collapses the long hang-backstop into a short | |
| // grace window so the clapperboard never lingers once the server is genuinely done. It does NOT | |
| // inspect which <video> is mounted (that would risk revealing a stale, still-buffered scene | |
| // during the DOM swap) — it only shortens the safety net and lets scArmReveal do the real work. | |
| window.__scFinishGeneration = () => { | |
| if (!window.__scGenerating) return; | |
| if (window.__scRevealTimer) clearTimeout(window.__scRevealTimer); | |
| window.__scRevealTimer = setTimeout(scRevealResult, SC_FINISH_GRACE_MS); | |
| }; | |
| window.__scCancelGeneration = () => { | |
| scRevealResult(); | |
| }; | |
| // The Gradio upload chain calls this only after server-side preflight succeeds. Quota or | |
| // missing-file failures therefore never enter the "Rolling..." generation state. | |
| window.__scStartGeneration = () => { | |
| window.__scGenerating = true; | |
| document.body.classList.add('sc-generating'); | |
| scMountLoader(); | |
| if (window.__scRevealTimer) clearTimeout(window.__scRevealTimer); | |
| window.__scRevealTimer = setTimeout(scRevealResult, SC_REVEAL_TIMEOUT_MS); | |
| }; | |
| // when Gradio swaps the finished cut into the stage, re-mount the loader over the new <video> | |
| // and arm the buffered-reveal; the loader stays until canplaythrough/readyState>=3 or timeout. | |
| const stageObserverHost = document.querySelector('.sc-stage-block') || document.body; | |
| if (stageObserverHost && !window.__scStageObs) { | |
| window.__scStageObs = new MutationObserver(() => { | |
| if (!window.__scGenerating) return; | |
| const shell = scStageHost(); | |
| if (!shell) return; | |
| const video = shell.querySelector('video'); | |
| if (video && !shell.querySelector('.sc-clap-loader')) scMountLoader(); | |
| if (video) scArmReveal(video); | |
| }); | |
| window.__scStageObs.observe(stageObserverHost, { childList: true, subtree: true }); | |
| } | |
| } | |
| """ | |
| PLAYBACK_SYNC_JS = PLAYBACK_SYNC_JS.replace( | |
| "__SC_CLAP_LOADER_HTML__", "`" + render_clapperboard_html() + "`" | |
| ) | |
| PLAYBACK_SYNC_ON_LOAD_JS = f"({PLAYBACK_SYNC_JS})();" | |
| START_GENERATION_IF_PREFLIGHT_OK_JS = """ | |
| (...args) => { | |
| const status = document.querySelector('.sc-upload-status.running'); | |
| if (status && window.__scStartGeneration) window.__scStartGeneration(); | |
| return args; | |
| } | |
| """ | |
| FINISH_OR_CANCEL_GENERATION_JS = """ | |
| () => { | |
| const complete = document.querySelector('.sc-upload-status.complete'); | |
| if (complete) { | |
| if (window.__scFinishGeneration) window.__scFinishGeneration(); | |
| return; | |
| } | |
| if (window.__scCancelGeneration) window.__scCancelGeneration(); | |
| } | |
| """ | |
| 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") and _engine_autodetect_enabled(): | |
| # 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 | |
| upload_sandbox = upload_sandbox_enabled() | |
| upload_enabled = client is None or upload_sandbox | |
| upload_library = LocalUploadLibrary.from_env() if upload_enabled else None | |
| upload_budget = DailyProcessingBudget.from_env() if upload_enabled else None | |
| if upload_library is not None: | |
| gr.set_static_paths(upload_library.static_paths()) | |
| stored_uploads = upload_library.list_scenes(limit=SHELF_LIMIT) if upload_library else [] | |
| seed = _merge_scene_lists(_seed_scenes(), stored_uploads) if client is None else [] | |
| 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, language=boot.get("language") | |
| ) | |
| 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"], | |
| timed_captions=boot.get("timed_captions"), | |
| ) | |
| 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, | |
| head="""<link rel="preconnect" href="https://huggingface.co" crossorigin>""", | |
| ) | |
| 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_panel_open = gr.State(False) if upload_enabled else None | |
| upload_run_allowed = gr.State(False) if upload_enabled else None | |
| upload_budget_token = gr.State(None) if upload_enabled else None | |
| with gr.Row(elem_classes="sc-topbar"): | |
| gr.HTML( | |
| f'<div class="sc-brand"><span class="sc-brand-line">{BRAND_MARK_SVG}' | |
| " Small Cuts · always rolling</span>" | |
| '<span class="sc-soul">Born on the glasses — what the narrator says in your ' | |
| "ear lands here as a cut you can keep.</span></div>", | |
| padding=False, | |
| ) | |
| if upload_enabled: | |
| with gr.Column(elem_classes="sc-upload-entry", min_width=0): | |
| upload_btn = gr.Button( | |
| "Upload a clip", | |
| elem_classes=["sc-icbtn", "sc-upload", "sc-ico-upload"], | |
| ) | |
| gr.HTML(render_upload_cta_html(), elem_classes="sc-plain", padding=False) | |
| # 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( | |
| '<div class="sc-progress">' | |
| '<div class="sc-progress-fill" id="sc-progress-fill"></div></div>', | |
| 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 <audio> element empty/unreadable. So the master | |
| # clock is our own hidden <audio id="sc-voice"> (in `audio`, re-rendered per | |
| # scene), driven by these controls + PLAYBACK_SYNC_JS. Boots PAUSED; the play | |
| # tap is the one user gesture that starts audio+video+captions as a unit. | |
| rewind_btn = gr.Button("", elem_classes=["sc-icbtn", "sc-ico-rewind"]) | |
| gr.Button("", elem_classes=["sc-icbtn", "sc-ico-play", "sc-play-btn"]) | |
| gr.HTML( | |
| '<span class="sc-vol-ctl">' | |
| '<input type="range" class="sc-vol" min="0" max="1" step="0.05" ' | |
| 'value="1" aria-label="volume"></span>', | |
| elem_classes="sc-plain", | |
| padding=False, | |
| ) | |
| forward_btn = gr.Button("", elem_classes=["sc-icbtn", "sc-ico-forward"]) | |
| # CC: soft caption toggle. No Python handler — PLAYBACK_SYNC_JS flips a | |
| # persisted body class (delegated DOM click), like the play gesture. | |
| gr.Button("CC", elem_classes=["sc-icbtn", "sc-cc-btn"]) | |
| if client is None: | |
| # like (honest no-count toggle) + flag now live in the pill, aligned | |
| # with the controls (Review-3 #2 — no longer orphaned below). | |
| like_btn = gr.Button( | |
| "", elem_classes=["sc-icbtn", "sc-ico-like", "sc-like-btn"] | |
| ) | |
| report_btn = gr.Button( | |
| "", elem_classes=["sc-icbtn", "sc-ico-flag", "sc-report-btn"] | |
| ) | |
| # hidden narration <audio> host (re-rendered on each scene change) | |
| audio = gr.HTML(boot_audio, elem_classes="sc-audio-host", padding=False) | |
| # The play tap is handled by PLAYBACK_SYNC_JS as a delegated DOM click so the | |
| # browser keeps user activation for audio.play(). | |
| if upload_enabled: | |
| # one signature voice — no director menu; voice-over is on by default | |
| style = gr.State(DEFAULT_STYLE_KEY) | |
| if client is not None: | |
| visibility_controls = os.environ.get( | |
| "SMALL_CUTS_ENABLE_VISIBILITY_CONTROLS", "" | |
| ).strip().lower() in ("1", "true", "yes") | |
| with gr.Row(elem_classes="sc-meta"): | |
| visibility = gr.Radio( | |
| choices=list(VISIBILITIES), | |
| value="private", | |
| label="share", | |
| show_label=False, | |
| visible=visibility_controls, | |
| interactive=visibility_controls, | |
| ) | |
| feed = gr.HTML( | |
| render_feed_html([feed_entry(s) for s in seed[-FEED_LIMIT:]]), | |
| elem_classes="sc-plain", | |
| padding=False, | |
| visible=SHOW_FEED, | |
| ) | |
| if upload_enabled: | |
| # The upload sandbox opens on demand from the top-right icon as a compact | |
| # overlay, video-only (the product narrates video, not stills). | |
| image_none = gr.State(None) | |
| with gr.Group(visible=False, elem_id="sc-upload-popover") as upload_panel: | |
| gr.HTML( | |
| render_upload_panel_help_html(), | |
| elem_classes="sc-plain", | |
| padding=False, | |
| ) | |
| # R2: a generous, centered "Drop Video Here / — or — / Click to Upload" | |
| # zone (styled in VIEWER_CSS via .sc-upload-video). | |
| drop_video = gr.Video( | |
| sources=["upload"], | |
| show_label=False, | |
| height=132, | |
| elem_classes="sc-upload-video", | |
| ) | |
| # R2/R3: the optional scene-hint sits below the zone; cleared on success. | |
| # Hidden on v2 where the persona dropdown replaces free-text steering. | |
| hint = gr.Textbox( | |
| show_label=False, | |
| placeholder="Whisper context to the narrator (optional)", | |
| lines=1, | |
| max_lines=2, | |
| elem_classes="sc-upload-hint", | |
| visible=not _modal_api_is_v2(), | |
| ) | |
| # v2 narrator persona: a closed set of curated manner steers | |
| # (replaces the free-text hint on the v2 path). Hidden on v1, | |
| # where the free-text hint is the scene-content cue instead. | |
| persona = gr.Dropdown( | |
| choices=persona_choices(), | |
| value=PERSONA_DEFAULT_KEY, | |
| label="Narrator", | |
| container=False, | |
| visible=_modal_api_is_v2(), | |
| elem_classes="sc-upload-persona", | |
| ) | |
| # Voice language for the v2 /v2/narrate pipeline — only the languages Carlos | |
| # can validate by ear. Hidden (defaults English) on the v1 live Space so its | |
| # UI/behaviour is unchanged; the v1 path ignores the value. | |
| language = gr.Dropdown( | |
| choices=["English", "Spanish", "French"], | |
| value="English", | |
| label="Voice language", | |
| container=False, | |
| visible=_modal_api_is_v2(), | |
| elem_classes="sc-upload-language", | |
| ) | |
| upload_status = gr.HTML( | |
| render_upload_status_html(), | |
| elem_classes="sc-plain", | |
| padding=False, | |
| ) | |
| # R2: full-width "Make the cut" button below the hint. | |
| go = gr.Button( | |
| "Make the cut", | |
| variant="primary", | |
| size="sm", | |
| interactive=False, | |
| elem_classes="sc-narrate-btn", | |
| ) | |
| with gr.Column(elem_classes="sc-rail-col"): | |
| gr.HTML( | |
| f'<div class="sc-rail-head">{RAIL_MARK_SVG}<span>Library</span></div>', | |
| elem_classes="sc-plain", | |
| padding=False, | |
| ) | |
| shelf = gr.Gallery( | |
| value=(local_shelf_items(seed) if seed else []), | |
| show_label=False, | |
| columns=2, | |
| allow_preview=False, | |
| object_fit="cover", | |
| elem_classes="sc-shelf", | |
| ) | |
| # "Back to live" is now the (clickable) header; this button stays for its un-pin / | |
| # re-follow-live wiring but is hidden via CSS and triggered by the header click in JS. | |
| live_btn = gr.Button("⟲ Back to live", elem_classes=["sc-live-btn"]) | |
| gr.HTML( | |
| "", | |
| html_template='<span aria-hidden="true"></span>', | |
| js_on_load=PLAYBACK_SYNC_ON_LOAD_JS, | |
| elem_classes=["sc-playback-events"], | |
| padding=False, | |
| ) | |
| relay_events = ( | |
| gr.HTML( | |
| "", | |
| html_template='<span aria-hidden="true"></span>', | |
| js_on_load=RELAY_EVENT_BRIDGE_JS, | |
| visible="hidden", | |
| elem_classes=["sc-relay-events"], | |
| padding=False, | |
| ) | |
| if client is not None | |
| else None | |
| ) | |
| if upload_enabled: | |
| upload_btn.click( | |
| _toggle_upload_panel, | |
| inputs=[upload_panel_open], | |
| outputs=[ | |
| upload_panel_open, | |
| upload_panel, | |
| upload_status, | |
| go, | |
| drop_video, | |
| hint, | |
| ], | |
| queue=False, | |
| ) | |
| def _upload_preflight_ui(video_path): | |
| if not video_path: | |
| return ( | |
| False, | |
| None, | |
| render_upload_status_html("blocked", "Upload a video clip first."), | |
| gr.update(interactive=True), | |
| ) | |
| decision = ( | |
| upload_budget.try_reserve() | |
| if upload_budget is not None | |
| else BudgetDecision(True, token=None) | |
| ) | |
| if not decision.allowed: | |
| return ( | |
| False, | |
| None, | |
| render_upload_status_html("blocked", _upload_budget_warning(decision)), | |
| gr.update(interactive=True), | |
| ) | |
| return ( | |
| True, | |
| decision.token, | |
| render_upload_status_html("running"), | |
| gr.update(interactive=False), | |
| ) | |
| drop_video.change( | |
| _sync_upload_submit_ready_ui, | |
| inputs=[drop_video], | |
| outputs=[go, upload_status], | |
| queue=False, | |
| show_progress="hidden", | |
| ) | |
| if client is not None: | |
| engine = client # narrow the type for the closures below | |
| if upload_sandbox: | |
| def _go_modal_upload_ui( | |
| can_run, | |
| budget_token, | |
| video_path, | |
| style_key, | |
| scene_hint, | |
| language, | |
| persona_key, | |
| state, | |
| upload_status_html, | |
| ): | |
| if not can_run: | |
| status_update = gr.skip() | |
| if video_path and _upload_status_is_running(upload_status_html): | |
| status_update = render_upload_status_html( | |
| "blocked", "Upload did not start. Please try again." | |
| ) | |
| return ( | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| _engine_ui_state(state), | |
| gr.skip(), | |
| status_update, | |
| gr.update(interactive=True), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| ) | |
| # _submit_modal_upload keeps its 7-tuple contract; the success/soft-fail | |
| # branch (and the R3 reset) is decided here, in the click wrapper, so the | |
| # data-flow function is untouched. | |
| started_at = time.monotonic() | |
| effective_hint = _effective_upload_context( | |
| persona_key, scene_hint, language, is_v2=_modal_api_is_v2() | |
| ) | |
| try: | |
| result = _submit_modal_upload( | |
| video_path, | |
| _effective_style_key(persona_key, style_key, is_v2=_modal_api_is_v2()), | |
| effective_hint, | |
| state, | |
| engine, | |
| language=language, | |
| ) | |
| finally: | |
| if upload_budget is not None: | |
| upload_budget.finish(budget_token, time.monotonic() - started_at) | |
| succeeded = not _is_gradio_update(result[0]) | |
| if succeeded and upload_library is not None: | |
| result_state = _engine_ui_state(result[5]) | |
| scene = result_state["upload_scene"] | |
| if scene is not None: | |
| saved_scene = upload_library.save_scene( | |
| scene, | |
| source_video_path=video_path, | |
| ) | |
| scenes = _merge_scene_lists( | |
| result_state["scenes"], | |
| upload_library.list_scenes(limit=SHELF_LIMIT), | |
| ) | |
| payload = format_stage(saved_scene) | |
| result = ( | |
| render_header_html( | |
| payload["title"], | |
| payload["style_label"], | |
| live=False, | |
| language=payload.get("language"), | |
| ), | |
| render_stage_html( | |
| payload["frame_src"], | |
| payload["caption"], | |
| live=False, | |
| clip_src=payload["clip_src"], | |
| duration=payload["duration"], | |
| source_icon=payload["source_icon"], | |
| timed_captions=payload.get("timed_captions"), | |
| ), | |
| render_feed_html( | |
| [feed_entry(scene) for scene in scenes[-FEED_LIMIT:]] | |
| ), | |
| _audio_html(payload["audio_src"]) | |
| if payload["audio_src"] | |
| else gr.skip(), | |
| shelf_items(scenes, engine), | |
| _pack_engine_ui_state( | |
| scenes, | |
| saved_scene["scene_id"], | |
| saved_scene["scene_id"], | |
| saved_scene["scene_id"], | |
| previous=result_state, | |
| upload_scene=saved_scene, | |
| upload_error_message=None, | |
| ), | |
| gr.update(value=payload["visibility"]) | |
| if payload["visibility"] | |
| else gr.skip(), | |
| ) | |
| status = ( | |
| render_upload_status_html("complete") | |
| if succeeded | |
| else render_upload_status_html( | |
| "blocked", | |
| _engine_ui_state(result[5])["upload_error_message"] | |
| or "Upload could not be processed. Please try again.", | |
| ) | |
| ) | |
| # R3: on SUCCESS clear the video + hint so a second upload works immediately; | |
| # on soft-fail keep the user's file and what they typed. | |
| video_reset = gr.update(value=None) if succeeded else gr.skip() | |
| hint_reset = gr.update(value="") if succeeded else gr.skip() | |
| # Close the popover on success; keep it open on soft-fail so the user can retry. | |
| panel_reset = gr.update(visible=False) if succeeded else gr.skip() | |
| panel_open_reset = False if succeeded else gr.skip() | |
| return ( | |
| *result, | |
| status, | |
| gr.update(interactive=True), | |
| video_reset, | |
| hint_reset, | |
| panel_reset, | |
| panel_open_reset, | |
| ) | |
| go.click( | |
| _upload_preflight_ui, | |
| inputs=[drop_video], | |
| outputs=[upload_run_allowed, upload_budget_token, upload_status, go], | |
| queue=False, | |
| show_progress="hidden", | |
| ).then( | |
| _go_modal_upload_ui, | |
| inputs=[ | |
| upload_run_allowed, | |
| upload_budget_token, | |
| drop_video, | |
| style, | |
| hint, | |
| language, | |
| persona, | |
| scenes_state, | |
| upload_status, | |
| ], | |
| outputs=[ | |
| header, | |
| stage, | |
| feed, | |
| audio, | |
| shelf, | |
| scenes_state, | |
| visibility, | |
| upload_status, | |
| go, | |
| drop_video, | |
| hint, | |
| upload_panel, | |
| upload_panel_open, | |
| ], | |
| concurrency_limit=1, | |
| concurrency_id=UPLOAD_CONCURRENCY_ID, | |
| show_progress="hidden", | |
| js=START_GENERATION_IF_PREFLIGHT_OK_JS, | |
| ).then( | |
| # Deterministic "generation done" signal (success or soft-fail): now that the | |
| # server has responded, collapse the clapperboard hang-backstop to a short grace | |
| # window. The buffered-reveal still drives the actual reveal on canplaythrough. | |
| js=FINISH_OR_CANCEL_GENERATION_JS, | |
| ) | |
| def _paint(state): | |
| state = _engine_ui_state(state) | |
| previous_scenes = state["scenes"] | |
| ( | |
| header_update, | |
| stage_update, | |
| feed_update, | |
| audio_update, | |
| shelf_update, | |
| scenes, | |
| current_id, | |
| playing_id, | |
| visibility_update, | |
| ) = poll_engine( | |
| engine, | |
| state["scenes"], | |
| state["pinned_id"], | |
| state["playing_id"], | |
| current_id=state["current_id"], | |
| ) | |
| if upload_library is not None: | |
| upload_scenes = upload_library.list_scenes(limit=SHELF_LIMIT) | |
| scenes = _merge_scene_lists(scenes, upload_scenes) | |
| prev_ids = [scene.get("scene_id") for scene in previous_scenes] | |
| ids = [scene.get("scene_id") for scene in scenes] | |
| if ids != prev_ids: | |
| shelf_update = shelf_items(scenes, engine) | |
| feed_update = render_feed_html( | |
| [feed_entry(scene) for scene in scenes[-FEED_LIMIT:]] | |
| ) | |
| if state["upload_scene"] is not None: | |
| header_update = gr.skip() | |
| stage_update = gr.skip() | |
| audio_update = gr.skip() | |
| playing_id = state["playing_id"] | |
| return ( | |
| header_update, | |
| stage_update, | |
| feed_update, | |
| audio_update, | |
| shelf_update, | |
| _pack_engine_ui_state( | |
| scenes, | |
| state["pinned_id"], | |
| current_id, | |
| playing_id, | |
| previous=state, | |
| ), | |
| visibility_update, | |
| ) | |
| def _tick(state): | |
| # Push-not-poll cache-bust: a relay-scene SSE event means a fresh cut just landed in | |
| # the bucket; drop the BucketSceneClient manifest cache so this repaint re-reads it | |
| # now (within MANIFEST_CACHE_TTL_S it would otherwise serve the stale cached list). | |
| # Named `_tick` because the relay_scene custom event exposes it as the "_tick" api. | |
| if isinstance(engine, BucketSceneClient): | |
| engine.invalidate_cache() | |
| return _paint(state) | |
| poll_outputs = [ | |
| header, | |
| stage, | |
| feed, | |
| audio, | |
| shelf, | |
| scenes_state, | |
| visibility, | |
| ] | |
| if relay_events is not None: | |
| relay_events.relay_scene( | |
| _tick, | |
| inputs=[scenes_state], | |
| outputs=poll_outputs, | |
| queue=False, | |
| api_visibility="private", | |
| ) | |
| # Initial library paint. In bucket/engine mode the feed + shelf boot EMPTY | |
| # (seed=[]) and otherwise only repaint on a pushed relay-scene SSE event — so a | |
| # fresh page load (a judge opening the Space, or a tab after a silent manifest | |
| # promote that fired no hook) would show nothing until the next push. Run the SAME | |
| # proven repaint once on load so the current bucket library always renders. This is | |
| # a one-shot initial render, NOT a timer/poll loop. It uses _paint (no cache-bust) | |
| # so concurrent page loads still share the manifest cache; only a push (_tick) | |
| # forces a fresh re-read. | |
| demo.load( | |
| _paint, | |
| inputs=[scenes_state], | |
| outputs=poll_outputs, | |
| queue=False, | |
| ) | |
| def _on_select(evt: gr.SelectData, state): | |
| state = _engine_ui_state(state) | |
| scenes = state["scenes"] | |
| scene = _library_scene_at_index(scenes, evt.index) | |
| return _engine_scene_control_outputs( | |
| scene, | |
| engine, | |
| scenes, | |
| state, | |
| pinned_id=str(scene.get("scene_id")) if scene is not None else None, | |
| restart_audio=True, | |
| ) | |
| shelf.select( | |
| _on_select, | |
| inputs=[scenes_state], | |
| outputs=[ | |
| header, | |
| stage, | |
| audio, | |
| scenes_state, | |
| visibility, | |
| ], | |
| ) | |
| def _step_engine(delta, state): | |
| # rewind/forward step clip-to-clip over the library (never intra-clip) | |
| state = _engine_ui_state(state) | |
| scenes = state["scenes"] | |
| scene = _stepped_scene(scenes, state["pinned_id"], delta) | |
| return _engine_scene_control_outputs( | |
| scene, | |
| engine, | |
| scenes, | |
| state, | |
| pinned_id=str(scene.get("scene_id")) if scene is not None else None, | |
| restart_audio=True, | |
| ) | |
| def _back_to_live_engine(state): | |
| state = _engine_ui_state(state) | |
| scenes = state["scenes"] | |
| scene = scenes[-1] if scenes else None | |
| return _engine_scene_control_outputs( | |
| scene, | |
| engine, | |
| scenes, | |
| state, | |
| pinned_id=None, | |
| restart_audio=False, | |
| empty_on_missing=True, | |
| ) | |
| step_outputs_e = [ | |
| header, | |
| stage, | |
| audio, | |
| scenes_state, | |
| visibility, | |
| ] | |
| rewind_btn.click( | |
| lambda state: _step_engine(+1, state), | |
| inputs=[scenes_state], | |
| outputs=step_outputs_e, | |
| ) | |
| forward_btn.click( | |
| lambda state: _step_engine(-1, state), | |
| inputs=[scenes_state], | |
| outputs=step_outputs_e, | |
| ) | |
| def _on_visibility(value, state): | |
| current_id = _engine_ui_state(state)["current_id"] | |
| if not visibility_controls or not current_id or value not in VISIBILITIES: | |
| return | |
| try: | |
| engine.set_visibility(current_id, value) | |
| except httpx.HTTPError as exc: | |
| raise gr.Error(f"Could not update visibility: {exc}") from exc | |
| visibility.input(_on_visibility, inputs=[visibility, scenes_state]) | |
| live_btn.click( | |
| _back_to_live_engine, | |
| inputs=[scenes_state], | |
| outputs=step_outputs_e, | |
| ) | |
| else: | |
| def _on_local_select(evt: gr.SelectData, scenes, liked_ids, reported_ids): | |
| scenes = scenes or [] | |
| scene = _library_scene_at_index(scenes, evt.index) | |
| if scene is None: | |
| return (gr.skip(),) * 6 | |
| payload = format_stage(scene) | |
| like_update, report_update = _scene_action_updates( | |
| payload["scene_id"], liked_ids, reported_ids | |
| ) | |
| return ( | |
| render_header_html( | |
| payload["title"], | |
| payload["style_label"], | |
| payload["live"], | |
| language=payload.get("language"), | |
| ), | |
| render_stage_html( | |
| payload["frame_src"], | |
| payload["caption"], | |
| payload["live"], | |
| clip_src=payload["clip_src"], | |
| duration=payload["duration"], | |
| source_icon=payload["source_icon"], | |
| timed_captions=payload.get("timed_captions"), | |
| ), | |
| _audio_html(payload["audio_src"]), | |
| payload["scene_id"], | |
| like_update, | |
| report_update, | |
| ) | |
| def _back_to_live(scenes, liked_ids, reported_ids): | |
| scenes = scenes or [] | |
| scene = scenes[-1] if scenes else None | |
| payload = format_stage(scene) | |
| like_update, report_update = _scene_action_updates( | |
| payload["scene_id"], liked_ids, reported_ids | |
| ) | |
| return ( | |
| render_header_html( | |
| payload["title"], | |
| payload["style_label"], | |
| payload["live"], | |
| language=payload.get("language"), | |
| ), | |
| render_stage_html( | |
| payload["frame_src"], | |
| payload["caption"], | |
| payload["live"], | |
| clip_src=payload["clip_src"], | |
| duration=payload["duration"], | |
| source_icon=payload["source_icon"], | |
| timed_captions=payload.get("timed_captions"), | |
| ), | |
| _audio_html(payload["audio_src"]), | |
| None, | |
| like_update, | |
| report_update, | |
| ) | |
| def _step_local(delta, scenes, pinned_id, liked_ids, reported_ids): | |
| # rewind/forward step clip-to-clip over the session library (never intra-clip) | |
| scenes = scenes or [] | |
| scene = _stepped_scene(scenes, pinned_id, delta) | |
| if scene is None: | |
| return (gr.skip(),) * 6 | |
| payload = format_stage(scene) | |
| like_update, report_update = _scene_action_updates( | |
| payload["scene_id"], liked_ids, reported_ids | |
| ) | |
| return ( | |
| render_header_html( | |
| payload["title"], | |
| payload["style_label"], | |
| payload["live"], | |
| language=payload.get("language"), | |
| ), | |
| render_stage_html( | |
| payload["frame_src"], | |
| payload["caption"], | |
| payload["live"], | |
| clip_src=payload["clip_src"], | |
| duration=payload["duration"], | |
| source_icon=payload["source_icon"], | |
| timed_captions=payload.get("timed_captions"), | |
| ), | |
| _audio_html(payload["audio_src"]), | |
| scene["scene_id"], | |
| like_update, | |
| report_update, | |
| ) | |
| def _go_live_ui( | |
| can_run, | |
| budget_token, | |
| image, | |
| video_path, | |
| style_key, | |
| scene_hint, | |
| scenes, | |
| liked_ids, | |
| reported_ids, | |
| ): | |
| if not can_run: | |
| return ( | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| scenes or [], | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.update(interactive=True), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| gr.skip(), | |
| ) | |
| started_at = time.monotonic() | |
| try: | |
| outputs = _go_live_handler(image, video_path, style_key, scene_hint, scenes) | |
| finally: | |
| if upload_budget is not None: | |
| upload_budget.finish(budget_token, time.monotonic() - started_at) | |
| generated_scene = (outputs[5] or [])[-1] if outputs[5] else None | |
| if upload_library is not None and generated_scene is not None: | |
| scene = upload_library.save_scene( | |
| generated_scene, | |
| source_video_path=video_path, | |
| ) | |
| scenes = _merge_scene_lists(seed, upload_library.list_scenes(limit=SHELF_LIMIT)) | |
| payload = format_stage(scene) | |
| outputs = ( | |
| render_header_html( | |
| payload["title"], | |
| payload["style_label"], | |
| live=False, | |
| language=payload.get("language"), | |
| ), | |
| render_stage_html( | |
| payload["frame_src"], | |
| payload["caption"], | |
| live=False, | |
| clip_src=payload["clip_src"], | |
| duration=payload["duration"], | |
| source_icon=payload["source_icon"], | |
| timed_captions=payload.get("timed_captions"), | |
| ), | |
| render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]), | |
| local_shelf_items(scenes), | |
| _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip(), | |
| scenes, | |
| None, | |
| ) | |
| scene_id = _scene_id((outputs[5] or [])[-1]) if outputs[5] else None | |
| like_update, report_update = _scene_action_updates( | |
| scene_id, liked_ids, reported_ids | |
| ) | |
| # R3: the local pipeline always produces a finished cut, so clear the drop zone + | |
| # hint once it's staged — the popover is empty/ready for the next upload. | |
| return ( | |
| *outputs, | |
| like_update, | |
| report_update, | |
| render_upload_status_html("complete"), | |
| gr.update(interactive=True), | |
| gr.update(value=None), | |
| gr.update(value=""), | |
| gr.update(visible=False), | |
| False, | |
| ) | |
| def _like_current(scenes, pinned_id, liked_ids, reported_ids): | |
| scene_id = _scene_id(_current_scene(scenes or [], pinned_id)) | |
| liked, like_update = _toggle_scene_like(scene_id, liked_ids, reported_ids) | |
| if scene_id: | |
| message = ( | |
| "Liked — thanks; likes help surface good cuts." | |
| if scene_id in liked | |
| else "Like removed." | |
| ) | |
| gr.Info(message) | |
| return liked, like_update | |
| def _report_current(scenes, pinned_id, liked_ids, reported_ids): | |
| scene_id = _scene_id(_current_scene(scenes or [], pinned_id)) | |
| before = _as_id_set(reported_ids) | |
| reported, report_update = _toggle_scene_report(scene_id, liked_ids, reported_ids) | |
| if scene_id: | |
| gr.Info( | |
| "Reported — thanks; we'll review this clip." | |
| if scene_id not in before | |
| else "Already reported — thanks." | |
| ) | |
| return reported, report_update | |
| go_inputs = [ | |
| upload_run_allowed, | |
| upload_budget_token, | |
| image_none, | |
| drop_video, | |
| style, | |
| hint, | |
| scenes_state, | |
| liked_state, | |
| reported_state, | |
| ] | |
| go_outputs = [ | |
| header, | |
| stage, | |
| feed, | |
| shelf, | |
| audio, | |
| scenes_state, | |
| pinned_state, | |
| like_btn, | |
| report_btn, | |
| ] | |
| # Narration fires only on the explicit button — binding drop_video.change too | |
| # would double-narrate (and double the TTS work) the moment a file lands. | |
| go.click( | |
| _upload_preflight_ui, | |
| inputs=[drop_video], | |
| outputs=[upload_run_allowed, upload_budget_token, upload_status, go], | |
| queue=False, | |
| show_progress="hidden", | |
| ).then( | |
| _go_live_ui, | |
| inputs=go_inputs, | |
| outputs=[ | |
| *go_outputs, | |
| upload_status, | |
| go, | |
| drop_video, | |
| hint, | |
| upload_panel, | |
| upload_panel_open, | |
| ], | |
| show_progress="hidden", | |
| js=START_GENERATION_IF_PREFLIGHT_OK_JS, | |
| ).then( | |
| js=FINISH_OR_CANCEL_GENERATION_JS, | |
| ) | |
| like_btn.click( | |
| _like_current, | |
| inputs=[scenes_state, pinned_state, liked_state, reported_state], | |
| outputs=[liked_state, like_btn], | |
| ) | |
| report_btn.click( | |
| _report_current, | |
| inputs=[scenes_state, pinned_state, liked_state, reported_state], | |
| outputs=[reported_state, report_btn], | |
| ) | |
| shelf.select( | |
| _on_local_select, | |
| inputs=[scenes_state, liked_state, reported_state], | |
| outputs=[header, stage, audio, pinned_state, like_btn, report_btn], | |
| ) | |
| live_btn.click( | |
| _back_to_live, | |
| inputs=[scenes_state, liked_state, reported_state], | |
| outputs=[header, stage, audio, pinned_state, like_btn, report_btn], | |
| ) | |
| step_outputs = [header, stage, audio, pinned_state, like_btn, report_btn] | |
| rewind_btn.click( | |
| lambda scenes, pinned, liked, reported: _step_local( | |
| +1, scenes, pinned, liked, reported | |
| ), | |
| inputs=[scenes_state, pinned_state, liked_state, reported_state], | |
| outputs=step_outputs, | |
| ) | |
| forward_btn.click( | |
| lambda scenes, pinned, liked, reported: _step_local( | |
| -1, scenes, pinned, liked, reported | |
| ), | |
| inputs=[scenes_state, pinned_state, liked_state, reported_state], | |
| outputs=step_outputs, | |
| ) | |
| demo.queue(max_size=UPLOAD_QUEUE_MAX_SIZE, default_concurrency_limit=1) | |
| return demo | |