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.
Files changed (36) hide show
  1. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/PKG-INFO +1 -1
  2. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/__init__.py +1 -1
  3. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/cli.py +70 -12
  4. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/config.py +21 -0
  5. legendaria-0.1.0.dev2/legendaria_engine/cues.py +124 -0
  6. legendaria-0.1.0.dev2/legendaria_engine/defaults/config.json +45 -0
  7. legendaria-0.1.0.dev2/legendaria_engine/formats.py +37 -0
  8. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/schema.py +36 -0
  9. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/transcriber.py +11 -2
  10. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/pyproject.toml +1 -1
  11. legendaria-0.1.0.dev2/schemas/config.schema.json +165 -0
  12. legendaria-0.1.0.dev2/schemas/cues.schema.json +83 -0
  13. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/tests/test_cli.py +34 -1
  14. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/tests/test_config.py +22 -0
  15. legendaria-0.1.0.dev2/tests/test_cues.py +110 -0
  16. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/tests/test_formats.py +19 -4
  17. legendaria-0.1.0.dev2/tests/test_schema.py +74 -0
  18. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/uv.lock +1 -1
  19. legendaria-0.1.0.dev0/legendaria_engine/defaults/config.json +0 -16
  20. legendaria-0.1.0.dev0/legendaria_engine/formats.py +0 -37
  21. legendaria-0.1.0.dev0/schemas/config.schema.json +0 -30
  22. legendaria-0.1.0.dev0/tests/test_schema.py +0 -26
  23. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/.gitignore +0 -0
  24. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/.python-version +0 -0
  25. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/LICENSE +0 -0
  26. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/README.md +0 -0
  27. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/__main__.py +0 -0
  28. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/cuda_libs.py +0 -0
  29. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/defaults/models.json +0 -0
  30. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/events.py +0 -0
  31. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/models.py +0 -0
  32. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/legendaria_engine/paths.py +0 -0
  33. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/schemas/models.schema.json +0 -0
  34. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/schemas/transcript.schema.json +0 -0
  35. {legendaria-0.1.0.dev0 → legendaria-0.1.0.dev2}/tests/conftest.py +0 -0
  36. {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.dev0
3
+ Version: 0.1.0.dev2
4
4
  Summary: Legendária transcription engine: local Whisper transcription with word timings (CLI: flosub).
5
5
  Project-URL: Homepage, https://github.com/flou-ainan/Legendaria
6
6
  Project-URL: Issues, https://github.com/flou-ainan/Legendaria/issues
@@ -1,3 +1,3 @@
1
1
  """Legendária transcription engine."""
2
2
 
3
- __version__ = "0.1.0.dev0"
3
+ __version__ = "0.1.0.dev2"
@@ -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 write_outputs(transcript: Transcript, stem: Path, formats: list[str]) -> list[str]:
18
- """Write the requested formats next to `stem` (no extension); returns the paths."""
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.with_suffix(f".{fmt}")
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](transcript), encoding="utf-8")
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`: convert a transcript JSON to SRT/VTT."""
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
- stem = Path(args.output).with_suffix("") if args.output else source.with_suffix("")
90
- outputs = write_outputs(transcript, stem, args.format)
91
- reporter.emit("done", outputs=outputs, segments=len(transcript.segments))
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="convert a transcript JSON to SRT/VTT")
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("--format", type=parse_formats, default=["srt"], help="comma-separated: srt,vtt")
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, info.duration)
114
- reporter.progress(info.duration, info.duration)
122
+ reporter.progress(max(0.0, seg.end - offset), total)
123
+ reporter.progress(total, total)
115
124
  return Transcript(
116
125
  source=media.name,
117
126
  language=info.language,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "legendaria"
3
- version = "0.1.0.dev0"
3
+ version = "0.1.0.dev2"
4
4
  description = "Legendária transcription engine: local Whisper transcription with word timings (CLI: flosub)."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- def test_to_srt(transcript):
27
- assert to_srt(transcript) == (
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(transcript):
34
- text = to_vtt(transcript)
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
@@ -472,7 +472,7 @@ wheels = [
472
472
 
473
473
  [[package]]
474
474
  name = "legendaria"
475
- version = "0.1.0.dev0"
475
+ version = "0.1.0.dev2"
476
476
  source = { editable = "." }
477
477
  dependencies = [
478
478
  { name = "faster-whisper" },
@@ -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