legendaria 0.1.0.dev2__tar.gz → 0.1.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/PKG-INFO +1 -1
  2. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/__init__.py +1 -1
  3. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/config.py +2 -0
  4. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/cues.py +48 -4
  5. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/defaults/config.json +11 -0
  6. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/pyproject.toml +1 -1
  7. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/schemas/config.schema.json +17 -1
  8. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_cues.py +50 -0
  9. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/uv.lock +1 -1
  10. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/.gitignore +0 -0
  11. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/.python-version +0 -0
  12. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/LICENSE +0 -0
  13. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/README.md +0 -0
  14. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/__main__.py +0 -0
  15. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/cli.py +0 -0
  16. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/cuda_libs.py +0 -0
  17. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/defaults/models.json +0 -0
  18. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/events.py +0 -0
  19. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/formats.py +0 -0
  20. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/models.py +0 -0
  21. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/paths.py +0 -0
  22. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/schema.py +0 -0
  23. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/transcriber.py +0 -0
  24. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/schemas/cues.schema.json +0 -0
  25. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/schemas/models.schema.json +0 -0
  26. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/schemas/transcript.schema.json +0 -0
  27. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/conftest.py +0 -0
  28. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_cli.py +0 -0
  29. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_config.py +0 -0
  30. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_formats.py +0 -0
  31. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_schema.py +0 -0
  32. {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_transcriber.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: legendaria
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev3
4
4
  Summary: Legendária transcription engine: local Whisper transcription with word timings (CLI: flosub).
5
5
  Project-URL: Homepage, https://github.com/flou-ainan/Legendaria
6
6
  Project-URL: Issues, https://github.com/flou-ainan/Legendaria/issues
@@ -1,3 +1,3 @@
1
1
  """Legendária transcription engine."""
2
2
 
3
- __version__ = "0.1.0.dev2"
3
+ __version__ = "0.1.0.dev3"
@@ -16,6 +16,8 @@ STYLE_NUMBERS = (
16
16
  "max_duration",
17
17
  "min_duration",
18
18
  "pause_break",
19
+ "join_short",
20
+ "fill_gaps",
19
21
  )
20
22
 
21
23
 
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  from dataclasses import asdict, dataclass, field
11
+ from itertools import pairwise
11
12
  from pathlib import Path
12
13
  from typing import Any
13
14
 
@@ -56,12 +57,30 @@ def _ends_with(word: Word, chars: str) -> bool:
56
57
  return bool(text) and bool(chars) and text[-1] in chars
57
58
 
58
59
 
60
+ def _is_attached(word: Word) -> bool:
61
+ """A piece Whisper glued to the previous word (no leading space): "-vindas", "'s"."""
62
+ return bool(word.word) and not word.word[0].isspace()
63
+
64
+
65
+ def _units(words: list[Word]) -> int:
66
+ """Readable words: attached pieces do not count ("bem" + "-vindas" is one)."""
67
+ return 1 + sum(1 for w in words[1:] if not _is_attached(w)) if words else 0
68
+
69
+
70
+ def _letters(words: list[Word]) -> int:
71
+ return sum(ch.isalnum() for ch in _join(words))
72
+
73
+
59
74
  def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "custom") -> CueList:
60
75
  """Chunk the transcript's words into cues that respect the style's limits.
61
76
 
62
- Style keys (all optional): max_words, 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).
77
+ Style keys (all optional): max_words (readable words; attached pieces like "-vindas"
78
+ never split from their word), max_chars, max_duration, min_duration, pause_break
79
+ (seconds of silence that force a break), break_after (punctuation characters that
80
+ end a cue), break_on_segment (start a new cue at each segment), join_short (a single
81
+ word with at most this many letters, like "a", "de", "no", may take the next word
82
+ even beyond max_words), fill_gaps (extend a cue to the next one when the silence
83
+ between them is at most this many seconds, so the screen does not blink).
65
84
  """
66
85
  max_words = style.get("max_words")
67
86
  max_chars = style.get("max_chars")
@@ -69,6 +88,7 @@ def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "
69
88
  pause_break = style.get("pause_break")
70
89
  break_after = style.get("break_after", "")
71
90
  break_on_segment = style.get("break_on_segment", True)
91
+ join_short = style.get("join_short")
72
92
 
73
93
  cues: list[Cue] = []
74
94
  current: list[Word] = []
@@ -87,8 +107,22 @@ def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "
87
107
  if break_on_segment:
88
108
  flush()
89
109
  for word in segment.words:
110
+ if current and _is_attached(word):
111
+ current.append(word) # glued piece: never starts a new cue
112
+ if _ends_with(word, break_after):
113
+ flush()
114
+ continue
90
115
  if current:
91
- too_many = max_words is not None and len(current) + 1 > max_words
116
+ allowed = max_words
117
+ if (
118
+ allowed is not None
119
+ and join_short
120
+ and _units(current) == 1
121
+ and _letters(current) <= join_short
122
+ and not _ends_with(current[-1], break_after)
123
+ ):
124
+ allowed += 1 # short word ("de", "a") takes the next one
125
+ too_many = allowed is not None and _units(current) + 1 > allowed
92
126
  too_long = max_chars is not None and len(_join([*current, word])) > max_chars
93
127
  too_slow = max_duration is not None and word.end - current[0].start > max_duration
94
128
  paused = pause_break is not None and word.start - current[-1].end > pause_break
@@ -98,12 +132,22 @@ def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "
98
132
  if _ends_with(word, break_after):
99
133
  flush()
100
134
  flush()
135
+ _fill_gaps(cues, style.get("fill_gaps"))
101
136
  _apply_min_duration(cues, style.get("min_duration"))
102
137
  return CueList(
103
138
  style=style_name, source=transcript.source, language=transcript.language, cues=cues
104
139
  )
105
140
 
106
141
 
142
+ def _fill_gaps(cues: list[Cue], max_gap: float | None) -> None:
143
+ """Extend each cue to the next one's start when the silence between them is short."""
144
+ if not max_gap:
145
+ return
146
+ for cue, nxt in pairwise(cues):
147
+ if 0 < nxt.start - cue.end <= max_gap:
148
+ cue.end = nxt.start
149
+
150
+
107
151
  def _apply_min_duration(cues: list[Cue], min_duration: float | None) -> None:
108
152
  """Stretch cues shorter than `min_duration`, without overlapping the next cue."""
109
153
  if not min_duration:
@@ -13,6 +13,17 @@
13
13
  "word_timestamps": true,
14
14
  "style": "subtitles",
15
15
  "styles": {
16
+ "pop": {
17
+ "max_words": 1,
18
+ "join_short": 3,
19
+ "max_chars": 24,
20
+ "max_duration": 1.6,
21
+ "min_duration": 0.25,
22
+ "pause_break": 0.6,
23
+ "fill_gaps": 0.35,
24
+ "break_after": ".,!?;:…",
25
+ "break_on_segment": true
26
+ },
16
27
  "subtitles": {
17
28
  "max_chars": 84,
18
29
  "line_length": 42,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "legendaria"
3
- version = "0.1.0.dev2"
3
+ version = "0.1.0.dev3"
4
4
  description = "Legendária transcription engine: local Whisper transcription with word timings (CLI: flosub)."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -96,7 +96,7 @@
96
96
  },
97
97
  "styles": {
98
98
  "type": "object",
99
- "description": "Cue styles: how words are grouped into on-screen text. 'captions' = short CapCut-style phrases (Blender default); 'subtitles' = film-style lines (SRT/VTT default). Add your own.",
99
+ "description": "Cue styles: how words are grouped into on-screen text. 'pop' = one word at a time (two when the first is very short), for pop animations; 'captions' = short CapCut-style phrases (Blender default); 'subtitles' = film-style lines (SRT/VTT default). Add your own.",
100
100
  "additionalProperties": {
101
101
  "type": "object",
102
102
  "properties": {
@@ -155,6 +155,22 @@
155
155
  "break_on_segment": {
156
156
  "type": "boolean",
157
157
  "description": "Start a new cue at each Whisper segment."
158
+ },
159
+ "join_short": {
160
+ "type": [
161
+ "integer",
162
+ "null"
163
+ ],
164
+ "minimum": 1,
165
+ "description": "A single word with at most this many letters (\"a\", \"de\", \"no\") may take the next word, even beyond max_words."
166
+ },
167
+ "fill_gaps": {
168
+ "type": [
169
+ "number",
170
+ "null"
171
+ ],
172
+ "minimum": 0,
173
+ "description": "Extend a cue to the next one when the silence between them is at most this many seconds (avoids blinking)."
158
174
  }
159
175
  },
160
176
  "additionalProperties": false
@@ -108,3 +108,53 @@ def test_attached_pieces_stay_glued():
108
108
  Word(0.5, 0.7, " Tom's"),
109
109
  ]
110
110
  assert texts(make_cues(transcript(segment(ws)), {})) == ["Descreve-se o Tom's"]
111
+
112
+
113
+ def test_attached_pieces_never_start_a_cue():
114
+ ws = [
115
+ Word(0, 0.3, " bem"),
116
+ Word(0.3, 0.6, "-vindas"),
117
+ Word(0.7, 1.0, " ao"),
118
+ Word(1.0, 1.3, " canal"),
119
+ ]
120
+ result = make_cues(transcript(segment(ws)), {"max_words": 1})
121
+ assert texts(result) == ["bem-vindas", "ao", "canal"]
122
+
123
+
124
+ def test_short_words_pair_with_the_next():
125
+ ws = words(
126
+ ("a", 0, 0.1),
127
+ ("bolsa", 0.1, 0.5),
128
+ ("de", 0.5, 0.6),
129
+ ("crochê", 0.6, 1.0),
130
+ ("linda", 1.0, 1.4),
131
+ )
132
+ result = make_cues(transcript(segment(ws)), {"max_words": 1, "join_short": 3})
133
+ assert texts(result) == ["a bolsa", "de crochê", "linda"]
134
+
135
+
136
+ def test_short_word_ending_a_sentence_does_not_pair():
137
+ ws = words(("é.", 0, 0.2), ("Agora", 0.3, 0.6))
138
+ result = make_cues(
139
+ transcript(segment(ws)), {"max_words": 1, "join_short": 3, "break_after": "."}
140
+ )
141
+ assert texts(result) == ["é.", "Agora"]
142
+
143
+
144
+ def test_fill_gaps_closes_short_silences_only():
145
+ ws = words(("um", 0.0, 0.2), ("dois", 0.4, 0.6), ("três", 2.0, 2.2))
146
+ result = make_cues(transcript(segment(ws)), {"max_words": 1, "fill_gaps": 0.35})
147
+ assert [(c.start, c.end) for c in result.cues] == [(0.0, 0.4), (0.4, 0.6), (2.0, 2.2)]
148
+
149
+
150
+ def test_pop_style_on_defaults():
151
+ config = load_config()
152
+ ws = words(
153
+ *[
154
+ (w, i * 0.3, i * 0.3 + 0.25)
155
+ for i, w in enumerate(["e", "hoje", "eu", "trouxe", "a", "bolsa"])
156
+ ]
157
+ )
158
+ result = make_cues(transcript(segment(ws)), config["styles"]["pop"], "pop")
159
+ assert texts(result) == ["e hoje", "eu trouxe", "a bolsa"]
160
+ assert all(len(c.text.split()) <= 2 for c in result.cues)
@@ -472,7 +472,7 @@ wheels = [
472
472
 
473
473
  [[package]]
474
474
  name = "legendaria"
475
- version = "0.1.0.dev2"
475
+ version = "0.1.0.dev3"
476
476
  source = { editable = "." }
477
477
  dependencies = [
478
478
  { name = "faster-whisper" },
File without changes