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.
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/PKG-INFO +1 -1
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/__init__.py +1 -1
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/cli.py +24 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/config.py +2 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/cues.py +48 -4
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/defaults/config.json +11 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/schema.py +36 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/transcriber.py +11 -2
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/pyproject.toml +1 -1
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/schemas/config.schema.json +17 -1
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/test_cli.py +16 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/test_cues.py +50 -0
- legendaria-0.1.0.dev3/tests/test_schema.py +74 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/uv.lock +1 -1
- legendaria-0.1.0.dev1/tests/test_schema.py +0 -35
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/.gitignore +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/.python-version +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/LICENSE +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/README.md +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/__main__.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/cuda_libs.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/defaults/models.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/events.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/formats.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/models.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/legendaria_engine/paths.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/schemas/cues.schema.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/schemas/models.schema.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/schemas/transcript.schema.json +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/conftest.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/test_config.py +0 -0
- {legendaria-0.1.0.dev1 → legendaria-0.1.0.dev3}/tests/test_formats.py +0 -0
- {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.
|
|
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
|
|
@@ -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")
|
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
@@ -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
|
|
@@ -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
|