macayaven commited on
Commit
c04972c
Β·
verified Β·
1 Parent(s): d4eb93d

midcuts phase5 steps 1-3: context manner-steer + CC captions + v2 budget (5bd269d)

Browse files
src/small_cuts/modal_upload.py CHANGED
@@ -133,18 +133,20 @@ class ModalUploadClient:
133
  *,
134
  style_key: str = "deadpan",
135
  language: str = "English",
 
136
  ) -> dict[str, Any]:
137
  """Upload a clip to the v2 ``/v2/narrate`` pipeline in the chosen narration language.
138
 
139
- Mirrors :meth:`submit_video` but targets the v2 endpoint (``style_key`` + ``language`` form
140
- fields, no ``scene_hint``) and polls ``/v2/narrate/{job_id}``. Kept as a separate method so
141
- the v1 ``/v1/cuts`` path used by the live Space is untouched.
 
142
  """
143
  close = self.http_client is None
144
  client = self.http_client or httpx.Client(timeout=30.0, follow_redirects=True)
145
  try:
146
  try:
147
- job_id = self._submit_v2(client, Path(video_path), style_key, language)
148
  except ModalUploadError:
149
  raise
150
  except httpx.HTTPError as exc:
@@ -169,12 +171,13 @@ class ModalUploadClient:
169
  video_path: Path,
170
  style_key: str,
171
  language: str,
 
172
  ) -> str:
173
  with video_path.open("rb") as handle:
174
  response = client.post(
175
  f"{self.base_url.rstrip('/')}/v2/narrate",
176
  headers={"Authorization": f"Bearer {self.token}"},
177
- data={"style_key": style_key, "language": language},
178
  files={"video": (video_path.name, handle, "video/mp4")},
179
  )
180
  _raise_for_modal_status(response, "request")
 
133
  *,
134
  style_key: str = "deadpan",
135
  language: str = "English",
136
+ context: str = "",
137
  ) -> dict[str, Any]:
138
  """Upload a clip to the v2 ``/v2/narrate`` pipeline in the chosen narration language.
139
 
140
+ Mirrors :meth:`submit_video` but targets the v2 endpoint (``style_key`` + ``language`` +
141
+ ``context`` form fields) and polls ``/v2/narrate/{job_id}``. ``context`` is the optional
142
+ free-text *manner* steer (how the moment is told). Kept as a separate method so the v1
143
+ ``/v1/cuts`` path used by the live Space is untouched.
144
  """
145
  close = self.http_client is None
146
  client = self.http_client or httpx.Client(timeout=30.0, follow_redirects=True)
147
  try:
148
  try:
149
+ job_id = self._submit_v2(client, Path(video_path), style_key, language, context)
150
  except ModalUploadError:
151
  raise
152
  except httpx.HTTPError as exc:
 
171
  video_path: Path,
172
  style_key: str,
173
  language: str,
174
+ context: str = "",
175
  ) -> str:
176
  with video_path.open("rb") as handle:
177
  response = client.post(
178
  f"{self.base_url.rstrip('/')}/v2/narrate",
179
  headers={"Authorization": f"Bearer {self.token}"},
180
+ data={"style_key": style_key, "language": language, "context": context},
181
  files={"video": (video_path.name, handle, "video/mp4")},
182
  )
183
  _raise_for_modal_status(response, "request")
src/small_cuts/narrate_v2.py CHANGED
@@ -25,6 +25,7 @@ from uuid import uuid4
25
  CONTRACT_VERSION = "1.2.0"
26
  TITLE_MAX = 80
27
  NARRATION_MAX = 2000
 
28
  RELAY_HOOK_TIMEOUT_S = 5.0
29
 
30
  # (local file, remote bucket-relative path) -> None. The Modal app binds this to a token-scoped
@@ -32,6 +33,207 @@ RELAY_HOOK_TIMEOUT_S = 5.0
32
  Uploader = Callable[[Path, str], None]
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def build_narrated_scene(
36
  *,
37
  narration: str,
@@ -101,6 +303,15 @@ def carrier_cut_index(words: list[dict[str, Any]], carrier: str) -> tuple[float,
101
  return (float(words[-1]["t_end"]), len(words) - 1) if words else (0.0, -1)
102
 
103
 
 
 
 
 
 
 
 
 
 
104
  def cues_from_words(
105
  words: list[dict[str, Any]],
106
  *,
@@ -126,6 +337,24 @@ def cues_from_words(
126
  return cues
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  def publish_scene(
130
  uploader: Uploader,
131
  *,
 
25
  CONTRACT_VERSION = "1.2.0"
26
  TITLE_MAX = 80
27
  NARRATION_MAX = 2000
28
+ MAX_CONTEXT_CHARS = 280
29
  RELAY_HOOK_TIMEOUT_S = 5.0
30
 
31
  # (local file, remote bucket-relative path) -> None. The Modal app binds this to a token-scoped
 
33
  Uploader = Callable[[Path, str], None]
34
 
35
 
36
+ # ─────────────────────────────────────────────────────────────────────────────
37
+ # Narration language config (the by-ear quality lever β€” CARLOS tunes these strings).
38
+ #
39
+ # Two knobs feed the Talker's accent (Phase 0.5 finding): (1) the *text* the Thinker writes β€” a
40
+ # native-language prompt yields cleaner, less-anglophone text than an English "write in {language}"
41
+ # prompt; (2) the autoregressive cold-start ramp β€” the first ~1s of speech drifts toward the
42
+ # dominant (EN+ZH) manifold before settling, so the Talker speaks a throwaway warm-up *carrier*
43
+ # first and we trim it off (the aligner finds where it ends). Only the by-ear-validated languages
44
+ # (en/es/fr) are configured; any other language degrades to an English-base prompt with NO carrier,
45
+ # so the aligner step is skipped. English is configured too; English-on-Aiden has a mild ramp, so
46
+ # drop "English" from PRIME_CARRIER if the warm-up isn't worth the extra step there.
47
+ # ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ DEADPAN_SYS = (
50
+ "You are a film narrator. Watch the clip and write ONE short, flat, factual sentence "
51
+ "describing the moment. Declarative only. No exclamations, no emphasis, no emotion words, "
52
+ "no asterisks, brackets, parentheses, or stage directions. Neutral, monotone, deadpan."
53
+ )
54
+ USER_PROMPT = "Narrate this moment."
55
+
56
+ # Native-language (system, user) narration prompts. English is the canonical deadpan spec; es/fr
57
+ # are faithful ports (peninsular Spanish; standard French).
58
+ NATIVE_PROMPTS: dict[str, tuple[str, str]] = {
59
+ "English": (DEADPAN_SYS, USER_PROMPT),
60
+ "Spanish": (
61
+ "Eres un narrador de cine espaΓ±ol. Observa el clip y escribe UNA sola frase corta, plana "
62
+ "y objetiva que describa el momento. Solo en modo declarativo. Sin exclamaciones, sin "
63
+ "Γ©nfasis, sin palabras emotivas, sin asteriscos, corchetes, parΓ©ntesis ni acotaciones "
64
+ "escΓ©nicas. Neutral, monΓ³tono e inexpresivo. Escribe la narraciΓ³n en espaΓ±ol de EspaΓ±a "
65
+ "(castellano peninsular).",
66
+ "Narra este momento.",
67
+ ),
68
+ "French": (
69
+ "Tu es un narrateur de cinΓ©ma franΓ§ais. Regarde le plan et Γ©cris UNE seule phrase courte, "
70
+ "neutre et factuelle qui dΓ©crit le moment. Uniquement au mode dΓ©claratif. Sans "
71
+ "exclamations, sans emphase, sans mots émotifs, sans astérisques, crochets, parenthèses ni "
72
+ "didascalies. Neutre, monotone, impassible. RΓ©dige la narration en franΓ§ais.",
73
+ "Raconte ce moment.",
74
+ ),
75
+ }
76
+
77
+ # ~7-8s deadpan warm-up the Talker speaks FIRST, then trimmed off. Two self-contained sentences
78
+ # ending in a full stop (so it can't bleed into the real narration) and content-neutral (so it
79
+ # can't bias what the model describes). ~16-18 words β‰ˆ 7s spoken β€” the Phase-0 workflow found <5s
80
+ # under-warms the cold-start ramp.
81
+ PRIME_CARRIER: dict[str, str] = {
82
+ "English": (
83
+ "Preparing the narrator's voice for this recording in English. "
84
+ "The description of the scene begins right now."
85
+ ),
86
+ "Spanish": (
87
+ "Preparando la voz del narrador en espaΓ±ol de EspaΓ±a. "
88
+ "La descripciΓ³n de la escena comienza ahora."
89
+ ),
90
+ "French": (
91
+ "PrΓ©paration de la voix du narrateur en franΓ§ais pour cet enregistrement. "
92
+ "La description de la scène commence maintenant."
93
+ ),
94
+ }
95
+
96
+ # Per-language instruction (appended to the system prompt) telling the Thinker to open with the
97
+ # carrier verbatim. Written in the target language to keep the model in a native context.
98
+ PRIME_INSTRUCTION: dict[str, str] = {
99
+ "English": "Begin your answer exactly with the sentence Β«{carrier}Β» and then the narration.",
100
+ "Spanish": (
101
+ "Comienza tu respuesta exactamente con la frase Β«{carrier}Β» y, a continuaciΓ³n, la "
102
+ "narraciΓ³n."
103
+ ),
104
+ "French": "Commence ta rΓ©ponse exactement par la phrase Β«{carrier}Β» puis la narration.",
105
+ }
106
+
107
+ # Per-language template for the optional free-text *manner* steer (Phase 5 step 1). The upload
108
+ # "Whisper context to the narrator" field steers HOW the moment is told β€” voice, mood, register β€”
109
+ # NOT what facts to include; it is allowed to override the neutral deadpan default. Written in the
110
+ # target language to keep the Talker in a native manifold; unknown languages reuse the English one.
111
+ # Appended to the system prompt BEFORE the prime/carrier block so the carrier stays last.
112
+ CONTEXT_INSTRUCTION: dict[str, str] = {
113
+ "English": (
114
+ " The person who lived this moment asks you to tell it a particular way: Β«{context}Β». "
115
+ "Let that set the voice, mood, and register of the narration β€” it overrides the neutral "
116
+ "monotone above where they conflict. Keep the other rules: one short sentence in "
117
+ "{language}, declarative, no stage directions, and invent nothing that is not in "
118
+ "the clip."
119
+ ),
120
+ "Spanish": (
121
+ " La persona que viviΓ³ este momento te pide que lo narres de una manera concreta: "
122
+ "Β«{context}Β». Deja que eso marque la voz, el tono y el registro de la narraciΓ³n; prevalece "
123
+ "sobre el tono neutro y monΓ³tono anterior cuando haya conflicto. MantΓ©n las demΓ‘s reglas: "
124
+ "una sola frase breve en espaΓ±ol, en modo declarativo, sin acotaciones, y no inventes nada "
125
+ "que no estΓ© en el clip."
126
+ ),
127
+ "French": (
128
+ " La personne qui a vécu ce moment te demande de le raconter d'une manière précise : "
129
+ "Β«{context}Β». Laisse cela dΓ©finir la voix, l'ambiance et le registre de la "
130
+ "narration ; cela prime sur le ton neutre et monotone ci-dessus en cas de "
131
+ "conflit. Garde les autres règles : "
132
+ "une seule phrase courte en franΓ§ais, au mode dΓ©claratif, sans didascalies, et n'invente "
133
+ "rien qui ne soit dans le plan."
134
+ ),
135
+ }
136
+
137
+ # Text-only (system, user) title prompts β€” a SEPARATE pass (return_audio=False) so the Talker never
138
+ # speaks JSON braces (the Β§7 #2 constraint). Output is run through clean_model_title.
139
+ _TITLE_SYS_EN = (
140
+ "You are a film editor. Watch the clip and give a short, evocative title for this moment, "
141
+ "between two and five words. Output ONLY the title β€” no quotation marks, no surrounding "
142
+ "punctuation, and no explanation."
143
+ )
144
+ TITLE_PROMPTS: dict[str, tuple[str, str]] = {
145
+ "English": (_TITLE_SYS_EN, "Title this moment."),
146
+ "Spanish": (
147
+ "Eres montador de cine. Observa el clip y propΓ³n un tΓ­tulo breve y evocador para este "
148
+ "momento, de dos a cinco palabras. Devuelve SOLO el tΓ­tulo, sin comillas, sin signos de "
149
+ "puntuaciΓ³n alrededor y sin explicaciones. Escribe el tΓ­tulo en espaΓ±ol.",
150
+ "Titula este momento.",
151
+ ),
152
+ "French": (
153
+ "Tu es monteur de cinΓ©ma. Regarde le plan et propose un titre court et Γ©vocateur pour ce "
154
+ "moment, de deux Γ  cinq mots. Renvoie UNIQUEMENT le titre, sans guillemets, sans "
155
+ "ponctuation autour et sans explication. RΓ©dige le titre en franΓ§ais.",
156
+ "Donne un titre Γ  ce moment.",
157
+ ),
158
+ }
159
+
160
+
161
+ def has_carrier(language: str) -> bool:
162
+ """True when a warm-up carrier AND its instruction exist β†’ enable the carrier+cut path;
163
+ otherwise the narration is published untrimmed (no aligner hop)."""
164
+ return language in PRIME_CARRIER and language in PRIME_INSTRUCTION
165
+
166
+
167
+ def clean_context(context: str) -> str:
168
+ """Strip and cap the free-text manner steer; collapse internal whitespace.
169
+
170
+ Empty/whitespace-only input returns "" so the steer is a true no-op (the deadpan default
171
+ prompt stays byte-identical). Capped to ``MAX_CONTEXT_CHARS`` to bound this public,
172
+ anonymous free-text before it reaches the model's instruction context (prompt-injection
173
+ surface)."""
174
+ return " ".join((context or "").split())[:MAX_CONTEXT_CHARS]
175
+
176
+
177
+ def build_narration_prompts(language: str, *, prime: bool, context: str = "") -> tuple[str, str]:
178
+ """Return the (system, user) narration prompt for ``language``.
179
+
180
+ Native languages use their hand-tuned pair; anything else falls back to the English deadpan
181
+ base plus "Write the narration in {language}.". A non-empty ``context`` (the upload manner
182
+ steer) is appended to the system prompt as a HOW-it's-told directive; an empty ``context``
183
+ leaves the prompt byte-identical to the ear-ratified default. When ``prime`` and a carrier
184
+ exists, the carrier instruction is appended LAST (after any context) so the Talker still opens
185
+ with the carrier verbatim and the aligner trim is unaffected.
186
+ """
187
+ if language in NATIVE_PROMPTS:
188
+ system, user = NATIVE_PROMPTS[language]
189
+ else:
190
+ system = f"{DEADPAN_SYS} Write the narration in {language}."
191
+ user = USER_PROMPT
192
+ steer = clean_context(context)
193
+ if steer:
194
+ template = CONTEXT_INSTRUCTION.get(language, CONTEXT_INSTRUCTION["English"])
195
+ system = f"{system}{template.format(context=steer, language=language)}"
196
+ if prime and has_carrier(language):
197
+ system = f"{system} {PRIME_INSTRUCTION[language].format(carrier=PRIME_CARRIER[language])}"
198
+ return system, user
199
+
200
+
201
+ def build_title_prompts(language: str) -> tuple[str, str]:
202
+ """Return the (system, user) prompt for the text-only title pass.
203
+
204
+ Native languages use their hand-tuned pair; anything else falls back to the English title system
205
+ plus "Write the title in {language}.".
206
+ """
207
+ if language in TITLE_PROMPTS:
208
+ return TITLE_PROMPTS[language]
209
+ return f"{_TITLE_SYS_EN} Write the title in {language}.", "Title this moment."
210
+
211
+
212
+ _TITLE_LABEL_RE = re.compile(r"^(title|titre|t[Γ­i]tulo)\s*[::\-–—]\s*", re.IGNORECASE)
213
+ _TITLE_WRAPPERS = " \t*_`\"'Β«Β»β€œβ€β€˜β€™#"
214
+
215
+
216
+ def clean_model_title(raw: str, *, fallback: str) -> str:
217
+ """Normalize the text-only title pass into a bare title; fall back on malformed/empty output.
218
+
219
+ The model is asked to emit only the title, but we tolerate quotes, markdown bold/headers, a
220
+ leading "Title:"/"Titre:"/"TΓ­tulo:" label, a trailing parenthetical gloss on a second line, and
221
+ trailing punctuation. Returns the first line that yields a non-empty title after cleaning; if
222
+ none does, returns ``fallback`` (the derive_title-of-narration the contract allows). Always
223
+ capped to TITLE_MAX.
224
+ """
225
+ for line in (raw or "").splitlines() or [""]:
226
+ candidate = line.strip(_TITLE_WRAPPERS)
227
+ candidate = _TITLE_LABEL_RE.sub("", candidate).strip(_TITLE_WRAPPERS)
228
+ candidate = " ".join(candidate.split())
229
+ candidate = candidate.rstrip(".,;:!?·。").strip(_TITLE_WRAPPERS).strip()
230
+ if candidate:
231
+ return candidate[:TITLE_MAX]
232
+ # The contract allows an empty title, but a blank slate reads badly; guarantee non-empty even
233
+ # when the derive_title fallback is also empty (the empty-narration degenerate case).
234
+ return (fallback or "").strip()[:TITLE_MAX] or "Untitled"
235
+
236
+
237
  def build_narrated_scene(
238
  *,
239
  narration: str,
 
303
  return (float(words[-1]["t_end"]), len(words) - 1) if words else (0.0, -1)
304
 
305
 
306
+ def has_speech_content(text: str) -> bool:
307
+ """True when ``text`` has at least one alphanumeric (speech) character.
308
+
309
+ Used to reject a punctuation-only carrier-cut tail β€” if the aligner segments a lone "." as the
310
+ only word after the carrier, the trim must fall back to the untrimmed take rather than publish a
311
+ near-empty narration."""
312
+ return bool(_norm(text or ""))
313
+
314
+
315
  def cues_from_words(
316
  words: list[dict[str, Any]],
317
  *,
 
337
  return cues
338
 
339
 
340
+ def plan_carrier_cut(
341
+ words: list[dict[str, Any]], carrier: str
342
+ ) -> tuple[float, str, list[dict[str, Any]]]:
343
+ """Pure planner for the warm-up trim, given an aligned word list.
344
+
345
+ Finds where the spoken ``carrier`` ends and returns ``(t_cut, real_text, timed_captions)``:
346
+ the cut time, the narration with the carrier dropped, and caption cues rebased so t=0 is the
347
+ trimmed audio. When the post-carrier tail has no speech (a lone aligner punctuation token),
348
+ returns ``("" , [])`` for text+captions β€” signalling the caller to publish the untrimmed take
349
+ with no captions (rebased cues would be misaligned against the untrimmed wav). GPU-free so the
350
+ Modal aligner's non-model logic is unit-testable."""
351
+ t_cut, idx = carrier_cut_index(words, carrier)
352
+ real_text = " ".join(w["word"] for w in words[idx + 1 :]).strip()
353
+ if not has_speech_content(real_text):
354
+ return t_cut, "", []
355
+ return t_cut, real_text, cues_from_words(words, start_index=idx + 1, t_offset=t_cut)
356
+
357
+
358
  def publish_scene(
359
  uploader: Uploader,
360
  *,
src/small_cuts/viewer.py CHANGED
@@ -185,6 +185,12 @@ footer { display: none !important; }
185
  color: #f3efe4; border-radius: 9px; padding: 11px 16px; font-family: 'Spectral', serif;
186
  font-size: 1.04rem; line-height: 1.38; text-shadow: 0 1px 2px rgba(0,0,0,.85); }
187
  .sc-subtitle .sc-sub-line[hidden] { display: none; }
 
 
 
 
 
 
188
 
189
  .sc-rec { position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center;
190
  gap: 7px; background: rgba(16,16,20,.78); color: #D4AF37; padding: 4px 11px;
@@ -782,6 +788,7 @@ def format_stage(
782
  "live": False,
783
  "visibility": None,
784
  "source_icon": None,
 
785
  }
786
  base = engine_url.rstrip("/")
787
 
@@ -803,6 +810,7 @@ def format_stage(
803
  "live": is_fresh(scene.get("created_at"), now=now),
804
  "visibility": scene.get("visibility"),
805
  "source_icon": _source_icon(scene),
 
806
  }
807
 
808
 
@@ -867,11 +875,16 @@ def render_stage_html(
867
  clip_src: str | None = None,
868
  duration: float | None = None,
869
  source_icon: str | None = None,
 
870
  ) -> str:
871
  """The 9:16 stage: the moment (video clip or still frame) + lower-third caption.
872
 
873
  `live` is retained for signature stability β€” the live/finished state now lives in the
874
  header ("Happening now" vs. the auto-title), not a REC chip on the stage.
 
 
 
 
875
  """
876
  if clip_src:
877
  poster = f' poster="{html.escape(frame_src, quote=True)}"' if frame_src else ""
@@ -887,7 +900,11 @@ def render_stage_html(
887
  body = '<div class="sc-stage-empty">🎬</div>'
888
  if caption and caption.strip():
889
  chunks = [chunk for chunk in _subtitle_chunks(caption.strip()) if chunk.strip()]
890
- cues = caption_cues(caption, duration)
 
 
 
 
891
  first_caption = cues[0][2] if cues else (chunks[0] if chunks else caption.strip())
892
  dur_attr = f' data-duration="{float(duration):.1f}"' if duration else ""
893
  # Timed cues (when the scene's duration is known server-side) are preferred by the painter;
@@ -1113,6 +1130,7 @@ def poll_engine(
1113
  clip_src=payload["clip_src"],
1114
  duration=payload["duration"],
1115
  source_icon=payload["source_icon"],
 
1116
  )
1117
  feed = render_feed_html([feed_entry(scene) for scene in scenes[-FEED_LIMIT:]])
1118
 
@@ -1237,6 +1255,7 @@ def _go_live_handler(
1237
  clip_src=payload["clip_src"],
1238
  duration=payload["duration"],
1239
  source_icon=payload["source_icon"],
 
1240
  ),
1241
  render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]),
1242
  local_shelf_items(scenes),
@@ -1351,6 +1370,7 @@ def _engine_scene_control_outputs(
1351
  clip_src=payload["clip_src"],
1352
  duration=payload["duration"],
1353
  source_icon=payload["source_icon"],
 
1354
  ),
1355
  audio_update,
1356
  _pack_engine_ui_state(
@@ -1417,6 +1437,7 @@ def _submit_modal_upload(
1417
  video_path,
1418
  style_key=style_key,
1419
  language=language,
 
1420
  )
1421
  else:
1422
  raw_scene = _modal_upload_client().submit_video(
@@ -1447,6 +1468,7 @@ def _submit_modal_upload(
1447
  clip_src=payload["clip_src"],
1448
  duration=payload["duration"],
1449
  source_icon=payload["source_icon"],
 
1450
  ),
1451
  render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]),
1452
  _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip(),
@@ -1971,6 +1993,19 @@ PLAYBACK_SYNC_JS = """
1971
  togglePlayback(e);
1972
  }, true);
1973
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1974
  // R4 + R5: between "Make the cut" and the buffered reveal the user sees ONLY the
1975
  // clapperboard loader. We mount the loader over the stage when the Narrate button is tapped,
1976
  // keep it (re-mounting it when Gradio swaps in the result stage) until the result <video> is
@@ -2155,6 +2190,7 @@ def build_viewer_app() -> gr.Blocks:
2155
  clip_src=boot["clip_src"],
2156
  duration=boot["duration"],
2157
  source_icon=boot["source_icon"],
 
2158
  )
2159
  boot_audio = _audio_html(boot["audio_src"])
2160
 
@@ -2226,6 +2262,9 @@ def build_viewer_app() -> gr.Blocks:
2226
  padding=False,
2227
  )
2228
  forward_btn = gr.Button("", elem_classes=["sc-icbtn", "sc-ico-forward"])
 
 
 
2229
  if client is None:
2230
  # like (honest no-count toggle) + flag now live in the pill, aligned
2231
  # with the controls (Review-3 #2 β€” no longer orphaned below).
@@ -2475,6 +2514,7 @@ def build_viewer_app() -> gr.Blocks:
2475
  clip_src=payload["clip_src"],
2476
  duration=payload["duration"],
2477
  source_icon=payload["source_icon"],
 
2478
  ),
2479
  render_feed_html(
2480
  [feed_entry(scene) for scene in scenes[-FEED_LIMIT:]]
@@ -2764,6 +2804,7 @@ def build_viewer_app() -> gr.Blocks:
2764
  clip_src=payload["clip_src"],
2765
  duration=payload["duration"],
2766
  source_icon=payload["source_icon"],
 
2767
  ),
2768
  _audio_html(payload["audio_src"]),
2769
  payload["scene_id"],
@@ -2787,6 +2828,7 @@ def build_viewer_app() -> gr.Blocks:
2787
  clip_src=payload["clip_src"],
2788
  duration=payload["duration"],
2789
  source_icon=payload["source_icon"],
 
2790
  ),
2791
  _audio_html(payload["audio_src"]),
2792
  None,
@@ -2813,6 +2855,7 @@ def build_viewer_app() -> gr.Blocks:
2813
  clip_src=payload["clip_src"],
2814
  duration=payload["duration"],
2815
  source_icon=payload["source_icon"],
 
2816
  ),
2817
  _audio_html(payload["audio_src"]),
2818
  scene["scene_id"],
@@ -2873,6 +2916,7 @@ def build_viewer_app() -> gr.Blocks:
2873
  clip_src=payload["clip_src"],
2874
  duration=payload["duration"],
2875
  source_icon=payload["source_icon"],
 
2876
  ),
2877
  render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]),
2878
  local_shelf_items(scenes),
 
185
  color: #f3efe4; border-radius: 9px; padding: 11px 16px; font-family: 'Spectral', serif;
186
  font-size: 1.04rem; line-height: 1.38; text-shadow: 0 1px 2px rgba(0,0,0,.85); }
187
  .sc-subtitle .sc-sub-line[hidden] { display: none; }
188
+ /* CC captions default OFF (voice-first thesis); shown only when the viewer opts in. The gate lives
189
+ on <body> so the preference survives the per-scene re-render of #sc-subtitle. */
190
+ body:not(.sc-cc-on) .sc-subtitle { display: none; }
191
+ .sc-cc-btn.sc-icbtn { color: #1a1a1f !important; font-size: .72rem !important; font-weight: 700;
192
+ letter-spacing: .04em; -webkit-mask-image: none !important; mask-image: none !important; }
193
+ body.sc-cc-on .sc-cc-btn.sc-icbtn { background-color: #D4AF37 !important; }
194
 
195
  .sc-rec { position: absolute; top: 12px; left: 12px; display: inline-flex; align-items: center;
196
  gap: 7px; background: rgba(16,16,20,.78); color: #D4AF37; padding: 4px 11px;
 
788
  "live": False,
789
  "visibility": None,
790
  "source_icon": None,
791
+ "timed_captions": None,
792
  }
793
  base = engine_url.rstrip("/")
794
 
 
810
  "live": is_fresh(scene.get("created_at"), now=now),
811
  "visibility": scene.get("visibility"),
812
  "source_icon": _source_icon(scene),
813
+ "timed_captions": scene.get("timed_captions"),
814
  }
815
 
816
 
 
875
  clip_src: str | None = None,
876
  duration: float | None = None,
877
  source_icon: str | None = None,
878
+ timed_captions: list[dict[str, Any]] | None = None,
879
  ) -> str:
880
  """The 9:16 stage: the moment (video clip or still frame) + lower-third caption.
881
 
882
  `live` is retained for signature stability β€” the live/finished state now lives in the
883
  header ("Happening now" vs. the auto-title), not a REC chip on the stage.
884
+
885
+ `timed_captions` are the aligner's real word-timed cues (object-shaped {t_start,t_end,text}).
886
+ When present they are converted to the painter's tuple shape [start,end,text] and preferred
887
+ over the even-window `caption_cues` derivation (used as the fallback for seed/v1 scenes).
888
  """
889
  if clip_src:
890
  poster = f' poster="{html.escape(frame_src, quote=True)}"' if frame_src else ""
 
900
  body = '<div class="sc-stage-empty">🎬</div>'
901
  if caption and caption.strip():
902
  chunks = [chunk for chunk in _subtitle_chunks(caption.strip()) if chunk.strip()]
903
+ if timed_captions:
904
+ # Real aligner cues β†’ tuple shape [start, end, text] the JS painter (cue[0/1/2]) reads.
905
+ cues = [[c["t_start"], c["t_end"], c["text"]] for c in timed_captions]
906
+ else:
907
+ cues = caption_cues(caption, duration)
908
  first_caption = cues[0][2] if cues else (chunks[0] if chunks else caption.strip())
909
  dur_attr = f' data-duration="{float(duration):.1f}"' if duration else ""
910
  # Timed cues (when the scene's duration is known server-side) are preferred by the painter;
 
1130
  clip_src=payload["clip_src"],
1131
  duration=payload["duration"],
1132
  source_icon=payload["source_icon"],
1133
+ timed_captions=payload.get("timed_captions"),
1134
  )
1135
  feed = render_feed_html([feed_entry(scene) for scene in scenes[-FEED_LIMIT:]])
1136
 
 
1255
  clip_src=payload["clip_src"],
1256
  duration=payload["duration"],
1257
  source_icon=payload["source_icon"],
1258
+ timed_captions=payload.get("timed_captions"),
1259
  ),
1260
  render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]),
1261
  local_shelf_items(scenes),
 
1370
  clip_src=payload["clip_src"],
1371
  duration=payload["duration"],
1372
  source_icon=payload["source_icon"],
1373
+ timed_captions=payload.get("timed_captions"),
1374
  ),
1375
  audio_update,
1376
  _pack_engine_ui_state(
 
1437
  video_path,
1438
  style_key=style_key,
1439
  language=language,
1440
+ context=scene_hint,
1441
  )
1442
  else:
1443
  raw_scene = _modal_upload_client().submit_video(
 
1468
  clip_src=payload["clip_src"],
1469
  duration=payload["duration"],
1470
  source_icon=payload["source_icon"],
1471
+ timed_captions=payload.get("timed_captions"),
1472
  ),
1473
  render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]),
1474
  _audio_html(payload["audio_src"]) if payload["audio_src"] else gr.skip(),
 
1993
  togglePlayback(e);
1994
  }, true);
1995
 
1996
+ // CC captions: a persisted, voice-first-OFF toggle. The button has no Gradio handler β€” flip a
1997
+ // body-level class (which survives the per-scene re-render of #sc-subtitle) and remember the
1998
+ // choice in localStorage. CSS hides .sc-subtitle unless body has .sc-cc-on.
1999
+ const scReadCcPref = () => {
2000
+ try { return window.localStorage.getItem('scCc') === '1'; } catch (e) { return false; }
2001
+ };
2002
+ if (scReadCcPref()) document.body.classList.add('sc-cc-on');
2003
+ document.addEventListener('click', (e) => {
2004
+ if (!(e.target.closest && e.target.closest('.sc-cc-btn'))) return;
2005
+ const on = document.body.classList.toggle('sc-cc-on');
2006
+ try { window.localStorage.setItem('scCc', on ? '1' : '0'); } catch (err) {}
2007
+ }, true);
2008
+
2009
  // R4 + R5: between "Make the cut" and the buffered reveal the user sees ONLY the
2010
  // clapperboard loader. We mount the loader over the stage when the Narrate button is tapped,
2011
  // keep it (re-mounting it when Gradio swaps in the result stage) until the result <video> is
 
2190
  clip_src=boot["clip_src"],
2191
  duration=boot["duration"],
2192
  source_icon=boot["source_icon"],
2193
+ timed_captions=boot.get("timed_captions"),
2194
  )
2195
  boot_audio = _audio_html(boot["audio_src"])
2196
 
 
2262
  padding=False,
2263
  )
2264
  forward_btn = gr.Button("", elem_classes=["sc-icbtn", "sc-ico-forward"])
2265
+ # CC: soft caption toggle. No Python handler β€” PLAYBACK_SYNC_JS flips a
2266
+ # persisted body class (delegated DOM click), like the play gesture.
2267
+ gr.Button("CC", elem_classes=["sc-icbtn", "sc-cc-btn"])
2268
  if client is None:
2269
  # like (honest no-count toggle) + flag now live in the pill, aligned
2270
  # with the controls (Review-3 #2 β€” no longer orphaned below).
 
2514
  clip_src=payload["clip_src"],
2515
  duration=payload["duration"],
2516
  source_icon=payload["source_icon"],
2517
+ timed_captions=payload.get("timed_captions"),
2518
  ),
2519
  render_feed_html(
2520
  [feed_entry(scene) for scene in scenes[-FEED_LIMIT:]]
 
2804
  clip_src=payload["clip_src"],
2805
  duration=payload["duration"],
2806
  source_icon=payload["source_icon"],
2807
+ timed_captions=payload.get("timed_captions"),
2808
  ),
2809
  _audio_html(payload["audio_src"]),
2810
  payload["scene_id"],
 
2828
  clip_src=payload["clip_src"],
2829
  duration=payload["duration"],
2830
  source_icon=payload["source_icon"],
2831
+ timed_captions=payload.get("timed_captions"),
2832
  ),
2833
  _audio_html(payload["audio_src"]),
2834
  None,
 
2855
  clip_src=payload["clip_src"],
2856
  duration=payload["duration"],
2857
  source_icon=payload["source_icon"],
2858
+ timed_captions=payload.get("timed_captions"),
2859
  ),
2860
  _audio_html(payload["audio_src"]),
2861
  scene["scene_id"],
 
2916
  clip_src=payload["clip_src"],
2917
  duration=payload["duration"],
2918
  source_icon=payload["source_icon"],
2919
+ timed_captions=payload.get("timed_captions"),
2920
  ),
2921
  render_feed_html([feed_entry(s) for s in scenes[-FEED_LIMIT:]]),
2922
  local_shelf_items(scenes),