legendaria 0.1.0.dev0__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.dev0 → legendaria-0.1.0.dev2}/PKG-INFO +1 -1
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/__init__.py +1 -1
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/cli.py +70 -12
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/config.py +21 -0
- legendaria-0.1.0.dev2/legendaria_engine/cues.py +124 -0
- legendaria-0.1.0.dev2/legendaria_engine/defaults/config.json +45 -0
- legendaria-0.1.0.dev2/legendaria_engine/formats.py +37 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/schema.py +36 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/transcriber.py +11 -2
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/pyproject.toml +1 -1
- legendaria-0.1.0.dev2/schemas/config.schema.json +165 -0
- legendaria-0.1.0.dev2/schemas/cues.schema.json +83 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/tests/test_cli.py +34 -1
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/tests/test_config.py +22 -0
- legendaria-0.1.0.dev2/tests/test_cues.py +110 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/tests/test_formats.py +19 -4
- legendaria-0.1.0.dev2/tests/test_schema.py +74 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/uv.lock +1 -1
- legendaria-0.1.0.dev0/legendaria_engine/defaults/config.json +0 -16
- legendaria-0.1.0.dev0/legendaria_engine/formats.py +0 -37
- legendaria-0.1.0.dev0/schemas/config.schema.json +0 -30
- legendaria-0.1.0.dev0/tests/test_schema.py +0 -26
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/.gitignore +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/.python-version +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/LICENSE +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/README.md +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/__main__.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/cuda_libs.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/defaults/models.json +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/events.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/models.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/paths.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/schemas/models.schema.json +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/schemas/transcript.schema.json +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/tests/conftest.py +0 -0
- {legendaria-0.1.0.dev0 → 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
|
|
@@ -9,25 +9,60 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
from legendaria_engine import __version__, paths
|
|
11
11
|
from legendaria_engine.config import ConfigError, load_config
|
|
12
|
+
from legendaria_engine.cues import make_cues
|
|
12
13
|
from legendaria_engine.events import Reporter, make_reporter
|
|
13
14
|
from legendaria_engine.formats import FORMATS, WRITERS
|
|
14
15
|
from legendaria_engine.schema import Transcript
|
|
15
16
|
|
|
16
17
|
|
|
17
|
-
def
|
|
18
|
-
"""
|
|
18
|
+
def output_path(stem: Path, fmt: str) -> Path:
|
|
19
|
+
"""`<stem>.<fmt>`, or `<stem>.cues.json`; dots in the stem are kept (`a.b.mp4` → `a.b.srt`)."""
|
|
20
|
+
suffix = "cues.json" if fmt == "cues" else fmt
|
|
21
|
+
return stem.parent / f"{stem.name}.{suffix}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_outputs(
|
|
25
|
+
transcript: Transcript, stem: Path, formats: list[str], config: dict, style: str
|
|
26
|
+
) -> list[str]:
|
|
27
|
+
"""Write the requested formats next to `stem` (no extension); returns the paths.
|
|
28
|
+
|
|
29
|
+
`json` is the full transcript; `cues`, `srt` and `vtt` use the cues of `style`.
|
|
30
|
+
"""
|
|
19
31
|
stem.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
rules = config["styles"][style]
|
|
33
|
+
cues = make_cues(transcript, rules, style) if set(formats) - {"json"} else None
|
|
20
34
|
written = []
|
|
21
35
|
for fmt in formats:
|
|
22
|
-
target = stem
|
|
36
|
+
target = output_path(stem, fmt)
|
|
23
37
|
if fmt == "json":
|
|
24
38
|
transcript.save(target)
|
|
39
|
+
elif fmt == "cues":
|
|
40
|
+
cues.save(target)
|
|
25
41
|
else:
|
|
26
|
-
target.write_text(WRITERS[fmt](
|
|
42
|
+
target.write_text(WRITERS[fmt](cues, rules.get("line_length")), encoding="utf-8")
|
|
27
43
|
written.append(str(target))
|
|
28
44
|
return written
|
|
29
45
|
|
|
30
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
|
+
|
|
58
|
+
def check_style(config: dict, style: str | None) -> str:
|
|
59
|
+
"""The style to use: `--style` if given, else the config default; must exist."""
|
|
60
|
+
style = style or config["style"]
|
|
61
|
+
if style not in config["styles"]:
|
|
62
|
+
raise ConfigError(f"Unknown style '{style}'. Available: {', '.join(config['styles'])}")
|
|
63
|
+
return style
|
|
64
|
+
|
|
65
|
+
|
|
31
66
|
def parse_formats(value: str | None) -> list[str] | None:
|
|
32
67
|
"""argparse type: comma-separated format list, validated against FORMATS."""
|
|
33
68
|
if value is None:
|
|
@@ -53,6 +88,11 @@ def cmd_transcribe(args: argparse.Namespace, reporter: Reporter) -> int:
|
|
|
53
88
|
"formats": args.format,
|
|
54
89
|
}
|
|
55
90
|
)
|
|
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
|
|
56
96
|
if config["device"] != "cpu": # make pip-installed CUDA libraries loadable (may re-exec)
|
|
57
97
|
from legendaria_engine.cuda_libs import ensure_cuda_libs
|
|
58
98
|
|
|
@@ -70,12 +110,16 @@ def cmd_transcribe(args: argparse.Namespace, reporter: Reporter) -> int:
|
|
|
70
110
|
beam_size=config["beam_size"],
|
|
71
111
|
vad_filter=config["vad_filter"],
|
|
72
112
|
word_timestamps=config["word_timestamps"],
|
|
113
|
+
clip=clip,
|
|
73
114
|
)
|
|
74
115
|
transcript = get_backend(config.get("backend", "faster-whisper")).transcribe(
|
|
75
116
|
media, options, reporter
|
|
76
117
|
)
|
|
118
|
+
if args.update:
|
|
119
|
+
base = Transcript.load(Path(args.update).expanduser())
|
|
120
|
+
transcript = base.merged_with(transcript, *clip)
|
|
77
121
|
out_dir = Path(args.output_dir).expanduser() if args.output_dir else media.parent
|
|
78
|
-
outputs = write_outputs(transcript, out_dir / media.stem, config["formats"])
|
|
122
|
+
outputs = write_outputs(transcript, out_dir / media.stem, config["formats"], config, style)
|
|
79
123
|
reporter.emit(
|
|
80
124
|
"done", outputs=outputs, segments=len(transcript.segments), language=transcript.language
|
|
81
125
|
)
|
|
@@ -83,12 +127,15 @@ def cmd_transcribe(args: argparse.Namespace, reporter: Reporter) -> int:
|
|
|
83
127
|
|
|
84
128
|
|
|
85
129
|
def cmd_export(args: argparse.Namespace, reporter: Reporter) -> int:
|
|
86
|
-
"""`flosub export`:
|
|
130
|
+
"""`flosub export`: turn a transcript JSON into cues / SRT / VTT in a given style."""
|
|
87
131
|
source = Path(args.file).expanduser()
|
|
88
132
|
transcript = Transcript.load(source)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
133
|
+
config = load_config()
|
|
134
|
+
style = check_style(config, args.style)
|
|
135
|
+
default_stem = source.parent / source.name.removesuffix(".json")
|
|
136
|
+
stem = Path(args.output) if args.output else default_stem
|
|
137
|
+
outputs = write_outputs(transcript, stem, args.format, config, style)
|
|
138
|
+
reporter.emit("done", outputs=outputs, segments=len(transcript.segments), style=style)
|
|
92
139
|
return 0
|
|
93
140
|
|
|
94
141
|
|
|
@@ -147,13 +194,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
147
194
|
t.add_argument("--model", help="model name (see `flosub models list`) or a model folder")
|
|
148
195
|
t.add_argument("--language", help="language code (pt, en, ...) or 'auto'")
|
|
149
196
|
t.add_argument("--device", choices=["auto", "cuda", "cpu"])
|
|
150
|
-
t.add_argument("--format", type=parse_formats, help="comma-separated: json,srt,vtt")
|
|
197
|
+
t.add_argument("--format", type=parse_formats, help="comma-separated: json,cues,srt,vtt")
|
|
198
|
+
t.add_argument(
|
|
199
|
+
"--style", help="cue style: subtitles (default), captions, or one from your config"
|
|
200
|
+
)
|
|
151
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
|
+
)
|
|
152
207
|
t.set_defaults(func=cmd_transcribe)
|
|
153
208
|
|
|
154
|
-
e = sub.add_parser("export", help="
|
|
209
|
+
e = sub.add_parser("export", help="turn a transcript JSON into cues/SRT/VTT")
|
|
155
210
|
e.add_argument("file")
|
|
156
|
-
e.add_argument(
|
|
211
|
+
e.add_argument(
|
|
212
|
+
"--format", type=parse_formats, default=["srt"], help="comma-separated: cues,srt,vtt"
|
|
213
|
+
)
|
|
214
|
+
e.add_argument("--style", help="cue style: subtitles (default), captions, or your own")
|
|
157
215
|
e.add_argument("--output", help="output path without extension (default: next to the JSON)")
|
|
158
216
|
e.set_defaults(func=cmd_export)
|
|
159
217
|
|
|
@@ -9,6 +9,14 @@ from typing import Any
|
|
|
9
9
|
from legendaria_engine import paths
|
|
10
10
|
|
|
11
11
|
DEVICES = ("auto", "cuda", "cpu")
|
|
12
|
+
STYLE_NUMBERS = (
|
|
13
|
+
"max_words",
|
|
14
|
+
"max_chars",
|
|
15
|
+
"line_length",
|
|
16
|
+
"max_duration",
|
|
17
|
+
"min_duration",
|
|
18
|
+
"pause_break",
|
|
19
|
+
)
|
|
12
20
|
|
|
13
21
|
|
|
14
22
|
class ConfigError(ValueError):
|
|
@@ -62,6 +70,19 @@ def validate(config: dict[str, Any], source: str = "config") -> None:
|
|
|
62
70
|
unknown = [f for f in config["formats"] if f not in FORMATS]
|
|
63
71
|
if unknown:
|
|
64
72
|
raise ConfigError(f"{source}: unknown format(s) {unknown}; use {', '.join(FORMATS)}")
|
|
73
|
+
styles = config.get("styles")
|
|
74
|
+
if not isinstance(styles, dict) or not styles:
|
|
75
|
+
raise ConfigError(f"{source}: 'styles' must be an object with at least one style")
|
|
76
|
+
for name, rules in styles.items():
|
|
77
|
+
if not isinstance(rules, dict):
|
|
78
|
+
raise ConfigError(f"{source}: style '{name}' must be an object")
|
|
79
|
+
for key, value in rules.items():
|
|
80
|
+
if key in STYLE_NUMBERS and value is not None and not isinstance(value, (int, float)):
|
|
81
|
+
raise ConfigError(f"{source}: styles.{name}.{key} must be a number or null")
|
|
82
|
+
if config.get("style") not in styles:
|
|
83
|
+
raise ConfigError(
|
|
84
|
+
f"{source}: 'style' must be one of the defined styles: {', '.join(styles)}"
|
|
85
|
+
)
|
|
65
86
|
|
|
66
87
|
|
|
67
88
|
def load_config(overrides: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Cues: the timed text blocks shown on screen, built from word timings by a style.
|
|
2
|
+
|
|
3
|
+
`captions` gives short CapCut-style phrases; `subtitles` gives film-style lines.
|
|
4
|
+
Styles are plain dicts from the config's "styles" section (see defaults/config.json).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from legendaria_engine.schema import Transcript, Word
|
|
15
|
+
|
|
16
|
+
CUES_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Cue:
|
|
21
|
+
"""One block of text on screen, with the words it contains."""
|
|
22
|
+
|
|
23
|
+
start: float
|
|
24
|
+
end: float
|
|
25
|
+
text: str
|
|
26
|
+
words: list[Word] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CueList:
|
|
31
|
+
"""Cues for one media file in one style (cues JSON v1)."""
|
|
32
|
+
|
|
33
|
+
style: str
|
|
34
|
+
source: str
|
|
35
|
+
language: str | None
|
|
36
|
+
cues: list[Cue] = field(default_factory=list)
|
|
37
|
+
version: int = CUES_VERSION
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
"""Plain dict ready for JSON."""
|
|
41
|
+
return asdict(self)
|
|
42
|
+
|
|
43
|
+
def save(self, path: Path) -> None:
|
|
44
|
+
"""Write as pretty UTF-8 JSON."""
|
|
45
|
+
path.write_text(json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _join(words: list[Word]) -> str:
|
|
49
|
+
"""Concatenate words as Whisper spells them: a leading space marks a new word,
|
|
50
|
+
so attached pieces such as "-se" or "'s" stay glued to the previous word."""
|
|
51
|
+
return " ".join("".join(w.word for w in words).split())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ends_with(word: Word, chars: str) -> bool:
|
|
55
|
+
text = word.word.strip()
|
|
56
|
+
return bool(text) and bool(chars) and text[-1] in chars
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "custom") -> CueList:
|
|
60
|
+
"""Chunk the transcript's words into cues that respect the style's limits.
|
|
61
|
+
|
|
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).
|
|
65
|
+
"""
|
|
66
|
+
max_words = style.get("max_words")
|
|
67
|
+
max_chars = style.get("max_chars")
|
|
68
|
+
max_duration = style.get("max_duration")
|
|
69
|
+
pause_break = style.get("pause_break")
|
|
70
|
+
break_after = style.get("break_after", "")
|
|
71
|
+
break_on_segment = style.get("break_on_segment", True)
|
|
72
|
+
|
|
73
|
+
cues: list[Cue] = []
|
|
74
|
+
current: list[Word] = []
|
|
75
|
+
|
|
76
|
+
def flush() -> None:
|
|
77
|
+
if current:
|
|
78
|
+
cues.append(Cue(current[0].start, current[-1].end, _join(current), list(current)))
|
|
79
|
+
current.clear()
|
|
80
|
+
|
|
81
|
+
for segment in transcript.segments:
|
|
82
|
+
if not segment.words: # no word timings: keep the whole segment as one cue
|
|
83
|
+
flush()
|
|
84
|
+
if segment.text.strip():
|
|
85
|
+
cues.append(Cue(segment.start, segment.end, segment.text.strip()))
|
|
86
|
+
continue
|
|
87
|
+
if break_on_segment:
|
|
88
|
+
flush()
|
|
89
|
+
for word in segment.words:
|
|
90
|
+
if current:
|
|
91
|
+
too_many = max_words is not None and len(current) + 1 > max_words
|
|
92
|
+
too_long = max_chars is not None and len(_join([*current, word])) > max_chars
|
|
93
|
+
too_slow = max_duration is not None and word.end - current[0].start > max_duration
|
|
94
|
+
paused = pause_break is not None and word.start - current[-1].end > pause_break
|
|
95
|
+
if too_many or too_long or too_slow or paused:
|
|
96
|
+
flush()
|
|
97
|
+
current.append(word)
|
|
98
|
+
if _ends_with(word, break_after):
|
|
99
|
+
flush()
|
|
100
|
+
flush()
|
|
101
|
+
_apply_min_duration(cues, style.get("min_duration"))
|
|
102
|
+
return CueList(
|
|
103
|
+
style=style_name, source=transcript.source, language=transcript.language, cues=cues
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _apply_min_duration(cues: list[Cue], min_duration: float | None) -> None:
|
|
108
|
+
"""Stretch cues shorter than `min_duration`, without overlapping the next cue."""
|
|
109
|
+
if not min_duration:
|
|
110
|
+
return
|
|
111
|
+
for i, cue in enumerate(cues):
|
|
112
|
+
if cue.end - cue.start < min_duration:
|
|
113
|
+
limit = cues[i + 1].start if i + 1 < len(cues) else cue.start + min_duration
|
|
114
|
+
cue.end = round(max(cue.end, min(cue.start + min_duration, limit)), 3)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def wrap_lines(text: str, line_length: int | None) -> str:
|
|
118
|
+
"""Split text into two balanced lines when it is longer than `line_length`."""
|
|
119
|
+
if not line_length or len(text) <= line_length or " " not in text:
|
|
120
|
+
return text
|
|
121
|
+
middle = len(text) // 2
|
|
122
|
+
spaces = [i for i, ch in enumerate(text) if ch == " "]
|
|
123
|
+
split = min(spaces, key=lambda i: abs(i - middle))
|
|
124
|
+
return text[:split] + "\n" + text[split + 1 :]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/flou-ainan/Legendaria/main/engine/schemas/config.schema.json",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"model": "small",
|
|
5
|
+
"language": "auto",
|
|
6
|
+
"device": "auto",
|
|
7
|
+
"compute_type": {
|
|
8
|
+
"cuda": "float16",
|
|
9
|
+
"cpu": "int8"
|
|
10
|
+
},
|
|
11
|
+
"beam_size": 5,
|
|
12
|
+
"vad_filter": true,
|
|
13
|
+
"word_timestamps": true,
|
|
14
|
+
"style": "subtitles",
|
|
15
|
+
"styles": {
|
|
16
|
+
"subtitles": {
|
|
17
|
+
"max_chars": 84,
|
|
18
|
+
"line_length": 42,
|
|
19
|
+
"max_duration": 6.0,
|
|
20
|
+
"min_duration": 1.0,
|
|
21
|
+
"pause_break": 1.0,
|
|
22
|
+
"break_after": ".!?…",
|
|
23
|
+
"break_on_segment": true
|
|
24
|
+
},
|
|
25
|
+
"captions": {
|
|
26
|
+
"max_words": 4,
|
|
27
|
+
"max_chars": 22,
|
|
28
|
+
"max_duration": 2.2,
|
|
29
|
+
"min_duration": 0.3,
|
|
30
|
+
"pause_break": 0.5,
|
|
31
|
+
"break_after": ".,!?;:…",
|
|
32
|
+
"break_on_segment": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"formats": [
|
|
36
|
+
"json",
|
|
37
|
+
"srt",
|
|
38
|
+
"vtt"
|
|
39
|
+
],
|
|
40
|
+
"ui_languages": [
|
|
41
|
+
"auto",
|
|
42
|
+
"pt",
|
|
43
|
+
"en"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Subtitle writers: cues → SRT / VTT text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from legendaria_engine.cues import CueList, wrap_lines
|
|
6
|
+
|
|
7
|
+
FORMATS = ("json", "cues", "srt", "vtt")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_timestamp(seconds: float, separator: str = ",") -> str:
|
|
11
|
+
"""`HH:MM:SS,mmm` (SRT) or `HH:MM:SS.mmm` (VTT). Hours keep counting past 24."""
|
|
12
|
+
total_ms = max(0, round(seconds * 1000))
|
|
13
|
+
hours, rest = divmod(total_ms, 3_600_000)
|
|
14
|
+
minutes, rest = divmod(rest, 60_000)
|
|
15
|
+
secs, ms = divmod(rest, 1000)
|
|
16
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}{separator}{ms:03d}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_srt(cues: CueList, line_length: int | None = None) -> str:
|
|
20
|
+
"""Numbered SRT blocks, one per cue; long cues wrap into two lines."""
|
|
21
|
+
blocks = []
|
|
22
|
+
for index, cue in enumerate(cues.cues, start=1):
|
|
23
|
+
start, end = format_timestamp(cue.start), format_timestamp(cue.end)
|
|
24
|
+
blocks.append(f"{index}\n{start} --> {end}\n{wrap_lines(cue.text, line_length)}\n")
|
|
25
|
+
return "\n".join(blocks)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_vtt(cues: CueList, line_length: int | None = None) -> str:
|
|
29
|
+
"""WebVTT blocks, one per cue; long cues wrap into two lines."""
|
|
30
|
+
blocks = ["WEBVTT\n"]
|
|
31
|
+
for cue in cues.cues:
|
|
32
|
+
start, end = format_timestamp(cue.start, "."), format_timestamp(cue.end, ".")
|
|
33
|
+
blocks.append(f"{start} --> {end}\n{wrap_lines(cue.text, line_length)}\n")
|
|
34
|
+
return "\n".join(blocks)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
WRITERS = {"srt": to_srt, "vtt": to_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,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/flou-ainan/Legendaria/main/engine/schemas/config.schema.json",
|
|
4
|
+
"title": "Legendária engine config",
|
|
5
|
+
"description": "Engine settings. Put only the keys you want to change in your user config.json (see `flosub config path`).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"$schema": {
|
|
9
|
+
"type": "string"
|
|
10
|
+
},
|
|
11
|
+
"version": {
|
|
12
|
+
"const": 1,
|
|
13
|
+
"description": "Config format version."
|
|
14
|
+
},
|
|
15
|
+
"model": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Model name from models.json (e.g. small, medium, large-v3-turbo) or a path to a CTranslate2 model folder."
|
|
18
|
+
},
|
|
19
|
+
"language": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "Speech language code (pt, en, es, ...) or 'auto' to detect it."
|
|
22
|
+
},
|
|
23
|
+
"device": {
|
|
24
|
+
"enum": [
|
|
25
|
+
"auto",
|
|
26
|
+
"cuda",
|
|
27
|
+
"cpu"
|
|
28
|
+
],
|
|
29
|
+
"description": "'auto' uses an NVIDIA GPU when available, otherwise the CPU."
|
|
30
|
+
},
|
|
31
|
+
"compute_type": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"description": "CTranslate2 compute type per device.",
|
|
34
|
+
"properties": {
|
|
35
|
+
"cuda": {
|
|
36
|
+
"enum": [
|
|
37
|
+
"float16",
|
|
38
|
+
"int8_float16",
|
|
39
|
+
"int8",
|
|
40
|
+
"float32",
|
|
41
|
+
"bfloat16"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"cpu": {
|
|
45
|
+
"enum": [
|
|
46
|
+
"int8",
|
|
47
|
+
"int8_float32",
|
|
48
|
+
"float32"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"additionalProperties": false
|
|
53
|
+
},
|
|
54
|
+
"beam_size": {
|
|
55
|
+
"type": "integer",
|
|
56
|
+
"minimum": 1,
|
|
57
|
+
"maximum": 10,
|
|
58
|
+
"description": "Beam search width: higher is slightly more accurate and slower."
|
|
59
|
+
},
|
|
60
|
+
"vad_filter": {
|
|
61
|
+
"type": "boolean",
|
|
62
|
+
"description": "Skip silent parts with voice activity detection (reduces made-up text)."
|
|
63
|
+
},
|
|
64
|
+
"word_timestamps": {
|
|
65
|
+
"type": "boolean",
|
|
66
|
+
"description": "Record the timing of every word (needed for CapCut-style captions)."
|
|
67
|
+
},
|
|
68
|
+
"formats": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"items": {
|
|
71
|
+
"enum": [
|
|
72
|
+
"json",
|
|
73
|
+
"srt",
|
|
74
|
+
"vtt"
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
"uniqueItems": true,
|
|
78
|
+
"description": "Files written by `flosub transcribe`."
|
|
79
|
+
},
|
|
80
|
+
"ui_languages": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"items": {
|
|
83
|
+
"type": "string"
|
|
84
|
+
},
|
|
85
|
+
"description": "Languages offered in the Blender add-on's language menu."
|
|
86
|
+
},
|
|
87
|
+
"backend": {
|
|
88
|
+
"enum": [
|
|
89
|
+
"faster-whisper"
|
|
90
|
+
],
|
|
91
|
+
"description": "Transcription backend."
|
|
92
|
+
},
|
|
93
|
+
"style": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "Style used by `flosub transcribe`/`export` when --style is not given (a key of 'styles')."
|
|
96
|
+
},
|
|
97
|
+
"styles": {
|
|
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.",
|
|
100
|
+
"additionalProperties": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"max_words": {
|
|
104
|
+
"type": [
|
|
105
|
+
"integer",
|
|
106
|
+
"null"
|
|
107
|
+
],
|
|
108
|
+
"minimum": 1,
|
|
109
|
+
"description": "Most words per cue."
|
|
110
|
+
},
|
|
111
|
+
"max_chars": {
|
|
112
|
+
"type": [
|
|
113
|
+
"integer",
|
|
114
|
+
"null"
|
|
115
|
+
],
|
|
116
|
+
"minimum": 1,
|
|
117
|
+
"description": "Most characters per cue."
|
|
118
|
+
},
|
|
119
|
+
"line_length": {
|
|
120
|
+
"type": [
|
|
121
|
+
"integer",
|
|
122
|
+
"null"
|
|
123
|
+
],
|
|
124
|
+
"minimum": 1,
|
|
125
|
+
"description": "SRT/VTT: wrap cues longer than this into two lines."
|
|
126
|
+
},
|
|
127
|
+
"max_duration": {
|
|
128
|
+
"type": [
|
|
129
|
+
"number",
|
|
130
|
+
"null"
|
|
131
|
+
],
|
|
132
|
+
"exclusiveMinimum": 0,
|
|
133
|
+
"description": "Longest time (s) a cue may cover."
|
|
134
|
+
},
|
|
135
|
+
"min_duration": {
|
|
136
|
+
"type": [
|
|
137
|
+
"number",
|
|
138
|
+
"null"
|
|
139
|
+
],
|
|
140
|
+
"minimum": 0,
|
|
141
|
+
"description": "Shortest time (s) a cue stays on screen (stretched without overlapping the next)."
|
|
142
|
+
},
|
|
143
|
+
"pause_break": {
|
|
144
|
+
"type": [
|
|
145
|
+
"number",
|
|
146
|
+
"null"
|
|
147
|
+
],
|
|
148
|
+
"minimum": 0,
|
|
149
|
+
"description": "A silence longer than this (s) starts a new cue."
|
|
150
|
+
},
|
|
151
|
+
"break_after": {
|
|
152
|
+
"type": "string",
|
|
153
|
+
"description": "Punctuation characters that end a cue."
|
|
154
|
+
},
|
|
155
|
+
"break_on_segment": {
|
|
156
|
+
"type": "boolean",
|
|
157
|
+
"description": "Start a new cue at each Whisper segment."
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"additionalProperties": false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
"additionalProperties": false
|
|
165
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/flou-ainan/Legendaria/main/engine/schemas/cues.schema.json",
|
|
4
|
+
"title": "Legendária cues (v1)",
|
|
5
|
+
"description": "Output of `flosub ... --format cues` (<name>.cues.json): on-screen text blocks in one style, read by the Blender add-on. Times in seconds.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"version",
|
|
9
|
+
"style",
|
|
10
|
+
"source",
|
|
11
|
+
"language",
|
|
12
|
+
"cues"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"version": {
|
|
16
|
+
"const": 1
|
|
17
|
+
},
|
|
18
|
+
"style": {
|
|
19
|
+
"type": "string"
|
|
20
|
+
},
|
|
21
|
+
"source": {
|
|
22
|
+
"type": "string"
|
|
23
|
+
},
|
|
24
|
+
"language": {
|
|
25
|
+
"type": [
|
|
26
|
+
"string",
|
|
27
|
+
"null"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"cues": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"items": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"required": [
|
|
35
|
+
"start",
|
|
36
|
+
"end",
|
|
37
|
+
"text"
|
|
38
|
+
],
|
|
39
|
+
"properties": {
|
|
40
|
+
"start": {
|
|
41
|
+
"type": "number",
|
|
42
|
+
"minimum": 0
|
|
43
|
+
},
|
|
44
|
+
"end": {
|
|
45
|
+
"type": "number",
|
|
46
|
+
"minimum": 0
|
|
47
|
+
},
|
|
48
|
+
"text": {
|
|
49
|
+
"type": "string"
|
|
50
|
+
},
|
|
51
|
+
"words": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"items": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"required": [
|
|
56
|
+
"start",
|
|
57
|
+
"end",
|
|
58
|
+
"word"
|
|
59
|
+
],
|
|
60
|
+
"properties": {
|
|
61
|
+
"start": {
|
|
62
|
+
"type": "number"
|
|
63
|
+
},
|
|
64
|
+
"end": {
|
|
65
|
+
"type": "number"
|
|
66
|
+
},
|
|
67
|
+
"word": {
|
|
68
|
+
"type": "string"
|
|
69
|
+
},
|
|
70
|
+
"probability": {
|
|
71
|
+
"type": "number"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"additionalProperties": false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"additionalProperties": false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"additionalProperties": false
|
|
83
|
+
}
|
|
@@ -13,11 +13,28 @@ def run(args, capsys):
|
|
|
13
13
|
def test_export_writes_srt_and_vtt(transcript, tmp_path, capsys):
|
|
14
14
|
source = tmp_path / "clip.json"
|
|
15
15
|
transcript.save(source)
|
|
16
|
-
code, events, _ = run(["export", str(source), "--format", "srt,vtt"], capsys)
|
|
16
|
+
code, events, _ = run(["export", str(source), "--format", "srt,vtt,cues"], capsys)
|
|
17
17
|
assert code == 0
|
|
18
18
|
assert events[-1]["event"] == "done"
|
|
19
19
|
assert (tmp_path / "clip.srt").read_text(encoding="utf-8").startswith("1\n00:00:00,000")
|
|
20
20
|
assert (tmp_path / "clip.vtt").read_text(encoding="utf-8").startswith("WEBVTT")
|
|
21
|
+
cues = json.loads((tmp_path / "clip.cues.json").read_text(encoding="utf-8"))
|
|
22
|
+
assert cues["style"] == "subtitles" and cues["version"] == 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_export_captions_style_and_dotted_names(transcript, tmp_path, capsys):
|
|
26
|
+
source = tmp_path / "aula.parte1.json"
|
|
27
|
+
transcript.save(source)
|
|
28
|
+
code, events, _ = run(["export", str(source), "--format", "srt", "--style", "captions"], capsys)
|
|
29
|
+
assert code == 0 and events[-1]["style"] == "captions"
|
|
30
|
+
assert (tmp_path / "aula.parte1.srt").is_file() # the ".parte1" part is kept
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_unknown_style_is_an_error(transcript, tmp_path, capsys):
|
|
34
|
+
source = tmp_path / "clip.json"
|
|
35
|
+
transcript.save(source)
|
|
36
|
+
code, events, _ = run(["export", str(source), "--style", "nope"], capsys)
|
|
37
|
+
assert code == 1 and "Unknown style" in events[-1]["message"]
|
|
21
38
|
|
|
22
39
|
|
|
23
40
|
def test_transcribe_missing_file_is_an_error(tmp_path, capsys):
|
|
@@ -70,3 +87,19 @@ def test_human_reporter_download_and_done(capsys):
|
|
|
70
87
|
reporter.emit("done", outputs=["a.srt"])
|
|
71
88
|
err = capsys.readouterr().err
|
|
72
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"]
|
|
@@ -69,6 +69,28 @@ def test_user_models_extend_catalog():
|
|
|
69
69
|
assert "small" in catalog
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
def test_user_can_tune_and_add_styles():
|
|
73
|
+
write_user(
|
|
74
|
+
"config.json", {"styles": {"captions": {"max_words": 2}, "karaoke": {"max_words": 1}}}
|
|
75
|
+
)
|
|
76
|
+
config = load_config()
|
|
77
|
+
assert config["styles"]["captions"]["max_words"] == 2
|
|
78
|
+
assert config["styles"]["captions"]["max_chars"] == 22 # rest of the default kept
|
|
79
|
+
assert "karaoke" in config["styles"]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_bad_style_values_are_rejected():
|
|
83
|
+
write_user("config.json", {"styles": {"captions": {"max_words": "four"}}})
|
|
84
|
+
with pytest.raises(ConfigError, match=r"styles\.captions\.max_words"):
|
|
85
|
+
load_config()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_default_style_must_exist():
|
|
89
|
+
write_user("config.json", {"style": "nope"})
|
|
90
|
+
with pytest.raises(ConfigError, match="'style'"):
|
|
91
|
+
load_config()
|
|
92
|
+
|
|
93
|
+
|
|
72
94
|
def test_deep_merge_does_not_mutate():
|
|
73
95
|
base = {"a": {"b": 1}}
|
|
74
96
|
deep_merge(base, {"a": {"c": 2}})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from legendaria_engine.config import load_config
|
|
4
|
+
from legendaria_engine.cues import make_cues, wrap_lines
|
|
5
|
+
from legendaria_engine.schema import Segment, Transcript, Word
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def words(*items):
|
|
9
|
+
"""items: (text, start, end)"""
|
|
10
|
+
return [Word(start=s, end=e, word=f" {t}") for t, s, e in items]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def transcript(*segments):
|
|
14
|
+
return Transcript(
|
|
15
|
+
source="a.mp4",
|
|
16
|
+
language="pt",
|
|
17
|
+
duration=60,
|
|
18
|
+
model="small",
|
|
19
|
+
device="cpu",
|
|
20
|
+
segments=list(segments),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def segment(ws):
|
|
25
|
+
return Segment(ws[0].start, ws[-1].end, " ".join(w.word.strip() for w in ws), ws)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def texts(cue_list):
|
|
29
|
+
return [c.text for c in cue_list.cues]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_max_words():
|
|
33
|
+
ws = words(*[(f"w{i}", i * 0.2, i * 0.2 + 0.15) for i in range(10)])
|
|
34
|
+
result = make_cues(transcript(segment(ws)), {"max_words": 4}, "captions")
|
|
35
|
+
assert texts(result) == ["w0 w1 w2 w3", "w4 w5 w6 w7", "w8 w9"]
|
|
36
|
+
assert result.style == "captions"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_max_chars_and_duration():
|
|
40
|
+
ws = words(("alpha", 0, 0.5), ("beta", 0.5, 1.0), ("gamma", 1.0, 1.5), ("delta", 3.0, 3.5))
|
|
41
|
+
assert texts(make_cues(transcript(segment(ws)), {"max_chars": 11})) == [
|
|
42
|
+
"alpha beta",
|
|
43
|
+
"gamma delta",
|
|
44
|
+
]
|
|
45
|
+
assert texts(make_cues(transcript(segment(ws)), {"max_duration": 1.2})) == [
|
|
46
|
+
"alpha beta",
|
|
47
|
+
"gamma",
|
|
48
|
+
"delta",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_breaks_after_punctuation_and_on_pauses():
|
|
53
|
+
ws = words(("Oi,", 0, 0.3), ("tudo", 0.35, 0.6), ("bem?", 0.6, 0.9), ("Sim", 2.0, 2.3))
|
|
54
|
+
result = make_cues(transcript(segment(ws)), {"break_after": ",?", "pause_break": 0.5})
|
|
55
|
+
assert texts(result) == ["Oi,", "tudo bem?", "Sim"]
|
|
56
|
+
ws2 = words(("um", 0, 0.2), ("dois", 1.5, 1.7))
|
|
57
|
+
assert texts(make_cues(transcript(segment(ws2)), {"pause_break": 0.5})) == ["um", "dois"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_segments_start_new_cues_unless_disabled():
|
|
61
|
+
t = transcript(segment(words(("a", 0, 0.1))), segment(words(("b", 0.2, 0.3))))
|
|
62
|
+
assert texts(make_cues(t, {})) == ["a", "b"]
|
|
63
|
+
assert texts(make_cues(t, {"break_on_segment": False})) == ["a b"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_min_duration_stretches_without_overlap():
|
|
67
|
+
ws = words(("a", 0.0, 0.1), ("b", 0.25, 0.3), ("c", 2.0, 2.05))
|
|
68
|
+
result = make_cues(transcript(segment(ws)), {"max_words": 1, "min_duration": 0.5})
|
|
69
|
+
assert [(c.start, c.end) for c in result.cues] == [(0.0, 0.25), (0.25, 0.75), (2.0, 2.5)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_segment_without_words_is_kept_whole():
|
|
73
|
+
t = transcript(Segment(0, 3, " Sem palavras aqui.", []))
|
|
74
|
+
result = make_cues(t, {"max_words": 1})
|
|
75
|
+
assert texts(result) == ["Sem palavras aqui."]
|
|
76
|
+
assert result.cues[0].words == []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_default_styles_produce_short_captions_and_long_subtitles():
|
|
80
|
+
config = load_config()
|
|
81
|
+
ws = words(*[(f"palavra{i}", i * 0.3, i * 0.3 + 0.25) for i in range(12)])
|
|
82
|
+
t = transcript(segment(ws))
|
|
83
|
+
captions = make_cues(t, config["styles"]["captions"], "captions")
|
|
84
|
+
subtitles = make_cues(t, config["styles"]["subtitles"], "subtitles")
|
|
85
|
+
assert all(len(c.words) <= 4 and len(c.text) <= 22 for c in captions.cues)
|
|
86
|
+
assert all(c.end - c.start <= 2.2 + 1e-9 for c in captions.cues)
|
|
87
|
+
assert len(subtitles.cues) < len(captions.cues)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize(
|
|
91
|
+
("text", "length", "expected"),
|
|
92
|
+
[
|
|
93
|
+
("short", 42, "short"),
|
|
94
|
+
("one two three four", 10, "one two\nthree four"),
|
|
95
|
+
("nospaceshere", 4, "nospaceshere"),
|
|
96
|
+
("any", None, "any"),
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
def test_wrap_lines(text, length, expected):
|
|
100
|
+
assert wrap_lines(text, length) == expected
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_attached_pieces_stay_glued():
|
|
104
|
+
ws = [
|
|
105
|
+
Word(0, 0.2, " Descreve"),
|
|
106
|
+
Word(0.2, 0.3, "-se"),
|
|
107
|
+
Word(0.3, 0.5, " o"),
|
|
108
|
+
Word(0.5, 0.7, " Tom's"),
|
|
109
|
+
]
|
|
110
|
+
assert texts(make_cues(transcript(segment(ws)), {})) == ["Descreve-se o Tom's"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
|
+
from legendaria_engine.cues import Cue, CueList
|
|
3
4
|
from legendaria_engine.formats import format_timestamp, to_srt, to_vtt
|
|
4
5
|
|
|
5
6
|
|
|
@@ -23,14 +24,28 @@ def test_format_timestamp_vtt_separator():
|
|
|
23
24
|
assert format_timestamp(1.25, ".") == "00:00:01.250"
|
|
24
25
|
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def cues():
|
|
29
|
+
return CueList(
|
|
30
|
+
style="subtitles",
|
|
31
|
+
source="clip.mp4",
|
|
32
|
+
language="pt",
|
|
33
|
+
cues=[Cue(0.0, 2.5, "Olá, este é um teste."), Cue(3723.0, 3725.5, "Hello again.")],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_to_srt(cues):
|
|
38
|
+
assert to_srt(cues) == (
|
|
28
39
|
"1\n00:00:00,000 --> 00:00:02,500\nOlá, este é um teste.\n\n"
|
|
29
40
|
"2\n01:02:03,000 --> 01:02:05,500\nHello again.\n"
|
|
30
41
|
)
|
|
31
42
|
|
|
32
43
|
|
|
33
|
-
def test_to_vtt(
|
|
34
|
-
text = to_vtt(
|
|
44
|
+
def test_to_vtt(cues):
|
|
45
|
+
text = to_vtt(cues)
|
|
35
46
|
assert text.startswith("WEBVTT\n\n00:00:00.000 --> 00:00:02.500\nOlá, este é um teste.\n")
|
|
36
47
|
assert "01:02:03.000 --> 01:02:05.500\nHello again.\n" in text
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_long_cues_wrap_into_two_lines(cues):
|
|
51
|
+
assert "Olá, este\né um teste." in to_srt(cues, line_length=12)
|
|
@@ -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,16 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://raw.githubusercontent.com/flou-ainan/Legendaria/main/engine/schemas/config.schema.json",
|
|
3
|
-
"version": 1,
|
|
4
|
-
"model": "small",
|
|
5
|
-
"language": "auto",
|
|
6
|
-
"device": "auto",
|
|
7
|
-
"compute_type": {
|
|
8
|
-
"cuda": "float16",
|
|
9
|
-
"cpu": "int8"
|
|
10
|
-
},
|
|
11
|
-
"beam_size": 5,
|
|
12
|
-
"vad_filter": true,
|
|
13
|
-
"word_timestamps": true,
|
|
14
|
-
"formats": ["json", "srt", "vtt"],
|
|
15
|
-
"ui_languages": ["auto", "pt", "en"]
|
|
16
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
"""Subtitle writers: transcript → SRT / VTT text."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from legendaria_engine.schema import Transcript
|
|
6
|
-
|
|
7
|
-
FORMATS = ("json", "srt", "vtt")
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def format_timestamp(seconds: float, separator: str = ",") -> str:
|
|
11
|
-
"""`HH:MM:SS,mmm` (SRT) or `HH:MM:SS.mmm` (VTT). Hours keep counting past 24."""
|
|
12
|
-
total_ms = max(0, round(seconds * 1000))
|
|
13
|
-
hours, rest = divmod(total_ms, 3_600_000)
|
|
14
|
-
minutes, rest = divmod(rest, 60_000)
|
|
15
|
-
secs, ms = divmod(rest, 1000)
|
|
16
|
-
return f"{hours:02d}:{minutes:02d}:{secs:02d}{separator}{ms:03d}"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def to_srt(transcript: Transcript) -> str:
|
|
20
|
-
"""Numbered SRT cues, one per segment."""
|
|
21
|
-
blocks = []
|
|
22
|
-
for index, seg in enumerate(transcript.segments, start=1):
|
|
23
|
-
start, end = format_timestamp(seg.start), format_timestamp(seg.end)
|
|
24
|
-
blocks.append(f"{index}\n{start} --> {end}\n{seg.text.strip()}\n")
|
|
25
|
-
return "\n".join(blocks)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def to_vtt(transcript: Transcript) -> str:
|
|
29
|
-
"""WebVTT cues, one per segment."""
|
|
30
|
-
blocks = ["WEBVTT\n"]
|
|
31
|
-
for seg in transcript.segments:
|
|
32
|
-
start, end = format_timestamp(seg.start, "."), format_timestamp(seg.end, ".")
|
|
33
|
-
blocks.append(f"{start} --> {end}\n{seg.text.strip()}\n")
|
|
34
|
-
return "\n".join(blocks)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
WRITERS = {"srt": to_srt, "vtt": to_vtt}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
-
"$id": "https://raw.githubusercontent.com/flou-ainan/Legendaria/main/engine/schemas/config.schema.json",
|
|
4
|
-
"title": "Legendária engine config",
|
|
5
|
-
"description": "Engine settings. Put only the keys you want to change in your user config.json (see `flosub config path`).",
|
|
6
|
-
"type": "object",
|
|
7
|
-
"properties": {
|
|
8
|
-
"$schema": {"type": "string"},
|
|
9
|
-
"version": {"const": 1, "description": "Config format version."},
|
|
10
|
-
"model": {"type": "string", "description": "Model name from models.json (e.g. small, medium, large-v3-turbo) or a path to a CTranslate2 model folder."},
|
|
11
|
-
"language": {"type": "string", "description": "Speech language code (pt, en, es, ...) or 'auto' to detect it."},
|
|
12
|
-
"device": {"enum": ["auto", "cuda", "cpu"], "description": "'auto' uses an NVIDIA GPU when available, otherwise the CPU."},
|
|
13
|
-
"compute_type": {
|
|
14
|
-
"type": "object",
|
|
15
|
-
"description": "CTranslate2 compute type per device.",
|
|
16
|
-
"properties": {
|
|
17
|
-
"cuda": {"enum": ["float16", "int8_float16", "int8", "float32", "bfloat16"]},
|
|
18
|
-
"cpu": {"enum": ["int8", "int8_float32", "float32"]}
|
|
19
|
-
},
|
|
20
|
-
"additionalProperties": false
|
|
21
|
-
},
|
|
22
|
-
"beam_size": {"type": "integer", "minimum": 1, "maximum": 10, "description": "Beam search width: higher is slightly more accurate and slower."},
|
|
23
|
-
"vad_filter": {"type": "boolean", "description": "Skip silent parts with voice activity detection (reduces made-up text)."},
|
|
24
|
-
"word_timestamps": {"type": "boolean", "description": "Record the timing of every word (needed for CapCut-style captions)."},
|
|
25
|
-
"formats": {"type": "array", "items": {"enum": ["json", "srt", "vtt"]}, "uniqueItems": true, "description": "Files written by `flosub transcribe`."},
|
|
26
|
-
"ui_languages": {"type": "array", "items": {"type": "string"}, "description": "Languages offered in the Blender add-on's language menu."},
|
|
27
|
-
"backend": {"enum": ["faster-whisper"], "description": "Transcription backend."}
|
|
28
|
-
},
|
|
29
|
-
"additionalProperties": false
|
|
30
|
-
}
|
|
@@ -1,26 +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)
|
|
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
|