legendaria 0.1.0.dev0__tar.gz → 0.1.0.dev1__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.dev1}/PKG-INFO +1 -1
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/__init__.py +1 -1
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/cli.py +46 -12
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/config.py +21 -0
- legendaria-0.1.0.dev1/legendaria_engine/cues.py +124 -0
- legendaria-0.1.0.dev1/legendaria_engine/defaults/config.json +45 -0
- legendaria-0.1.0.dev1/legendaria_engine/formats.py +37 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/pyproject.toml +1 -1
- legendaria-0.1.0.dev1/schemas/config.schema.json +165 -0
- legendaria-0.1.0.dev1/schemas/cues.schema.json +83 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/tests/test_cli.py +18 -1
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/tests/test_config.py +22 -0
- legendaria-0.1.0.dev1/tests/test_cues.py +110 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/tests/test_formats.py +19 -4
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/tests/test_schema.py +9 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/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 → legendaria-0.1.0.dev1}/.gitignore +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/.python-version +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/LICENSE +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/README.md +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/__main__.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/cuda_libs.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/defaults/models.json +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/events.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/models.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/paths.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/schema.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/legendaria_engine/transcriber.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/schemas/models.schema.json +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/schemas/transcript.schema.json +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/tests/conftest.py +0 -0
- {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev1}/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.dev1
|
|
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,49 @@ 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 check_style(config: dict, style: str | None) -> str:
|
|
48
|
+
"""The style to use: `--style` if given, else the config default; must exist."""
|
|
49
|
+
style = style or config["style"]
|
|
50
|
+
if style not in config["styles"]:
|
|
51
|
+
raise ConfigError(f"Unknown style '{style}'. Available: {', '.join(config['styles'])}")
|
|
52
|
+
return style
|
|
53
|
+
|
|
54
|
+
|
|
31
55
|
def parse_formats(value: str | None) -> list[str] | None:
|
|
32
56
|
"""argparse type: comma-separated format list, validated against FORMATS."""
|
|
33
57
|
if value is None:
|
|
@@ -53,6 +77,7 @@ def cmd_transcribe(args: argparse.Namespace, reporter: Reporter) -> int:
|
|
|
53
77
|
"formats": args.format,
|
|
54
78
|
}
|
|
55
79
|
)
|
|
80
|
+
style = check_style(config, args.style)
|
|
56
81
|
if config["device"] != "cpu": # make pip-installed CUDA libraries loadable (may re-exec)
|
|
57
82
|
from legendaria_engine.cuda_libs import ensure_cuda_libs
|
|
58
83
|
|
|
@@ -75,7 +100,7 @@ def cmd_transcribe(args: argparse.Namespace, reporter: Reporter) -> int:
|
|
|
75
100
|
media, options, reporter
|
|
76
101
|
)
|
|
77
102
|
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"])
|
|
103
|
+
outputs = write_outputs(transcript, out_dir / media.stem, config["formats"], config, style)
|
|
79
104
|
reporter.emit(
|
|
80
105
|
"done", outputs=outputs, segments=len(transcript.segments), language=transcript.language
|
|
81
106
|
)
|
|
@@ -83,12 +108,15 @@ def cmd_transcribe(args: argparse.Namespace, reporter: Reporter) -> int:
|
|
|
83
108
|
|
|
84
109
|
|
|
85
110
|
def cmd_export(args: argparse.Namespace, reporter: Reporter) -> int:
|
|
86
|
-
"""`flosub export`:
|
|
111
|
+
"""`flosub export`: turn a transcript JSON into cues / SRT / VTT in a given style."""
|
|
87
112
|
source = Path(args.file).expanduser()
|
|
88
113
|
transcript = Transcript.load(source)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
114
|
+
config = load_config()
|
|
115
|
+
style = check_style(config, args.style)
|
|
116
|
+
default_stem = source.parent / source.name.removesuffix(".json")
|
|
117
|
+
stem = Path(args.output) if args.output else default_stem
|
|
118
|
+
outputs = write_outputs(transcript, stem, args.format, config, style)
|
|
119
|
+
reporter.emit("done", outputs=outputs, segments=len(transcript.segments), style=style)
|
|
92
120
|
return 0
|
|
93
121
|
|
|
94
122
|
|
|
@@ -147,13 +175,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
147
175
|
t.add_argument("--model", help="model name (see `flosub models list`) or a model folder")
|
|
148
176
|
t.add_argument("--language", help="language code (pt, en, ...) or 'auto'")
|
|
149
177
|
t.add_argument("--device", choices=["auto", "cuda", "cpu"])
|
|
150
|
-
t.add_argument("--format", type=parse_formats, help="comma-separated: json,srt,vtt")
|
|
178
|
+
t.add_argument("--format", type=parse_formats, help="comma-separated: json,cues,srt,vtt")
|
|
179
|
+
t.add_argument(
|
|
180
|
+
"--style", help="cue style: subtitles (default), captions, or one from your config"
|
|
181
|
+
)
|
|
151
182
|
t.add_argument("--output-dir", help="where to write the files (default: next to the input)")
|
|
152
183
|
t.set_defaults(func=cmd_transcribe)
|
|
153
184
|
|
|
154
|
-
e = sub.add_parser("export", help="
|
|
185
|
+
e = sub.add_parser("export", help="turn a transcript JSON into cues/SRT/VTT")
|
|
155
186
|
e.add_argument("file")
|
|
156
|
-
e.add_argument(
|
|
187
|
+
e.add_argument(
|
|
188
|
+
"--format", type=parse_formats, default=["srt"], help="comma-separated: cues,srt,vtt"
|
|
189
|
+
)
|
|
190
|
+
e.add_argument("--style", help="cue style: subtitles (default), captions, or your own")
|
|
157
191
|
e.add_argument("--output", help="output path without extension (default: next to the JSON)")
|
|
158
192
|
e.set_defaults(func=cmd_export)
|
|
159
193
|
|
|
@@ -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}
|
|
@@ -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):
|
|
@@ -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)
|
|
@@ -24,3 +24,12 @@ def test_rejects_other_versions(transcript):
|
|
|
24
24
|
data = transcript.to_dict() | {"version": 2}
|
|
25
25
|
with pytest.raises(ValueError, match="version"):
|
|
26
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)
|
|
@@ -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
|
-
}
|
|
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
|