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.
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/PKG-INFO +1 -1
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/__init__.py +1 -1
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/config.py +2 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/cues.py +48 -4
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/defaults/config.json +11 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/pyproject.toml +1 -1
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/schemas/config.schema.json +17 -1
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_cues.py +50 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/uv.lock +1 -1
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/.gitignore +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/.python-version +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/LICENSE +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/README.md +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/__main__.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/cli.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/cuda_libs.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/defaults/models.json +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/events.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/formats.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/models.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/paths.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/schema.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/legendaria_engine/transcriber.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/schemas/cues.schema.json +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/schemas/models.schema.json +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/schemas/transcript.schema.json +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/conftest.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_cli.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_config.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_formats.py +0 -0
- {legendaria-0.1.0.dev2 → legendaria-0.1.0.dev3}/tests/test_schema.py +0 -0
- {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.
|
|
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
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from itertools import pairwise
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
@@ -56,12 +57,30 @@ def _ends_with(word: Word, chars: str) -> bool:
|
|
|
56
57
|
return bool(text) and bool(chars) and text[-1] in chars
|
|
57
58
|
|
|
58
59
|
|
|
60
|
+
def _is_attached(word: Word) -> bool:
|
|
61
|
+
"""A piece Whisper glued to the previous word (no leading space): "-vindas", "'s"."""
|
|
62
|
+
return bool(word.word) and not word.word[0].isspace()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _units(words: list[Word]) -> int:
|
|
66
|
+
"""Readable words: attached pieces do not count ("bem" + "-vindas" is one)."""
|
|
67
|
+
return 1 + sum(1 for w in words[1:] if not _is_attached(w)) if words else 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _letters(words: list[Word]) -> int:
|
|
71
|
+
return sum(ch.isalnum() for ch in _join(words))
|
|
72
|
+
|
|
73
|
+
|
|
59
74
|
def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "custom") -> CueList:
|
|
60
75
|
"""Chunk the transcript's words into cues that respect the style's limits.
|
|
61
76
|
|
|
62
|
-
Style keys (all optional): max_words
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
Style keys (all optional): max_words (readable words; attached pieces like "-vindas"
|
|
78
|
+
never split from their word), max_chars, max_duration, min_duration, pause_break
|
|
79
|
+
(seconds of silence that force a break), break_after (punctuation characters that
|
|
80
|
+
end a cue), break_on_segment (start a new cue at each segment), join_short (a single
|
|
81
|
+
word with at most this many letters, like "a", "de", "no", may take the next word
|
|
82
|
+
even beyond max_words), fill_gaps (extend a cue to the next one when the silence
|
|
83
|
+
between them is at most this many seconds, so the screen does not blink).
|
|
65
84
|
"""
|
|
66
85
|
max_words = style.get("max_words")
|
|
67
86
|
max_chars = style.get("max_chars")
|
|
@@ -69,6 +88,7 @@ def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "
|
|
|
69
88
|
pause_break = style.get("pause_break")
|
|
70
89
|
break_after = style.get("break_after", "")
|
|
71
90
|
break_on_segment = style.get("break_on_segment", True)
|
|
91
|
+
join_short = style.get("join_short")
|
|
72
92
|
|
|
73
93
|
cues: list[Cue] = []
|
|
74
94
|
current: list[Word] = []
|
|
@@ -87,8 +107,22 @@ def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "
|
|
|
87
107
|
if break_on_segment:
|
|
88
108
|
flush()
|
|
89
109
|
for word in segment.words:
|
|
110
|
+
if current and _is_attached(word):
|
|
111
|
+
current.append(word) # glued piece: never starts a new cue
|
|
112
|
+
if _ends_with(word, break_after):
|
|
113
|
+
flush()
|
|
114
|
+
continue
|
|
90
115
|
if current:
|
|
91
|
-
|
|
116
|
+
allowed = max_words
|
|
117
|
+
if (
|
|
118
|
+
allowed is not None
|
|
119
|
+
and join_short
|
|
120
|
+
and _units(current) == 1
|
|
121
|
+
and _letters(current) <= join_short
|
|
122
|
+
and not _ends_with(current[-1], break_after)
|
|
123
|
+
):
|
|
124
|
+
allowed += 1 # short word ("de", "a") takes the next one
|
|
125
|
+
too_many = allowed is not None and _units(current) + 1 > allowed
|
|
92
126
|
too_long = max_chars is not None and len(_join([*current, word])) > max_chars
|
|
93
127
|
too_slow = max_duration is not None and word.end - current[0].start > max_duration
|
|
94
128
|
paused = pause_break is not None and word.start - current[-1].end > pause_break
|
|
@@ -98,12 +132,22 @@ def make_cues(transcript: Transcript, style: dict[str, Any], style_name: str = "
|
|
|
98
132
|
if _ends_with(word, break_after):
|
|
99
133
|
flush()
|
|
100
134
|
flush()
|
|
135
|
+
_fill_gaps(cues, style.get("fill_gaps"))
|
|
101
136
|
_apply_min_duration(cues, style.get("min_duration"))
|
|
102
137
|
return CueList(
|
|
103
138
|
style=style_name, source=transcript.source, language=transcript.language, cues=cues
|
|
104
139
|
)
|
|
105
140
|
|
|
106
141
|
|
|
142
|
+
def _fill_gaps(cues: list[Cue], max_gap: float | None) -> None:
|
|
143
|
+
"""Extend each cue to the next one's start when the silence between them is short."""
|
|
144
|
+
if not max_gap:
|
|
145
|
+
return
|
|
146
|
+
for cue, nxt in pairwise(cues):
|
|
147
|
+
if 0 < nxt.start - cue.end <= max_gap:
|
|
148
|
+
cue.end = nxt.start
|
|
149
|
+
|
|
150
|
+
|
|
107
151
|
def _apply_min_duration(cues: list[Cue], min_duration: float | None) -> None:
|
|
108
152
|
"""Stretch cues shorter than `min_duration`, without overlapping the next cue."""
|
|
109
153
|
if not min_duration:
|
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
"word_timestamps": true,
|
|
14
14
|
"style": "subtitles",
|
|
15
15
|
"styles": {
|
|
16
|
+
"pop": {
|
|
17
|
+
"max_words": 1,
|
|
18
|
+
"join_short": 3,
|
|
19
|
+
"max_chars": 24,
|
|
20
|
+
"max_duration": 1.6,
|
|
21
|
+
"min_duration": 0.25,
|
|
22
|
+
"pause_break": 0.6,
|
|
23
|
+
"fill_gaps": 0.35,
|
|
24
|
+
"break_after": ".,!?;:…",
|
|
25
|
+
"break_on_segment": true
|
|
26
|
+
},
|
|
16
27
|
"subtitles": {
|
|
17
28
|
"max_chars": 84,
|
|
18
29
|
"line_length": 42,
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|