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.
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/PKG-INFO +1 -1
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/__init__.py +1 -1
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/cli.py +24 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/schema.py +36 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/transcriber.py +11 -2
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/pyproject.toml +1 -1
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/test_cli.py +16 -0
- legendaria-0.1.0.dev2/tests/test_schema.py +74 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/uv.lock +1 -1
- legendaria-0.1.0.dev1/tests/test_schema.py +0 -35
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/.gitignore +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/.python-version +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/LICENSE +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/README.md +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/__main__.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/config.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/cuda_libs.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/cues.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/defaults/config.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/defaults/models.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/events.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/formats.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/models.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/legendaria_engine/paths.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/schemas/config.schema.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/schemas/cues.schema.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/schemas/models.schema.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/schemas/transcript.schema.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/conftest.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/test_config.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/test_cues.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev2}/tests/test_formats.py +0 -0
- {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.
|
|
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
|
|
@@ -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,
|
|
114
|
-
reporter.progress(
|
|
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,
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|