RishiKar210's picture
Deploy backend incl. missing-dialogue detection + character creation
03090ce verified
Raw
History Blame Contribute Delete
3.5 kB
"""Shared data model + helpers for all script parsers."""
from __future__ import annotations
import re
from pydantic import BaseModel
# Bracketed/parenthetical tags and stage directions that are NOT part of the
# speaker's identity — a character voiced in different on-screen forms is still
# one performer/entity.
_TAG_RE = re.compile(r"\[.*?\]|\(.*?\)")
_NOISE_WORDS = ("on screen", "off screen", "voice over", "v.o", "o.s", "narration")
_SPLIT_RE = re.compile(r"\s*[/&+]\s*")
class ScriptSegment(BaseModel):
"""One scripted line: a timed span attributed to one or more characters."""
index: int
start_s: float
end_s: float
character_raw: str # label exactly as written, e.g. "Shoma [narration]"
characters: list[str] # canonical keys, e.g. ["shoma"] (a row may name >1)
text: str = ""
@property
def duration_s(self) -> float:
return max(0.0, self.end_s - self.start_s)
class ScriptDoc(BaseModel):
source_format: str # "docx" | "srt" | "csv"
fps: float | None = None # set when timecodes were frame-based (HH:MM:SS:FF)
segments: list[ScriptSegment]
def characters(self) -> set[str]:
return {c for s in self.segments for c in s.characters}
def normalize_character(raw: str) -> list[str]:
"""Map a raw character cell to one or more canonical keys.
'Shoma [narration]' -> ['shoma'] ; 'Amane/Shoma' -> ['amane', 'shoma'].
"""
c = _TAG_RE.sub(" ", raw.lower())
for w in _NOISE_WORDS:
c = c.replace(w, " ")
parts = _SPLIT_RE.split(c)
keys = []
for p in parts:
k = canonical_key(p)
if k and k not in keys:
keys.append(k)
return keys
def canonical_key(name: str) -> str:
"""Lowercase alphanumeric slug used as a character's stable identity key."""
return re.sub(r"[^a-z0-9]+", " ", name.lower()).strip().replace(" ", "_")
def parse_timecode(tc: str, fps: float | None = None) -> float:
"""Parse a timecode string into seconds. Accepts:
HH:MM:SS:FF (frame-based — needs fps)
HH:MM:SS,mmm / HH:MM:SS.mmm (milliseconds)
HH:MM:SS / MM:SS
a bare float (already seconds)
"""
tc = tc.strip()
if not tc:
raise ValueError("empty timecode")
# bare seconds
if re.fullmatch(r"\d+(\.\d+)?", tc):
return float(tc)
# HH:MM:SS,mmm or HH:MM:SS.mmm -> milliseconds in the last field
m = re.fullmatch(r"(\d{1,2}):(\d{2}):(\d{2})[.,](\d{1,3})", tc)
if m:
h, mi, s, ms = m.groups()
return int(h) * 3600 + int(mi) * 60 + int(s) + int(ms.ljust(3, "0")) / 1000.0
parts = re.split(r"[:;]", tc)
if len(parts) == 4: # HH:MM:SS:FF (frames)
h, mi, s, f = (int(p) for p in parts)
if not fps:
raise ValueError(f"frame timecode '{tc}' needs fps")
return h * 3600 + mi * 60 + s + f / fps
if len(parts) == 3: # HH:MM:SS
h, mi, s = (int(p) for p in parts)
return h * 3600 + mi * 60 + s
if len(parts) == 2: # MM:SS
mi, s = (int(p) for p in parts)
return mi * 60 + s
raise ValueError(f"unrecognised timecode '{tc}'")
def infer_fps_from_frames(max_frame: int) -> float:
"""Guess the frame rate from the largest frame field seen (FF in HH:MM:SS:FF)."""
for rate in (24, 25, 30, 48, 50, 60):
if max_frame < rate:
return float(rate)
return 30.0