legendaria 0.1.0.dev1__tar.gz → 0.1.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/PKG-INFO +1 -1
  2. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/__init__.py +1 -1
  3. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/cli.py +24 -0
  4. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/config.py +2 -0
  5. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/cues.py +48 -4
  6. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/defaults/config.json +11 -0
  7. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/schema.py +36 -0
  8. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/transcriber.py +11 -2
  9. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/pyproject.toml +1 -1
  10. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/schemas/config.schema.json +17 -1
  11. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/test_cli.py +16 -0
  12. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/test_cues.py +50 -0
  13. legendaria-0.1.0.dev3/tests/test_schema.py +74 -0
  14. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/uv.lock +1 -1
  15. legendaria-0.1.0.dev1/tests/test_schema.py +0 -35
  16. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/.gitignore +0 -0
  17. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/.python-version +0 -0
  18. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/LICENSE +0 -0
  19. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/README.md +0 -0
  20. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/__main__.py +0 -0
  21. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/cuda_libs.py +0 -0
  22. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/defaults/models.json +0 -0
  23. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/events.py +0 -0
  24. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/formats.py +0 -0
  25. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/models.py +0 -0
  26. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/paths.py +0 -0
  27. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/schemas/cues.schema.json +0 -0
  28. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/schemas/models.schema.json +0 -0
  29. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/schemas/transcript.schema.json +0 -0
  30. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/conftest.py +0 -0
  31. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/test_config.py +0 -0
  32. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/test_formats.py +0 -0
  33. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/test_transcriber.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: legendaria
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev3
4
4
  Summary: Legendária transcription engine: local Whisper transcription with word timings (CLI: flosub).
5
5
  Project-URL: Homepage, https://github.com/flou-ainan/Legendaria
6
6
  Project-URL: Issues, https://github.com/flou-ainan/Legendaria/issues
@@ -1,3 +1,3 @@
1
1
  """Legendária transcription engine."""
2
2
 
3
- __version__ = "0.1.0.dev1"
3
+ __version__ = "0.1.0.dev3"
@@ -44,6 +44,17 @@ def write_outputs(
44
44
  return written
45
45
 
46
46
 
47
+ def parse_clip(value: str) -> tuple[float, float]:
48
+ """`START,END` in seconds → (start, end); raises ValueError on bad input."""
49
+ try:
50
+ start, end = (float(v) for v in value.split(","))
51
+ except ValueError as exc:
52
+ raise ValueError(f"--clip must be START,END in seconds, got '{value}'") from exc
53
+ if start < 0 or end <= start:
54
+ raise ValueError(f"--clip end must be after start, got '{value}'")
55
+ return start, end
56
+
57
+
47
58
  def check_style(config: dict, style: str | None) -> str:
48
59
  """The style to use: `--style` if given, else the config default; must exist."""
49
60
  style = style or config["style"]
@@ -78,6 +89,10 @@ def cmd_transcribe(args: argparse.Namespace, reporter: Reporter) -> int:
78
89
  }
79
90
  )
80
91
  style = check_style(config, args.style)
92
+ clip = parse_clip(args.clip) if args.clip else None
93
+ if args.update and not clip:
94
+ reporter.error("--update needs --clip (the time range to replace)")
95
+ return 2
81
96
  if config["device"] != "cpu": # make pip-installed CUDA libraries loadable (may re-exec)
82
97
  from legendaria_engine.cuda_libs import ensure_cuda_libs
83
98
 
@@ -95,10 +110,14 @@ def cmd_transcribe(args: argparse.Namespace, reporter: Reporter) -> int:
95
110
  beam_size=config["beam_size"],
96
111
  vad_filter=config["vad_filter"],
97
112
  word_timestamps=config["word_timestamps"],
113
+ clip=clip,
98
114
  )
99
115
  transcript = get_backend(config.get("backend", "faster-whisper")).transcribe(
100
116
  media, options, reporter
101
117
  )
118
+ if args.update:
119
+ base = Transcript.load(Path(args.update).expanduser())
120
+ transcript = base.merged_with(transcript, *clip)
102
121
  out_dir = Path(args.output_dir).expanduser() if args.output_dir else media.parent
103
122
  outputs = write_outputs(transcript, out_dir / media.stem, config["formats"], config, style)
104
123
  reporter.emit(
@@ -180,6 +199,11 @@ def build_parser() -> argparse.ArgumentParser:
180
199
  "--style", help="cue style: subtitles (default), captions, or one from your config"
181
200
  )
182
201
  t.add_argument("--output-dir", help="where to write the files (default: next to the input)")
202
+ t.add_argument("--clip", help="transcribe only START,END (seconds from the media start)")
203
+ t.add_argument(
204
+ "--update",
205
+ help="with --clip: transcript JSON to update; segments in the range are replaced",
206
+ )
183
207
  t.set_defaults(func=cmd_transcribe)
184
208
 
185
209
  e = sub.add_parser("export", help="turn a transcript JSON into cues/SRT/VTT")
@@ -16,6 +16,8 @@ STYLE_NUMBERS = (
16
16
  "max_duration",
17
17
  "min_duration",
18
18
  "pause_break",
19
+ "join_short",
20
+ "fill_gaps",
19
21
  )
20
22
 
21
23
 
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  from dataclasses import asdict, dataclass, field
11
+ from itertools import pairwise
11
12
  from pathlib import Path
12
13
  from typing import Any
13
14
 
@@ -56,12 +57,30 @@ def _ends_with(word: Word, chars: str) -> bool:
56
57
  return bool(text) and bool(chars) and text[-1] in chars
57
58
 
58
59
 
60
+ def _is_attached(word: Word) -> bool:
61
+ """A piece Whisper glued to the previous word (no leading space): "-vindas", "'s"."""
62
+ return bool(word.word) and not word.word[0].isspace()
63
+
64
+
65
+ def _units(words: list[Word]) -> int:
66
+ """Readable words: attached pieces do not count ("bem" + "-vindas" is one)."""
67
+ return 1 + sum(1 for w in words[1:] if not _is_attached(w)) if words else 0
68
+
69
+
70
+ def _letters(words: list[Word]) -> int:
71
+ return sum(ch.isalnum() for ch in _join(words))
72
+
73
+
59
74
  def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "custom") -> CueList:
60
75
  """Chunk the transcript's words into cues that respect the style's limits.
61
76
 
62
- Style keys (all optional): max_words, max_chars, max_duration, min_duration,
63
- pause_break (seconds of silence that force a break), break_after (punctuation
64
- characters that end a cue), break_on_segment (start a new cue at each segment).
77
+ Style keys (all optional): max_words (readable words; attached pieces like "-vindas"
78
+ never split from their word), max_chars, max_duration, min_duration, pause_break
79
+ (seconds of silence that force a break), break_after (punctuation characters that
80
+ end a cue), break_on_segment (start a new cue at each segment), join_short (a single
81
+ word with at most this many letters, like "a", "de", "no", may take the next word
82
+ even beyond max_words), fill_gaps (extend a cue to the next one when the silence
83
+ between them is at most this many seconds, so the screen does not blink).
65
84
  """
66
85
  max_words = style.get("max_words")
67
86
  max_chars = style.get("max_chars")
@@ -69,6 +88,7 @@ def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "
69
88
  pause_break = style.get("pause_break")
70
89
  break_after = style.get("break_after", "")
71
90
  break_on_segment = style.get("break_on_segment", True)
91
+ join_short = style.get("join_short")
72
92
 
73
93
  cues: list[Cue] = []
74
94
  current: list[Word] = []
@@ -87,8 +107,22 @@ def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "
87
107
  if break_on_segment:
88
108
  flush()
89
109
  for word in segment.words:
110
+ if current and _is_attached(word):
111
+ current.append(word) # glued piece: never starts a new cue
112
+ if _ends_with(word, break_after):
113
+ flush()
114
+ continue
90
115
  if current:
91
- too_many = max_words is not None and len(current) + 1 > max_words
116
+ allowed = max_words
117
+ if (
118
+ allowed is not None
119
+ and join_short
120
+ and _units(current) == 1
121
+ and _letters(current) <= join_short
122
+ and not _ends_with(current[-1], break_after)
123
+ ):
124
+ allowed += 1 # short word ("de", "a") takes the next one
125
+ too_many = allowed is not None and _units(current) + 1 > allowed
92
126
  too_long = max_chars is not None and len(_join([*current, word])) > max_chars
93
127
  too_slow = max_duration is not None and word.end - current[0].start > max_duration
94
128
  paused = pause_break is not None and word.start - current[-1].end > pause_break
@@ -98,12 +132,22 @@ def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "
98
132
  if _ends_with(word, break_after):
99
133
  flush()
100
134
  flush()
135
+ _fill_gaps(cues, style.get("fill_gaps"))
101
136
  _apply_min_duration(cues, style.get("min_duration"))
102
137
  return CueList(
103
138
  style=style_name, source=transcript.source, language=transcript.language, cues=cues
104
139
  )
105
140
 
106
141
 
142
+ def _fill_gaps(cues: list[Cue], max_gap: float | None) -> None:
143
+ """Extend each cue to the next one's start when the silence between them is short."""
144
+ if not max_gap:
145
+ return
146
+ for cue, nxt in pairwise(cues):
147
+ if 0 < nxt.start - cue.end <= max_gap:
148
+ cue.end = nxt.start
149
+
150
+
107
151
  def _apply_min_duration(cues: list[Cue], min_duration: float | None) -> None:
108
152
  """Stretch cues shorter than `min_duration`, without overlapping the next cue."""
109
153
  if not min_duration:
@@ -13,6 +13,17 @@
13
13
  "word_timestamps": true,
14
14
  "style": "subtitles",
15
15
  "styles": {
16
+ "pop": {
17
+ "max_words": 1,
18
+ "join_short": 3,
19
+ "max_chars": 24,
20
+ "max_duration": 1.6,
21
+ "min_duration": 0.25,
22
+ "pause_break": 0.6,
23
+ "fill_gaps": 0.35,
24
+ "break_after": ".,!?;:…",
25
+ "break_on_segment": true
26
+ },
16
27
  "subtitles": {
17
28
  "max_chars": 84,
18
29
  "line_length": 42,
@@ -69,6 +69,42 @@ class Transcript:
69
69
  **fields, segments=segments, language_probability=data.get("language_probability")
70
70
  )
71
71
 
72
+ def merged_with(self, update: Transcript, start: float, end: float) -> Transcript:
73
+ """A copy of this transcript where [start, end] comes from `update`.
74
+
75
+ Works word by word, so a segment that crosses a boundary keeps only its words on
76
+ its own side: no word is lost or duplicated. A word (or a segment without word
77
+ timings) belongs to the range when its midpoint is inside. Used to regenerate
78
+ part of a video (plan 02, stage 2c).
79
+ """
80
+
81
+ def inside(a: float, b: float) -> bool:
82
+ return start <= (a + b) / 2 <= end
83
+
84
+ def part(segments: list[Segment], want_inside: bool) -> list[Segment]:
85
+ out = []
86
+ for seg in segments:
87
+ if not seg.words:
88
+ if inside(seg.start, seg.end) == want_inside:
89
+ out.append(seg)
90
+ continue
91
+ words = [w for w in seg.words if inside(w.start, w.end) == want_inside]
92
+ if words:
93
+ text = " ".join("".join(w.word for w in words).split())
94
+ out.append(Segment(words[0].start, words[-1].end, text, words))
95
+ return out
96
+
97
+ merged = part(self.segments, False) + part(update.segments, True)
98
+ return Transcript(
99
+ source=self.source,
100
+ language=self.language,
101
+ duration=self.duration,
102
+ model=update.model,
103
+ device=update.device,
104
+ segments=sorted(merged, key=lambda s: s.start),
105
+ language_probability=self.language_probability,
106
+ )
107
+
72
108
  def save(self, path: Path) -> None:
73
109
  """Write as pretty UTF-8 JSON."""
74
110
  path.write_text(json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
@@ -22,6 +22,7 @@ class Options:
22
22
  beam_size: int = 5
23
23
  vad_filter: bool = True
24
24
  word_timestamps: bool = True
25
+ clip: tuple[float, float] | None = None # transcribe only this time range (seconds)
25
26
 
26
27
 
27
28
  class Backend(Protocol):
@@ -75,12 +76,20 @@ class FasterWhisperBackend:
75
76
 
76
77
  compute_type = options.compute_type.get(device, "default")
77
78
  model = WhisperModel(str(options.model_path), device=device, compute_type=compute_type)
79
+ extra = {"clip_timestamps": list(options.clip)} if options.clip else {}
78
80
  segments_iter, info = model.transcribe(
79
81
  str(media),
80
82
  language=options.language,
81
83
  beam_size=options.beam_size,
82
84
  vad_filter=options.vad_filter,
83
85
  word_timestamps=options.word_timestamps,
86
+ **extra,
87
+ )
88
+ # Progress covers the clip when there is one (times stay absolute, from the media start).
89
+ offset, total = (
90
+ (options.clip[0], options.clip[1] - options.clip[0])
91
+ if options.clip
92
+ else (0.0, info.duration)
84
93
  )
85
94
  reporter.emit(
86
95
  "start",
@@ -110,8 +119,8 @@ class FasterWhisperBackend:
110
119
  words=words,
111
120
  )
112
121
  )
113
- reporter.progress(seg.end, info.duration)
114
- reporter.progress(info.duration, info.duration)
122
+ reporter.progress(max(0.0, seg.end - offset), total)
123
+ reporter.progress(total, total)
115
124
  return Transcript(
116
125
  source=media.name,
117
126
  language=info.language,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "legendaria"
3
- version = "0.1.0.dev1"
3
+ version = "0.1.0.dev3"
4
4
  description = "Legendária transcription engine: local Whisper transcription with word timings (CLI: flosub)."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -96,7 +96,7 @@
96
96
  },
97
97
  "styles": {
98
98
  "type": "object",
99
- "description": "Cue styles: how words are grouped into on-screen text. 'captions' = short CapCut-style phrases (Blender default); 'subtitles' = film-style lines (SRT/VTT default). Add your own.",
99
+ "description": "Cue styles: how words are grouped into on-screen text. 'pop' = one word at a time (two when the first is very short), for pop animations; 'captions' = short CapCut-style phrases (Blender default); 'subtitles' = film-style lines (SRT/VTT default). Add your own.",
100
100
  "additionalProperties": {
101
101
  "type": "object",
102
102
  "properties": {
@@ -155,6 +155,22 @@
155
155
  "break_on_segment": {
156
156
  "type": "boolean",
157
157
  "description": "Start a new cue at each Whisper segment."
158
+ },
159
+ "join_short": {
160
+ "type": [
161
+ "integer",
162
+ "null"
163
+ ],
164
+ "minimum": 1,
165
+ "description": "A single word with at most this many letters (\"a\", \"de\", \"no\") may take the next word, even beyond max_words."
166
+ },
167
+ "fill_gaps": {
168
+ "type": [
169
+ "number",
170
+ "null"
171
+ ],
172
+ "minimum": 0,
173
+ "description": "Extend a cue to the next one when the silence between them is at most this many seconds (avoids blinking)."
158
174
  }
159
175
  },
160
176
  "additionalProperties": false
@@ -87,3 +87,19 @@ def test_human_reporter_download_and_done(capsys):
87
87
  reporter.emit("done", outputs=["a.srt"])
88
88
  err = capsys.readouterr().err
89
89
  assert "50%" in err and "done" in err and "Saved: a.srt" in err
90
+
91
+
92
+ def test_clip_parsing():
93
+ import pytest
94
+
95
+ assert cli.parse_clip("1.5,3") == (1.5, 3.0)
96
+ for bad in ("3,1", "x,2", "5", "-1,2"):
97
+ with pytest.raises(ValueError):
98
+ cli.parse_clip(bad)
99
+
100
+
101
+ def test_update_needs_clip(tmp_path, capsys):
102
+ media = tmp_path / "a.mp3"
103
+ media.write_bytes(b"")
104
+ code, events, _ = run(["transcribe", str(media), "--update", "x.json"], capsys)
105
+ assert code == 2 and "--clip" in events[-1]["message"]
@@ -108,3 +108,53 @@ def test_attached_pieces_stay_glued():
108
108
  Word(0.5, 0.7, " Tom's"),
109
109
  ]
110
110
  assert texts(make_cues(transcript(segment(ws)), {})) == ["Descreve-se o Tom's"]
111
+
112
+
113
+ def test_attached_pieces_never_start_a_cue():
114
+ ws = [
115
+ Word(0, 0.3, " bem"),
116
+ Word(0.3, 0.6, "-vindas"),
117
+ Word(0.7, 1.0, " ao"),
118
+ Word(1.0, 1.3, " canal"),
119
+ ]
120
+ result = make_cues(transcript(segment(ws)), {"max_words": 1})
121
+ assert texts(result) == ["bem-vindas", "ao", "canal"]
122
+
123
+
124
+ def test_short_words_pair_with_the_next():
125
+ ws = words(
126
+ ("a", 0, 0.1),
127
+ ("bolsa", 0.1, 0.5),
128
+ ("de", 0.5, 0.6),
129
+ ("crochê", 0.6, 1.0),
130
+ ("linda", 1.0, 1.4),
131
+ )
132
+ result = make_cues(transcript(segment(ws)), {"max_words": 1, "join_short": 3})
133
+ assert texts(result) == ["a bolsa", "de crochê", "linda"]
134
+
135
+
136
+ def test_short_word_ending_a_sentence_does_not_pair():
137
+ ws = words(("é.", 0, 0.2), ("Agora", 0.3, 0.6))
138
+ result = make_cues(
139
+ transcript(segment(ws)), {"max_words": 1, "join_short": 3, "break_after": "."}
140
+ )
141
+ assert texts(result) == ["é.", "Agora"]
142
+
143
+
144
+ def test_fill_gaps_closes_short_silences_only():
145
+ ws = words(("um", 0.0, 0.2), ("dois", 0.4, 0.6), ("três", 2.0, 2.2))
146
+ result = make_cues(transcript(segment(ws)), {"max_words": 1, "fill_gaps": 0.35})
147
+ assert [(c.start, c.end) for c in result.cues] == [(0.0, 0.4), (0.4, 0.6), (2.0, 2.2)]
148
+
149
+
150
+ def test_pop_style_on_defaults():
151
+ config = load_config()
152
+ ws = words(
153
+ *[
154
+ (w, i * 0.3, i * 0.3 + 0.25)
155
+ for i, w in enumerate(["e", "hoje", "eu", "trouxe", "a", "bolsa"])
156
+ ]
157
+ )
158
+ result = make_cues(transcript(segment(ws)), config["styles"]["pop"], "pop")
159
+ assert texts(result) == ["e hoje", "eu trouxe", "a bolsa"]
160
+ assert all(len(c.text.split()) <= 2 for c in result.cues)
@@ -0,0 +1,74 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import jsonschema
5
+ import pytest
6
+
7
+ from legendaria_engine.schema import Transcript
8
+
9
+ SCHEMAS = Path(__file__).resolve().parents[1] / "schemas"
10
+
11
+
12
+ def test_round_trip(transcript, tmp_path):
13
+ path = tmp_path / "t.json"
14
+ transcript.save(path)
15
+ assert Transcript.load(path) == transcript
16
+
17
+
18
+ def test_matches_json_schema(transcript):
19
+ schema = json.loads((SCHEMAS / "transcript.schema.json").read_text(encoding="utf-8"))
20
+ jsonschema.validate(transcript.to_dict(), schema)
21
+
22
+
23
+ def test_rejects_other_versions(transcript):
24
+ data = transcript.to_dict() | {"version": 2}
25
+ with pytest.raises(ValueError, match="version"):
26
+ Transcript.from_dict(data)
27
+
28
+
29
+ def test_cues_match_json_schema(transcript):
30
+ from legendaria_engine.config import load_config
31
+ from legendaria_engine.cues import make_cues
32
+
33
+ schema = json.loads((SCHEMAS / "cues.schema.json").read_text(encoding="utf-8"))
34
+ cues = make_cues(transcript, load_config()["styles"]["captions"], "captions")
35
+ jsonschema.validate(cues.to_dict(), schema)
36
+
37
+
38
+ def test_merge_replaces_only_the_range():
39
+ from legendaria_engine.schema import Segment
40
+
41
+ base = Transcript(
42
+ "a.mp4",
43
+ "pt",
44
+ 30.0,
45
+ "small",
46
+ "cpu",
47
+ [Segment(0, 5, "a"), Segment(10, 12, "b"), Segment(20, 25, "c")],
48
+ )
49
+ update = Transcript(
50
+ "a.mp4", "pt", 30.0, "medium", "cuda", [Segment(9.5, 11, "B1"), Segment(11, 13, "B2")]
51
+ )
52
+ merged = base.merged_with(update, 8, 15)
53
+ assert [s.text for s in merged.segments] == ["a", "B1", "B2", "c"]
54
+ assert merged.model == "medium" and base.segments[1].text == "b"
55
+
56
+
57
+ def test_merge_splits_segments_crossing_the_boundary():
58
+ from legendaria_engine.schema import Segment, Word
59
+
60
+ old = Segment(
61
+ 8, 14, "one two three", [Word(8, 9, " one"), Word(10, 11, " two"), Word(13, 14, " three")]
62
+ )
63
+ base = Transcript("a.mp4", "pt", 30.0, "small", "cpu", [old])
64
+ new = Segment(
65
+ 9.5,
66
+ 16,
67
+ "TWO THREE four",
68
+ [Word(9.5, 11, " TWO"), Word(13, 14, " THREE"), Word(15, 16, " four")],
69
+ )
70
+ update = Transcript("a.mp4", "pt", 30.0, "tiny", "cpu", [new])
71
+ merged = base.merged_with(update, 10, 14.5)
72
+ assert [s.text for s in merged.segments] == ["one", "TWO THREE"]
73
+ words = [w.word.strip() for s in merged.segments for w in s.words]
74
+ assert words == ["one", "TWO", "THREE"] # nothing duplicated, "four" stays out
@@ -472,7 +472,7 @@ wheels = [
472
472
 
473
473
  [[package]]
474
474
  name = "legendaria"
475
- version = "0.1.0.dev1"
475
+ version = "0.1.0.dev3"
476
476
  source = { editable = "." }
477
477
  dependencies = [
478
478
  { name = "faster-whisper" },
@@ -1,35 +0,0 @@
1
- import json
2
- from pathlib import Path
3
-
4
- import jsonschema
5
- import pytest
6
-
7
- from legendaria_engine.schema import Transcript
8
-
9
- SCHEMAS = Path(__file__).resolve().parents[1] / "schemas"
10
-
11
-
12
- def test_round_trip(transcript, tmp_path):
13
- path = tmp_path / "t.json"
14
- transcript.save(path)
15
- assert Transcript.load(path) == transcript
16
-
17
-
18
- def test_matches_json_schema(transcript):
19
- schema = json.loads((SCHEMAS / "transcript.schema.json").read_text(encoding="utf-8"))
20
- jsonschema.validate(transcript.to_dict(), schema)
21
-
22
-
23
- def test_rejects_other_versions(transcript):
24
- data = transcript.to_dict() | {"version": 2}
25
- with pytest.raises(ValueError, match="version"):
26
- Transcript.from_dict(data)
27
-
28
-
29
- def test_cues_match_json_schema(transcript):
30
- from legendaria_engine.config import load_config
31
- from legendaria_engine.cues import make_cues
32
-
33
- schema = json.loads((SCHEMAS / "cues.schema.json").read_text(encoding="utf-8"))
34
- cues = make_cues(transcript, load_config()["styles"]["captions"], "captions")
35
- jsonschema.validate(cues.to_dict(), schema)
File without changes