Spaces:
Paused
Paused
deploy(midcuts): per-clip style+language labels + CC restyle (contract v1.3.0)
Browse files- docs/contracts/narrated-scene.schema.json +223 -0
- src/small_cuts/narrate_v2.py +7 -1
- src/small_cuts/viewer.py +94 -18
docs/contracts/narrated-scene.schema.json
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 3 |
+
"$id": "https://github.com/macayaven/small-cuts/docs/contracts/1.3.0/narrated-scene.schema.json",
|
| 4 |
+
"title": "NarratedScene",
|
| 5 |
+
"description": "One narrated scene, produced by the engine. The SAME object is the viewer's live SSE event (D7) and the library's stored entry (D6); media fields are engine-served URLs.",
|
| 6 |
+
"type": "object",
|
| 7 |
+
"required": [
|
| 8 |
+
"captured_at",
|
| 9 |
+
"contract_version",
|
| 10 |
+
"created_at",
|
| 11 |
+
"media",
|
| 12 |
+
"moment_id",
|
| 13 |
+
"narration",
|
| 14 |
+
"scene_id",
|
| 15 |
+
"seq",
|
| 16 |
+
"session_id",
|
| 17 |
+
"style_key",
|
| 18 |
+
"title",
|
| 19 |
+
"visibility"
|
| 20 |
+
],
|
| 21 |
+
"additionalProperties": false,
|
| 22 |
+
"properties": {
|
| 23 |
+
"contract_version": {
|
| 24 |
+
"type": "string",
|
| 25 |
+
"enum": ["1.1.0", "1.2.0", "1.3.0"]
|
| 26 |
+
},
|
| 27 |
+
"scene_id": {
|
| 28 |
+
"type": "string",
|
| 29 |
+
"format": "uuid"
|
| 30 |
+
},
|
| 31 |
+
"moment_id": {
|
| 32 |
+
"type": "string",
|
| 33 |
+
"format": "uuid"
|
| 34 |
+
},
|
| 35 |
+
"session_id": {
|
| 36 |
+
"type": "string"
|
| 37 |
+
},
|
| 38 |
+
"created_at": {
|
| 39 |
+
"type": "string",
|
| 40 |
+
"format": "date-time"
|
| 41 |
+
},
|
| 42 |
+
"style_key": {
|
| 43 |
+
"type": "string"
|
| 44 |
+
},
|
| 45 |
+
"title": {
|
| 46 |
+
"type": "string",
|
| 47 |
+
"maxLength": 80,
|
| 48 |
+
"description": "Model-generated short title for the slate/thumbnail text; engines may fall back to derive_title for legacy or malformed model output."
|
| 49 |
+
},
|
| 50 |
+
"narration": {
|
| 51 |
+
"type": "string",
|
| 52 |
+
"maxLength": 2000
|
| 53 |
+
},
|
| 54 |
+
"visibility": {
|
| 55 |
+
"enum": [
|
| 56 |
+
"private",
|
| 57 |
+
"shared",
|
| 58 |
+
"public"
|
| 59 |
+
],
|
| 60 |
+
"default": "private"
|
| 61 |
+
},
|
| 62 |
+
"media": {
|
| 63 |
+
"type": "object",
|
| 64 |
+
"required": [
|
| 65 |
+
"frame_url"
|
| 66 |
+
],
|
| 67 |
+
"additionalProperties": false,
|
| 68 |
+
"properties": {
|
| 69 |
+
"frame_url": {
|
| 70 |
+
"type": "string",
|
| 71 |
+
"format": "uri-reference"
|
| 72 |
+
},
|
| 73 |
+
"card_url": {
|
| 74 |
+
"type": "string",
|
| 75 |
+
"format": "uri-reference"
|
| 76 |
+
},
|
| 77 |
+
"audio_url": {
|
| 78 |
+
"type": "string",
|
| 79 |
+
"format": "uri-reference"
|
| 80 |
+
},
|
| 81 |
+
"clip_url": {
|
| 82 |
+
"type": "string",
|
| 83 |
+
"format": "uri-reference",
|
| 84 |
+
"description": "Short viewer clip for library revisit/share, either uploaded or assembled by the engine from timestamped capture frames."
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
"persona": {
|
| 89 |
+
"type": "string",
|
| 90 |
+
"description": "Narrator persona key (e.g. nature_doc). Additive in v1.3.0; optional. Display derives the style label from it."
|
| 91 |
+
},
|
| 92 |
+
"language": {
|
| 93 |
+
"type": "string",
|
| 94 |
+
"description": "Spoken narration language (e.g. English). Additive in v1.3.0; optional."
|
| 95 |
+
},
|
| 96 |
+
"engine": {
|
| 97 |
+
"type": "object",
|
| 98 |
+
"description": "Provenance + latency breakdown (the inference team's scoreboard).",
|
| 99 |
+
"additionalProperties": false,
|
| 100 |
+
"properties": {
|
| 101 |
+
"narrator_model": {
|
| 102 |
+
"type": "string"
|
| 103 |
+
},
|
| 104 |
+
"narrator_backend": {
|
| 105 |
+
"enum": [
|
| 106 |
+
"llama_cpp",
|
| 107 |
+
"transformers",
|
| 108 |
+
"mock"
|
| 109 |
+
]
|
| 110 |
+
},
|
| 111 |
+
"tts_model": {
|
| 112 |
+
"type": "string"
|
| 113 |
+
},
|
| 114 |
+
"latency_ms": {
|
| 115 |
+
"type": "object",
|
| 116 |
+
"additionalProperties": false,
|
| 117 |
+
"properties": {
|
| 118 |
+
"queue": {
|
| 119 |
+
"type": "integer"
|
| 120 |
+
},
|
| 121 |
+
"narration": {
|
| 122 |
+
"type": "integer"
|
| 123 |
+
},
|
| 124 |
+
"tts": {
|
| 125 |
+
"type": "integer"
|
| 126 |
+
},
|
| 127 |
+
"total": {
|
| 128 |
+
"type": "integer"
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
},
|
| 134 |
+
"seq": {
|
| 135 |
+
"type": "integer",
|
| 136 |
+
"minimum": 0,
|
| 137 |
+
"description": "Monotonic engine event id - SSE id: field; clients resume with Last-Event-ID."
|
| 138 |
+
},
|
| 139 |
+
"captured_at": {
|
| 140 |
+
"type": "string",
|
| 141 |
+
"format": "date-time",
|
| 142 |
+
"description": "From the source MomentEnvelope - the library's chronology key (created_at is engine time)."
|
| 143 |
+
},
|
| 144 |
+
"owner": {
|
| 145 |
+
"type": "string",
|
| 146 |
+
"description": "Reserved for multi-user: identity that owns the scene. v1 single-user engines emit a constant."
|
| 147 |
+
},
|
| 148 |
+
"share_token": {
|
| 149 |
+
"type": [
|
| 150 |
+
"string",
|
| 151 |
+
"null"
|
| 152 |
+
],
|
| 153 |
+
"description": "Reserved: capability token for visibility=shared links. Enforcement spec lands with the library milestone."
|
| 154 |
+
},
|
| 155 |
+
"duration": {
|
| 156 |
+
"type": "number",
|
| 157 |
+
"minimum": 0,
|
| 158 |
+
"description": "Authoritative playback duration in seconds — the narration/voice audio length, which is the single-clock player's clock (NOT the source-clip length). Additive in v1.2.0; optional."
|
| 159 |
+
},
|
| 160 |
+
"keyframe_time": {
|
| 161 |
+
"type": "number",
|
| 162 |
+
"minimum": 0,
|
| 163 |
+
"description": "Offset in seconds from the start of the source clip of the producer-selected poster frame (the image itself is media.frame_url). Poster provenance, not a codec keyframe. Additive in v1.2.0; optional."
|
| 164 |
+
},
|
| 165 |
+
"timed_captions": {
|
| 166 |
+
"type": "array",
|
| 167 |
+
"description": "Speech-relative caption cues (aligner-produced) for the viewer CC track + accessibility. Times are relative to the trimmed narration audio. Additive in v1.2.0; optional.",
|
| 168 |
+
"items": {
|
| 169 |
+
"type": "object",
|
| 170 |
+
"additionalProperties": false,
|
| 171 |
+
"required": [
|
| 172 |
+
"t_start",
|
| 173 |
+
"t_end",
|
| 174 |
+
"text"
|
| 175 |
+
],
|
| 176 |
+
"properties": {
|
| 177 |
+
"t_start": {
|
| 178 |
+
"type": "number",
|
| 179 |
+
"minimum": 0
|
| 180 |
+
},
|
| 181 |
+
"t_end": {
|
| 182 |
+
"type": "number",
|
| 183 |
+
"minimum": 0
|
| 184 |
+
},
|
| 185 |
+
"text": {
|
| 186 |
+
"type": "string"
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
},
|
| 192 |
+
"allOf": [
|
| 193 |
+
{
|
| 194 |
+
"description": "Version-truth: a v1.2.0 field requires contract_version >= 1.2.0.",
|
| 195 |
+
"if": {
|
| 196 |
+
"anyOf": [
|
| 197 |
+
{ "required": ["duration"] },
|
| 198 |
+
{ "required": ["keyframe_time"] },
|
| 199 |
+
{ "required": ["timed_captions"] }
|
| 200 |
+
]
|
| 201 |
+
},
|
| 202 |
+
"then": {
|
| 203 |
+
"properties": {
|
| 204 |
+
"contract_version": { "enum": ["1.2.0", "1.3.0"] }
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"description": "Version-truth: a v1.3.0 field forces contract_version 1.3.0.",
|
| 210 |
+
"if": {
|
| 211 |
+
"anyOf": [
|
| 212 |
+
{ "required": ["persona"] },
|
| 213 |
+
{ "required": ["language"] }
|
| 214 |
+
]
|
| 215 |
+
},
|
| 216 |
+
"then": {
|
| 217 |
+
"properties": {
|
| 218 |
+
"contract_version": { "const": "1.3.0" }
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
]
|
| 223 |
+
}
|
src/small_cuts/narrate_v2.py
CHANGED
|
@@ -24,7 +24,7 @@ from pathlib import Path
|
|
| 24 |
from typing import Any, Protocol
|
| 25 |
from uuid import uuid4
|
| 26 |
|
| 27 |
-
CONTRACT_VERSION = "1.
|
| 28 |
TITLE_MAX = 80
|
| 29 |
NARRATION_MAX = 2000
|
| 30 |
MAX_CONTEXT_CHARS = 280
|
|
@@ -457,6 +457,8 @@ def build_narrated_scene(
|
|
| 457 |
keyframe_time: float | None = None,
|
| 458 |
scene_id: str | None = None,
|
| 459 |
moment_id: str | None = None,
|
|
|
|
|
|
|
| 460 |
) -> dict[str, Any]:
|
| 461 |
"""Build a NarratedScene that validates against narrated-scene.schema.json by construction.
|
| 462 |
|
|
@@ -486,6 +488,10 @@ def build_narrated_scene(
|
|
| 486 |
scene["keyframe_time"] = keyframe_time
|
| 487 |
if timed_captions is not None:
|
| 488 |
scene["timed_captions"] = timed_captions
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
return scene
|
| 490 |
|
| 491 |
|
|
|
|
| 24 |
from typing import Any, Protocol
|
| 25 |
from uuid import uuid4
|
| 26 |
|
| 27 |
+
CONTRACT_VERSION = "1.3.0"
|
| 28 |
TITLE_MAX = 80
|
| 29 |
NARRATION_MAX = 2000
|
| 30 |
MAX_CONTEXT_CHARS = 280
|
|
|
|
| 457 |
keyframe_time: float | None = None,
|
| 458 |
scene_id: str | None = None,
|
| 459 |
moment_id: str | None = None,
|
| 460 |
+
persona: str | None = None,
|
| 461 |
+
language: str | None = None,
|
| 462 |
) -> dict[str, Any]:
|
| 463 |
"""Build a NarratedScene that validates against narrated-scene.schema.json by construction.
|
| 464 |
|
|
|
|
| 488 |
scene["keyframe_time"] = keyframe_time
|
| 489 |
if timed_captions is not None:
|
| 490 |
scene["timed_captions"] = timed_captions
|
| 491 |
+
if persona is not None:
|
| 492 |
+
scene["persona"] = persona
|
| 493 |
+
if language is not None:
|
| 494 |
+
scene["language"] = language
|
| 495 |
return scene
|
| 496 |
|
| 497 |
|
src/small_cuts/viewer.py
CHANGED
|
@@ -53,7 +53,7 @@ from .hf_relay import (
|
|
| 53 |
BucketSceneClient as _BucketSceneClient,
|
| 54 |
)
|
| 55 |
from .modal_upload import ModalUploadClient, ModalUploadError
|
| 56 |
-
from .narrate_v2 import PERSONA_DEFAULT_KEY, persona_choices, resolve_persona_steer
|
| 57 |
from .observability import capture_exception
|
| 58 |
from .styles import DEFAULT_STYLE_KEY, STYLES
|
| 59 |
from .title_card import derive_title
|
|
@@ -189,9 +189,11 @@ footer { display: none !important; }
|
|
| 189 |
/* CC captions default OFF (voice-first thesis); shown only when the viewer opts in. The gate lives
|
| 190 |
on <body> so the preference survives the per-scene re-render of #sc-subtitle. */
|
| 191 |
body:not(.sc-cc-on) .sc-subtitle { display: none; }
|
| 192 |
-
.sc-cc-btn.sc-icbtn { color: #1a1a1f !important; font-size: .
|
| 193 |
-
letter-spacing: .
|
| 194 |
-
|
|
|
|
|
|
|
| 195 |
|
| 196 |
.sc-rec { position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center;
|
| 197 |
gap: 7px; background: rgba(16,16,20,.78); color: #D4AF37; padding: 4px 11px;
|
|
@@ -790,6 +792,7 @@ def format_stage(
|
|
| 790 |
"visibility": None,
|
| 791 |
"source_icon": None,
|
| 792 |
"timed_captions": None,
|
|
|
|
| 793 |
}
|
| 794 |
base = engine_url.rstrip("/")
|
| 795 |
|
|
@@ -802,7 +805,10 @@ def format_stage(
|
|
| 802 |
return {
|
| 803 |
"scene_id": scene.get("scene_id"),
|
| 804 |
"title": scene.get("title") or "Untitled Scene",
|
| 805 |
-
"style_label":
|
|
|
|
|
|
|
|
|
|
| 806 |
"caption": scene.get("narration", ""),
|
| 807 |
"frame_src": scene.get("frame_src") or _absolute(media.get("frame_url")),
|
| 808 |
"clip_src": scene.get("clip_src") or _absolute(media.get("clip_url")),
|
|
@@ -937,6 +943,7 @@ def render_header_html(
|
|
| 937 |
style_label: str,
|
| 938 |
live: bool,
|
| 939 |
notice: str | None = None,
|
|
|
|
| 940 |
) -> str:
|
| 941 |
# One unnamed signature voice — the channel is Small Cuts, not a per-cut director.
|
| 942 |
# style_label is retained in the call signature for future per-owner channels.
|
|
@@ -949,10 +956,15 @@ def render_header_html(
|
|
| 949 |
)
|
| 950 |
else:
|
| 951 |
headline = '<span class="sc-live-dot"></span>Happening now' if live else html.escape(title)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 952 |
return (
|
| 953 |
f'<div class="sc-header sc-{state}">'
|
| 954 |
f'<span class="sc-header-title">{headline}</span>'
|
| 955 |
-
'<span class="sc-header-channel">
|
| 956 |
"</div>"
|
| 957 |
)
|
| 958 |
|
|
@@ -1048,10 +1060,31 @@ def _gallery_media_src(src: str) -> str:
|
|
| 1048 |
return src
|
| 1049 |
|
| 1050 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1051 |
def _shelf_caption(scene: dict[str, Any]) -> str:
|
| 1052 |
title = scene.get("title", "")
|
| 1053 |
source_icon = _source_icon(scene)
|
| 1054 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1055 |
|
| 1056 |
|
| 1057 |
def _scene_with_media_urls(
|
|
@@ -1122,7 +1155,11 @@ def poll_engine(
|
|
| 1122 |
notice = "New cut available" if channel_live and not on_air else None
|
| 1123 |
|
| 1124 |
header = render_header_html(
|
| 1125 |
-
payload["title"],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1126 |
)
|
| 1127 |
stage = render_stage_html(
|
| 1128 |
payload["frame_src"],
|
|
@@ -1248,7 +1285,9 @@ def _go_live_handler(
|
|
| 1248 |
return (
|
| 1249 |
# An upload is a finished, processed cut — show its auto-title, not "Happening now"
|
| 1250 |
# (that headline is reserved for live engine-mode capture).
|
| 1251 |
-
render_header_html(
|
|
|
|
|
|
|
| 1252 |
render_stage_html(
|
| 1253 |
payload["frame_src"],
|
| 1254 |
payload["caption"],
|
|
@@ -1363,7 +1402,12 @@ def _engine_scene_control_outputs(
|
|
| 1363 |
else:
|
| 1364 |
audio_update = gr.skip()
|
| 1365 |
return (
|
| 1366 |
-
render_header_html(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1367 |
render_stage_html(
|
| 1368 |
payload["frame_src"],
|
| 1369 |
payload["caption"],
|
|
@@ -1413,6 +1457,11 @@ def _effective_upload_context(
|
|
| 1413 |
return scene_hint
|
| 1414 |
|
| 1415 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1416 |
def _submit_modal_upload(
|
| 1417 |
video_path: str | None,
|
| 1418 |
style_key: str,
|
|
@@ -1473,7 +1522,9 @@ def _submit_modal_upload(
|
|
| 1473 |
payload = format_stage(scene)
|
| 1474 |
scene_id = payload["scene_id"]
|
| 1475 |
return (
|
| 1476 |
-
render_header_html(
|
|
|
|
|
|
|
| 1477 |
render_stage_html(
|
| 1478 |
payload["frame_src"],
|
| 1479 |
payload["caption"],
|
|
@@ -2195,7 +2246,9 @@ def build_viewer_app() -> gr.Blocks:
|
|
| 2195 |
boot_audio = _audio_html(None)
|
| 2196 |
else:
|
| 2197 |
boot = format_stage(seed[-1] if seed else None)
|
| 2198 |
-
boot_header = render_header_html(
|
|
|
|
|
|
|
| 2199 |
boot_stage = render_stage_html(
|
| 2200 |
boot["frame_src"],
|
| 2201 |
boot["caption"],
|
|
@@ -2510,7 +2563,7 @@ def build_viewer_app() -> gr.Blocks:
|
|
| 2510 |
try:
|
| 2511 |
result = _submit_modal_upload(
|
| 2512 |
video_path,
|
| 2513 |
-
style_key,
|
| 2514 |
effective_hint,
|
| 2515 |
state,
|
| 2516 |
engine,
|
|
@@ -2535,7 +2588,10 @@ def build_viewer_app() -> gr.Blocks:
|
|
| 2535 |
payload = format_stage(saved_scene)
|
| 2536 |
result = (
|
| 2537 |
render_header_html(
|
| 2538 |
-
payload["title"],
|
|
|
|
|
|
|
|
|
|
| 2539 |
),
|
| 2540 |
render_stage_html(
|
| 2541 |
payload["frame_src"],
|
|
@@ -2827,7 +2883,12 @@ def build_viewer_app() -> gr.Blocks:
|
|
| 2827 |
payload["scene_id"], liked_ids, reported_ids
|
| 2828 |
)
|
| 2829 |
return (
|
| 2830 |
-
render_header_html(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2831 |
render_stage_html(
|
| 2832 |
payload["frame_src"],
|
| 2833 |
payload["caption"],
|
|
@@ -2851,7 +2912,12 @@ def build_viewer_app() -> gr.Blocks:
|
|
| 2851 |
payload["scene_id"], liked_ids, reported_ids
|
| 2852 |
)
|
| 2853 |
return (
|
| 2854 |
-
render_header_html(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2855 |
render_stage_html(
|
| 2856 |
payload["frame_src"],
|
| 2857 |
payload["caption"],
|
|
@@ -2878,7 +2944,12 @@ def build_viewer_app() -> gr.Blocks:
|
|
| 2878 |
payload["scene_id"], liked_ids, reported_ids
|
| 2879 |
)
|
| 2880 |
return (
|
| 2881 |
-
render_header_html(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2882 |
render_stage_html(
|
| 2883 |
payload["frame_src"],
|
| 2884 |
payload["caption"],
|
|
@@ -2939,7 +3010,12 @@ def build_viewer_app() -> gr.Blocks:
|
|
| 2939 |
scenes = _merge_scene_lists(seed, upload_library.list_scenes(limit=SHELF_LIMIT))
|
| 2940 |
payload = format_stage(scene)
|
| 2941 |
outputs = (
|
| 2942 |
-
render_header_html(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2943 |
render_stage_html(
|
| 2944 |
payload["frame_src"],
|
| 2945 |
payload["caption"],
|
|
|
|
| 53 |
BucketSceneClient as _BucketSceneClient,
|
| 54 |
)
|
| 55 |
from .modal_upload import ModalUploadClient, ModalUploadError
|
| 56 |
+
from .narrate_v2 import PERSONA_DEFAULT_KEY, PERSONA_LABELS, persona_choices, resolve_persona_steer
|
| 57 |
from .observability import capture_exception
|
| 58 |
from .styles import DEFAULT_STYLE_KEY, STYLES
|
| 59 |
from .title_card import derive_title
|
|
|
|
| 189 |
/* CC captions default OFF (voice-first thesis); shown only when the viewer opts in. The gate lives
|
| 190 |
on <body> so the preference survives the per-scene re-render of #sc-subtitle. */
|
| 191 |
body:not(.sc-cc-on) .sc-subtitle { display: none; }
|
| 192 |
+
.sc-cc-btn.sc-icbtn { color: #1a1a1f !important; font-size: .66rem !important; font-weight: 700;
|
| 193 |
+
letter-spacing: .06em; display: flex !important; align-items: center; justify-content: center;
|
| 194 |
+
-webkit-mask-image: none !important; mask-image: none !important; }
|
| 195 |
+
body.sc-cc-on .sc-cc-btn.sc-icbtn { background-color: #D4AF37 !important;
|
| 196 |
+
color: #1a1a1f !important; }
|
| 197 |
|
| 198 |
.sc-rec { position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center;
|
| 199 |
gap: 7px; background: rgba(16,16,20,.78); color: #D4AF37; padding: 4px 11px;
|
|
|
|
| 792 |
"visibility": None,
|
| 793 |
"source_icon": None,
|
| 794 |
"timed_captions": None,
|
| 795 |
+
"language": None,
|
| 796 |
}
|
| 797 |
base = engine_url.rstrip("/")
|
| 798 |
|
|
|
|
| 805 |
return {
|
| 806 |
"scene_id": scene.get("scene_id"),
|
| 807 |
"title": scene.get("title") or "Untitled Scene",
|
| 808 |
+
"style_label": (
|
| 809 |
+
persona_display(scene.get("persona"))[1] or _style_label(scene.get("style_key", ""))
|
| 810 |
+
),
|
| 811 |
+
"language": scene.get("language"),
|
| 812 |
"caption": scene.get("narration", ""),
|
| 813 |
"frame_src": scene.get("frame_src") or _absolute(media.get("frame_url")),
|
| 814 |
"clip_src": scene.get("clip_src") or _absolute(media.get("clip_url")),
|
|
|
|
| 943 |
style_label: str,
|
| 944 |
live: bool,
|
| 945 |
notice: str | None = None,
|
| 946 |
+
language: str | None = None,
|
| 947 |
) -> str:
|
| 948 |
# One unnamed signature voice — the channel is Small Cuts, not a per-cut director.
|
| 949 |
# style_label is retained in the call signature for future per-owner channels.
|
|
|
|
| 956 |
)
|
| 957 |
else:
|
| 958 |
headline = '<span class="sc-live-dot"></span>Happening now' if live else html.escape(title)
|
| 959 |
+
abbr = lang_abbr(language)
|
| 960 |
+
if abbr and style_label and not live:
|
| 961 |
+
right = f"{abbr} · {html.escape(style_label)}"
|
| 962 |
+
else:
|
| 963 |
+
right = "Small Cuts"
|
| 964 |
return (
|
| 965 |
f'<div class="sc-header sc-{state}">'
|
| 966 |
f'<span class="sc-header-title">{headline}</span>'
|
| 967 |
+
f'<span class="sc-header-channel">{right}</span>'
|
| 968 |
"</div>"
|
| 969 |
)
|
| 970 |
|
|
|
|
| 1060 |
return src
|
| 1061 |
|
| 1062 |
|
| 1063 |
+
_LANG_ABBR = {"English": "EN", "Spanish": "ES", "French": "FR"}
|
| 1064 |
+
|
| 1065 |
+
|
| 1066 |
+
def lang_abbr(language: str | None) -> str:
|
| 1067 |
+
"""Two-letter abbreviation for a known language; "" otherwise."""
|
| 1068 |
+
return _LANG_ABBR.get(language or "", "")
|
| 1069 |
+
|
| 1070 |
+
|
| 1071 |
+
def persona_display(persona: str | None) -> tuple[str, str]:
|
| 1072 |
+
"""(archetype, full label) for a persona key; ("", "") if unknown."""
|
| 1073 |
+
label = PERSONA_LABELS.get(persona or "")
|
| 1074 |
+
if not label:
|
| 1075 |
+
return ("", "")
|
| 1076 |
+
return (label.split(" · ", 1)[0], label)
|
| 1077 |
+
|
| 1078 |
+
|
| 1079 |
def _shelf_caption(scene: dict[str, Any]) -> str:
|
| 1080 |
title = scene.get("title", "")
|
| 1081 |
source_icon = _source_icon(scene)
|
| 1082 |
+
caption = f"{_SOURCE_SHELF_PREFIXES.get(source_icon, '')}{title}"
|
| 1083 |
+
abbr = lang_abbr(scene.get("language"))
|
| 1084 |
+
archetype, _ = persona_display(scene.get("persona"))
|
| 1085 |
+
if abbr and archetype:
|
| 1086 |
+
caption = f"{caption} · {abbr} · {archetype}"
|
| 1087 |
+
return caption
|
| 1088 |
|
| 1089 |
|
| 1090 |
def _scene_with_media_urls(
|
|
|
|
| 1155 |
notice = "New cut available" if channel_live and not on_air else None
|
| 1156 |
|
| 1157 |
header = render_header_html(
|
| 1158 |
+
payload["title"],
|
| 1159 |
+
payload["style_label"],
|
| 1160 |
+
live=channel_live,
|
| 1161 |
+
notice=notice,
|
| 1162 |
+
language=payload.get("language"),
|
| 1163 |
)
|
| 1164 |
stage = render_stage_html(
|
| 1165 |
payload["frame_src"],
|
|
|
|
| 1285 |
return (
|
| 1286 |
# An upload is a finished, processed cut — show its auto-title, not "Happening now"
|
| 1287 |
# (that headline is reserved for live engine-mode capture).
|
| 1288 |
+
render_header_html(
|
| 1289 |
+
payload["title"], payload["style_label"], live=False, language=payload.get("language")
|
| 1290 |
+
),
|
| 1291 |
render_stage_html(
|
| 1292 |
payload["frame_src"],
|
| 1293 |
payload["caption"],
|
|
|
|
| 1402 |
else:
|
| 1403 |
audio_update = gr.skip()
|
| 1404 |
return (
|
| 1405 |
+
render_header_html(
|
| 1406 |
+
payload["title"],
|
| 1407 |
+
payload["style_label"],
|
| 1408 |
+
payload["live"],
|
| 1409 |
+
language=payload.get("language"),
|
| 1410 |
+
),
|
| 1411 |
render_stage_html(
|
| 1412 |
payload["frame_src"],
|
| 1413 |
payload["caption"],
|
|
|
|
| 1457 |
return scene_hint
|
| 1458 |
|
| 1459 |
|
| 1460 |
+
def _effective_style_key(persona_key: str, style_key: str, *, is_v2: bool) -> str:
|
| 1461 |
+
"""v2 carries the persona key in style_key (for display); v1 keeps its director style."""
|
| 1462 |
+
return persona_key if is_v2 else style_key
|
| 1463 |
+
|
| 1464 |
+
|
| 1465 |
def _submit_modal_upload(
|
| 1466 |
video_path: str | None,
|
| 1467 |
style_key: str,
|
|
|
|
| 1522 |
payload = format_stage(scene)
|
| 1523 |
scene_id = payload["scene_id"]
|
| 1524 |
return (
|
| 1525 |
+
render_header_html(
|
| 1526 |
+
payload["title"], payload["style_label"], live=False, language=payload.get("language")
|
| 1527 |
+
),
|
| 1528 |
render_stage_html(
|
| 1529 |
payload["frame_src"],
|
| 1530 |
payload["caption"],
|
|
|
|
| 2246 |
boot_audio = _audio_html(None)
|
| 2247 |
else:
|
| 2248 |
boot = format_stage(seed[-1] if seed else None)
|
| 2249 |
+
boot_header = render_header_html(
|
| 2250 |
+
boot["title"], boot["style_label"], live=False, language=boot.get("language")
|
| 2251 |
+
)
|
| 2252 |
boot_stage = render_stage_html(
|
| 2253 |
boot["frame_src"],
|
| 2254 |
boot["caption"],
|
|
|
|
| 2563 |
try:
|
| 2564 |
result = _submit_modal_upload(
|
| 2565 |
video_path,
|
| 2566 |
+
_effective_style_key(persona_key, style_key, is_v2=_modal_api_is_v2()),
|
| 2567 |
effective_hint,
|
| 2568 |
state,
|
| 2569 |
engine,
|
|
|
|
| 2588 |
payload = format_stage(saved_scene)
|
| 2589 |
result = (
|
| 2590 |
render_header_html(
|
| 2591 |
+
payload["title"],
|
| 2592 |
+
payload["style_label"],
|
| 2593 |
+
live=False,
|
| 2594 |
+
language=payload.get("language"),
|
| 2595 |
),
|
| 2596 |
render_stage_html(
|
| 2597 |
payload["frame_src"],
|
|
|
|
| 2883 |
payload["scene_id"], liked_ids, reported_ids
|
| 2884 |
)
|
| 2885 |
return (
|
| 2886 |
+
render_header_html(
|
| 2887 |
+
payload["title"],
|
| 2888 |
+
payload["style_label"],
|
| 2889 |
+
payload["live"],
|
| 2890 |
+
language=payload.get("language"),
|
| 2891 |
+
),
|
| 2892 |
render_stage_html(
|
| 2893 |
payload["frame_src"],
|
| 2894 |
payload["caption"],
|
|
|
|
| 2912 |
payload["scene_id"], liked_ids, reported_ids
|
| 2913 |
)
|
| 2914 |
return (
|
| 2915 |
+
render_header_html(
|
| 2916 |
+
payload["title"],
|
| 2917 |
+
payload["style_label"],
|
| 2918 |
+
payload["live"],
|
| 2919 |
+
language=payload.get("language"),
|
| 2920 |
+
),
|
| 2921 |
render_stage_html(
|
| 2922 |
payload["frame_src"],
|
| 2923 |
payload["caption"],
|
|
|
|
| 2944 |
payload["scene_id"], liked_ids, reported_ids
|
| 2945 |
)
|
| 2946 |
return (
|
| 2947 |
+
render_header_html(
|
| 2948 |
+
payload["title"],
|
| 2949 |
+
payload["style_label"],
|
| 2950 |
+
payload["live"],
|
| 2951 |
+
language=payload.get("language"),
|
| 2952 |
+
),
|
| 2953 |
render_stage_html(
|
| 2954 |
payload["frame_src"],
|
| 2955 |
payload["caption"],
|
|
|
|
| 3010 |
scenes = _merge_scene_lists(seed, upload_library.list_scenes(limit=SHELF_LIMIT))
|
| 3011 |
payload = format_stage(scene)
|
| 3012 |
outputs = (
|
| 3013 |
+
render_header_html(
|
| 3014 |
+
payload["title"],
|
| 3015 |
+
payload["style_label"],
|
| 3016 |
+
live=False,
|
| 3017 |
+
language=payload.get("language"),
|
| 3018 |
+
),
|
| 3019 |
render_stage_html(
|
| 3020 |
payload["frame_src"],
|
| 3021 |
payload["caption"],
|