macayaven commited on
Commit
5af6674
·
verified ·
1 Parent(s): 4083556

deploy(midcuts): per-clip style+language labels + CC restyle (contract v1.3.0)

Browse files
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.2.0"
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: .72rem !important; font-weight: 700;
193
- letter-spacing: .04em; -webkit-mask-image: none !important; mask-image: none !important; }
194
- body.sc-cc-on .sc-cc-btn.sc-icbtn { background-color: #D4AF37 !important; }
 
 
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": _style_label(scene.get("style_key", "")),
 
 
 
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">Small Cuts</span>'
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
- return f"{_SOURCE_SHELF_PREFIXES.get(source_icon, '')}{title}"
 
 
 
 
 
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"], payload["style_label"], live=channel_live, notice=notice
 
 
 
 
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(payload["title"], payload["style_label"], live=False),
 
 
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(payload["title"], payload["style_label"], payload["live"]),
 
 
 
 
 
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(payload["title"], payload["style_label"], live=False),
 
 
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(boot["title"], boot["style_label"], live=False)
 
 
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"], payload["style_label"], live=False
 
 
 
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(payload["title"], payload["style_label"], payload["live"]),
 
 
 
 
 
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(payload["title"], payload["style_label"], payload["live"]),
 
 
 
 
 
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(payload["title"], payload["style_label"], payload["live"]),
 
 
 
 
 
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(payload["title"], payload["style_label"], live=False),
 
 
 
 
 
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"],