feat(short): narración — el grounding la cubre, el contrato la admite y el prompt la guía
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
El comprobador va primero, antes que el campo (fase 2 §12): la narración es prosa que el modelo redacta, no una etiqueta que copia, y es donde se cuela una cifra sin fuente. De paso, la huella de una cifra pasa a ser número + unidad canónica: con la voz repitiendo la pantalla, '35,000 FT' y '35,000 feet' son el mismo dato y contarlos dos veces inflaría el informe del que depende la revisión humana. editorial_notes estima la duración CON la voz: la declarada es un suelo y sin esto el modelo escribiría 40 s de shots, les colgaría narración y se enteraría del Short de 65 s cuando ya está pagado. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
93a506b636
commit
a13c3062b6
@@ -27,6 +27,7 @@ __all__ = [
|
||||
"SpecInvalid",
|
||||
"validate_spec",
|
||||
"editorial_notes",
|
||||
"estimated_duration",
|
||||
"describe_templates",
|
||||
"TARGET_MIN_DURATION",
|
||||
"TARGET_MAX_DURATION",
|
||||
@@ -224,6 +225,22 @@ def _check_audio(audio: Any, total: float,
|
||||
return errors
|
||||
|
||||
|
||||
#: Tope de la narración de un shot, el mismo que aplica shortsmith. Rechazarla
|
||||
#: aquí cuesta un reintento del modelo; rechazarla allí cuesta el render entero.
|
||||
MAX_NARRATION_CHARS = 320
|
||||
|
||||
|
||||
def _check_narration(narration: Any, path: str) -> list[str]:
|
||||
if narration is None:
|
||||
return []
|
||||
if not isinstance(narration, str):
|
||||
return [f"{path}.narration: se esperaba texto"]
|
||||
if len(narration) > MAX_NARRATION_CHARS:
|
||||
return [f"{path}.narration: {len(narration)} caracteres, el máximo es "
|
||||
f"{MAX_NARRATION_CHARS}"]
|
||||
return []
|
||||
|
||||
|
||||
def _total_duration(spec: dict) -> float:
|
||||
total = 0.0
|
||||
for shot in spec.get("shots") or []:
|
||||
@@ -275,9 +292,10 @@ def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
f"(las plantillas son: {known})")
|
||||
continue
|
||||
for key in shot:
|
||||
if key not in ("template", "duration", "props"):
|
||||
if key not in ("template", "duration", "props", "narration"):
|
||||
errors.append(f"{path}.{key}: campo no permitido "
|
||||
"(las válidas son: template, duration, props)")
|
||||
"(las válidas son: template, duration, props, narration)")
|
||||
errors.extend(_check_narration(shot.get("narration"), path))
|
||||
duration = shot.get("duration")
|
||||
if not isinstance(duration, (int, float)) or isinstance(duration, bool):
|
||||
errors.append(f"{path}.duration: obligatoria y numérica")
|
||||
@@ -303,6 +321,36 @@ def validate_spec(spec: Any, templates: dict[str, dict],
|
||||
raise SpecInvalid(errors)
|
||||
|
||||
|
||||
#: Caracteres por segundo de la voz (Piper `en_US-lessac-medium` a length_scale
|
||||
#: 1.0). Medido el 2026-08-06: 82 caracteres en 5.78 s. Sirve para ESTIMAR aquí
|
||||
#: lo que shortsmith sabrá exacto al sintetizar.
|
||||
NARRATION_CHARS_PER_SECOND = 14.2
|
||||
#: El respiro que shortsmith deja tras cada línea antes de permitir el corte.
|
||||
NARRATION_PAD = 0.45
|
||||
|
||||
|
||||
def estimated_duration(spec: dict) -> float:
|
||||
"""Lo que durará el vídeo, no lo que suman las duraciones declaradas.
|
||||
|
||||
Con narración, la duración declarada es un suelo: shortsmith estira el shot
|
||||
si la frase no cabe. Sin esta estimación el modelo escribiría 40 s de shots,
|
||||
les colgaría narración a todos y recibiría un Short de 55 s sin que nada le
|
||||
hubiera avisado — el aviso llegaría del render, cuando ya está pagado.
|
||||
"""
|
||||
total = 0.0
|
||||
for shot in spec.get("shots") or []:
|
||||
if not isinstance(shot, dict):
|
||||
continue
|
||||
declared = shot.get("duration")
|
||||
declared = float(declared) if isinstance(declared, (int, float)) else 0.0
|
||||
narration = shot.get("narration")
|
||||
if isinstance(narration, str) and narration.strip():
|
||||
spoken = len(narration.strip()) / NARRATION_CHARS_PER_SECOND + NARRATION_PAD
|
||||
declared = max(declared, spoken)
|
||||
total += declared
|
||||
return total
|
||||
|
||||
|
||||
def editorial_notes(spec: dict) -> list[str]:
|
||||
"""Lo que no viola el contrato pero sí el encargo.
|
||||
|
||||
@@ -311,15 +359,22 @@ def editorial_notes(spec: dict) -> list[str]:
|
||||
si insiste, se renderiza igual antes que tirar la generación a la basura.
|
||||
"""
|
||||
notes = []
|
||||
total = _total_duration(spec)
|
||||
declared = _total_duration(spec)
|
||||
total = estimated_duration(spec)
|
||||
stretched = total > declared + 0.5
|
||||
how = (f"la duración estimada son {total:.1f}s con la narración "
|
||||
f"({declared:.1f}s de shots)" if stretched
|
||||
else f"la duración total son {total:.1f}s")
|
||||
|
||||
if total < TARGET_MIN_DURATION:
|
||||
notes.append(f"la duración total son {total:.1f}s y el objetivo es "
|
||||
notes.append(f"{how} y el objetivo es "
|
||||
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
|
||||
"queda corto, añade un shot o alarga los que tienes")
|
||||
elif total > TARGET_MAX_DURATION:
|
||||
notes.append(f"la duración total son {total:.1f}s y el objetivo es "
|
||||
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: "
|
||||
"recorta shots o acorta duraciones")
|
||||
fix = ("recorta narración: la voz manda sobre la duración declarada"
|
||||
if stretched else "recorta shots o acorta duraciones")
|
||||
notes.append(f"{how} y el objetivo es "
|
||||
f"{TARGET_MIN_DURATION:.0f}-{TARGET_MAX_DURATION:.0f}s: {fix}")
|
||||
return notes
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user