Spaces:
Paused
Paused
midcuts phase5 steps 1-3: context manner-steer + CC captions + v2 budget (5bd269d)
Browse files- src/small_cuts/modal_upload.py +8 -5
- src/small_cuts/narrate_v2.py +229 -0
- src/small_cuts/viewer.py +45 -1
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``
|
| 140 |
-
|
| 141 |
-
the
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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),
|