legendaria 0.1.0.dev1__tar.gz → 0.1.0.dev2__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.dev2}/PKG-INFO +1 -1
  2. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/__init__.py +1 -1
  3. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/cli.py +24 -0
  4. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/schema.py +36 -0
  5. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/transcriber.py +11 -2
  6. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/pyproject.toml +1 -1
  7. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/test_cli.py +16 -0
  8. legendaria-0.1.0.dev2/tests/test_schema.py +74 -0
  9. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/uv.lock +1 -1
  10. legendaria-0.1.0.dev1/tests/test_schema.py +0 -35
  11. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/.gitignore +0 -0
  12. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/.python-version +0 -0
  13. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/LICENSE +0 -0
  14. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/README.md +0 -0
  15. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/__main__.py +0 -0
  16. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/config.py +0 -0
  17. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/cuda_libs.py +0 -0
  18. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/cues.py +0 -0
  19. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/defaults/config.json +0 -0
  20. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/defaults/models.json +0 -0
  21. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/events.py +0 -0
  22. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/formats.py +0 -0
  23. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/models.py +0 -0
  24. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/paths.py +0 -0
  25. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/schemas/config.schema.json +0 -0
  26. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/schemas/cues.schema.json +0 -0
  27. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/schemas/models.schema.json +0 -0
  28. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/schemas/transcript.schema.json +0 -0
  29. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/conftest.py +0 -0
  30. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/test_config.py +0 -0
  31. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/test_cues.py +0 -0
  32. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/test_formats.py +0 -0
  33. {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/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.dev2
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.dev2"
@@ -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")
@@ -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.dev2"
4
4
  description = "Legendária transcription engine: local Whisper transcription with word timings (CLI: flosub)."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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"]
@@ -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.dev2"
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