lyrics-transcriber 0.30.0__py3-none-any.whl → 0.32.1__py3-none-any.whl
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.
- lyrics_transcriber/__init__.py +2 -1
- lyrics_transcriber/cli/{main.py → cli_main.py} +47 -14
- lyrics_transcriber/core/config.py +35 -0
- lyrics_transcriber/core/controller.py +164 -166
- lyrics_transcriber/correction/anchor_sequence.py +471 -0
- lyrics_transcriber/correction/corrector.py +256 -0
- lyrics_transcriber/correction/handlers/__init__.py +0 -0
- lyrics_transcriber/correction/handlers/base.py +30 -0
- lyrics_transcriber/correction/handlers/extend_anchor.py +91 -0
- lyrics_transcriber/correction/handlers/levenshtein.py +147 -0
- lyrics_transcriber/correction/handlers/no_space_punct_match.py +98 -0
- lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +55 -0
- lyrics_transcriber/correction/handlers/repeat.py +71 -0
- lyrics_transcriber/correction/handlers/sound_alike.py +223 -0
- lyrics_transcriber/correction/handlers/syllables_match.py +182 -0
- lyrics_transcriber/correction/handlers/word_count_match.py +54 -0
- lyrics_transcriber/correction/handlers/word_operations.py +135 -0
- lyrics_transcriber/correction/phrase_analyzer.py +426 -0
- lyrics_transcriber/correction/text_utils.py +30 -0
- lyrics_transcriber/lyrics/base_lyrics_provider.py +125 -0
- lyrics_transcriber/lyrics/genius.py +73 -0
- lyrics_transcriber/lyrics/spotify.py +82 -0
- lyrics_transcriber/output/ass/__init__.py +21 -0
- lyrics_transcriber/output/{ass.py → ass/ass.py} +150 -690
- lyrics_transcriber/output/ass/ass_specs.txt +732 -0
- lyrics_transcriber/output/ass/config.py +37 -0
- lyrics_transcriber/output/ass/constants.py +23 -0
- lyrics_transcriber/output/ass/event.py +94 -0
- lyrics_transcriber/output/ass/formatters.py +132 -0
- lyrics_transcriber/output/ass/lyrics_line.py +219 -0
- lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
- lyrics_transcriber/output/ass/section_detector.py +89 -0
- lyrics_transcriber/output/ass/section_screen.py +106 -0
- lyrics_transcriber/output/ass/style.py +187 -0
- lyrics_transcriber/output/cdg.py +503 -0
- lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
- lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
- lyrics_transcriber/output/cdgmaker/composer.py +1919 -0
- lyrics_transcriber/output/cdgmaker/config.py +151 -0
- lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
- lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
- lyrics_transcriber/output/cdgmaker/pack.py +507 -0
- lyrics_transcriber/output/cdgmaker/render.py +346 -0
- lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
- lyrics_transcriber/output/cdgmaker/utils.py +132 -0
- lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
- lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
- lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/arial.ttf +0 -0
- lyrics_transcriber/output/fonts/georgia.ttf +0 -0
- lyrics_transcriber/output/fonts/verdana.ttf +0 -0
- lyrics_transcriber/output/generator.py +140 -171
- lyrics_transcriber/output/lyrics_file.py +102 -0
- lyrics_transcriber/output/plain_text.py +91 -0
- lyrics_transcriber/output/segment_resizer.py +416 -0
- lyrics_transcriber/output/subtitles.py +328 -302
- lyrics_transcriber/output/video.py +219 -0
- lyrics_transcriber/review/__init__.py +1 -0
- lyrics_transcriber/review/server.py +138 -0
- lyrics_transcriber/storage/dropbox.py +110 -134
- lyrics_transcriber/transcribers/audioshake.py +171 -105
- lyrics_transcriber/transcribers/base_transcriber.py +149 -0
- lyrics_transcriber/transcribers/whisper.py +267 -133
- lyrics_transcriber/types.py +454 -0
- {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/METADATA +14 -3
- lyrics_transcriber-0.32.1.dist-info/RECORD +86 -0
- {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/WHEEL +1 -1
- lyrics_transcriber-0.32.1.dist-info/entry_points.txt +4 -0
- lyrics_transcriber/core/corrector.py +0 -56
- lyrics_transcriber/core/fetcher.py +0 -143
- lyrics_transcriber/storage/tokens.py +0 -116
- lyrics_transcriber/transcribers/base.py +0 -31
- lyrics_transcriber-0.30.0.dist-info/RECORD +0 -22
- lyrics_transcriber-0.30.0.dist-info/entry_points.txt +0 -3
- {lyrics_transcriber-0.30.0.dist-info → lyrics_transcriber-0.32.1.dist-info}/LICENSE +0 -0
@@ -0,0 +1,37 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
|
3
|
+
|
4
|
+
class ScreenConfig:
|
5
|
+
"""Configuration for screen timing and layout."""
|
6
|
+
|
7
|
+
def __init__(self, line_height: int = 50, max_visible_lines: int = 4, top_padding: int = None, video_width: int = 640, video_height: int = 360):
|
8
|
+
# Screen layout
|
9
|
+
self.max_visible_lines = max_visible_lines
|
10
|
+
self.line_height = line_height
|
11
|
+
self.top_padding = top_padding if top_padding is not None else line_height
|
12
|
+
self.video_height = video_height
|
13
|
+
self.video_width = video_width
|
14
|
+
# Timing configuration
|
15
|
+
self.screen_gap_threshold = 5.0
|
16
|
+
self.post_roll_time = 1.0
|
17
|
+
self.fade_in_ms = 200
|
18
|
+
self.fade_out_ms = 300
|
19
|
+
|
20
|
+
|
21
|
+
@dataclass
|
22
|
+
class LineTimingInfo:
|
23
|
+
"""Timing information for a single line."""
|
24
|
+
|
25
|
+
fade_in_time: float
|
26
|
+
end_time: float
|
27
|
+
fade_out_time: float
|
28
|
+
clear_time: float
|
29
|
+
|
30
|
+
|
31
|
+
@dataclass
|
32
|
+
class LineState:
|
33
|
+
"""Complete state for a single line."""
|
34
|
+
|
35
|
+
text: str
|
36
|
+
timing: LineTimingInfo
|
37
|
+
y_position: int
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# Alignment constants
|
2
|
+
ALIGN_BOTTOM_LEFT = 1
|
3
|
+
ALIGN_BOTTOM_CENTER = 2
|
4
|
+
ALIGN_BOTTOM_RIGHT = 3
|
5
|
+
ALIGN_MIDDLE_LEFT = 4
|
6
|
+
ALIGN_MIDDLE_CENTER = 5
|
7
|
+
ALIGN_MIDDLE_RIGHT = 6
|
8
|
+
ALIGN_TOP_LEFT = 7
|
9
|
+
ALIGN_TOP_CENTER = 8
|
10
|
+
ALIGN_TOP_RIGHT = 9
|
11
|
+
|
12
|
+
# Legacy alignment mapping
|
13
|
+
LEGACY_ALIGNMENT_TO_REGULAR = {
|
14
|
+
"1": ALIGN_BOTTOM_LEFT,
|
15
|
+
"2": ALIGN_BOTTOM_CENTER,
|
16
|
+
"3": ALIGN_BOTTOM_RIGHT,
|
17
|
+
"5": ALIGN_TOP_LEFT,
|
18
|
+
"6": ALIGN_TOP_CENTER,
|
19
|
+
"7": ALIGN_TOP_RIGHT,
|
20
|
+
"9": ALIGN_MIDDLE_LEFT,
|
21
|
+
"10": ALIGN_MIDDLE_CENTER,
|
22
|
+
"11": ALIGN_MIDDLE_RIGHT,
|
23
|
+
}
|
@@ -0,0 +1,94 @@
|
|
1
|
+
class Event:
|
2
|
+
aliases = {}
|
3
|
+
formatters = None
|
4
|
+
order = [
|
5
|
+
"Layer",
|
6
|
+
"Start",
|
7
|
+
"End",
|
8
|
+
"Style",
|
9
|
+
"Name",
|
10
|
+
"MarginL",
|
11
|
+
"MarginR",
|
12
|
+
"MarginV",
|
13
|
+
"Effect",
|
14
|
+
"Text",
|
15
|
+
]
|
16
|
+
|
17
|
+
# Constructor
|
18
|
+
def __init__(self):
|
19
|
+
self.type = None
|
20
|
+
|
21
|
+
self.Layer = 0
|
22
|
+
self.Start = 0.0
|
23
|
+
self.End = 0.0
|
24
|
+
self.Style = None
|
25
|
+
self.Name = ""
|
26
|
+
self.MarginL = 0
|
27
|
+
self.MarginR = 0
|
28
|
+
self.MarginV = 0
|
29
|
+
self.Effect = ""
|
30
|
+
self.Text = ""
|
31
|
+
|
32
|
+
def set(self, attribute_name, value, *args):
|
33
|
+
if hasattr(self, attribute_name) and attribute_name[0].isupper():
|
34
|
+
setattr(
|
35
|
+
self,
|
36
|
+
attribute_name,
|
37
|
+
self.formatters[attribute_name][0](value, *args),
|
38
|
+
)
|
39
|
+
|
40
|
+
def get(self, attribute_name, *args):
|
41
|
+
if hasattr(self, attribute_name) and attribute_name[0].isupper():
|
42
|
+
return self.formatters[attribute_name][1](getattr(self, attribute_name), *args)
|
43
|
+
return None
|
44
|
+
|
45
|
+
def copy(self, other=None):
|
46
|
+
if other is None:
|
47
|
+
other = self.__class__()
|
48
|
+
target = other
|
49
|
+
source = self
|
50
|
+
else:
|
51
|
+
target = other
|
52
|
+
source = self
|
53
|
+
|
54
|
+
# Copy all attributes
|
55
|
+
target.type = source.type
|
56
|
+
target.Layer = source.Layer
|
57
|
+
target.Start = source.Start
|
58
|
+
target.End = source.End
|
59
|
+
target.Style = source.Style
|
60
|
+
target.Name = source.Name
|
61
|
+
target.MarginL = source.MarginL
|
62
|
+
target.MarginR = source.MarginR
|
63
|
+
target.MarginV = source.MarginV
|
64
|
+
target.Effect = source.Effect
|
65
|
+
target.Text = source.Text
|
66
|
+
|
67
|
+
return target
|
68
|
+
|
69
|
+
def equals(self, other):
|
70
|
+
return (
|
71
|
+
self.type == other.type
|
72
|
+
and self.Layer == other.Layer
|
73
|
+
and self.Start == other.Start
|
74
|
+
and self.End == other.End
|
75
|
+
and self.Style is other.Style
|
76
|
+
and self.Name == other.Name
|
77
|
+
and self.MarginL == other.MarginL
|
78
|
+
and self.MarginR == other.MarginR
|
79
|
+
and self.MarginV == other.MarginV
|
80
|
+
and self.Effect == other.Effect
|
81
|
+
and self.Text == other.Text
|
82
|
+
)
|
83
|
+
|
84
|
+
def same_style(self, other):
|
85
|
+
return (
|
86
|
+
self.type == other.type
|
87
|
+
and self.Layer == other.Layer
|
88
|
+
and self.Style is other.Style
|
89
|
+
and self.Name == other.Name
|
90
|
+
and self.MarginL == other.MarginL
|
91
|
+
and self.MarginR == other.MarginR
|
92
|
+
and self.MarginV == other.MarginV
|
93
|
+
and self.Effect == other.Effect
|
94
|
+
)
|
@@ -0,0 +1,132 @@
|
|
1
|
+
import re
|
2
|
+
|
3
|
+
|
4
|
+
class Formatters:
|
5
|
+
__re_color_format = re.compile(r"&H([0-9a-fA-F]{8}|[0-9a-fA-F]{6})", re.U)
|
6
|
+
__re_tag_number = re.compile(r"^\s*([\+\-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))", re.U)
|
7
|
+
|
8
|
+
@classmethod
|
9
|
+
def same(cls, val, *args):
|
10
|
+
return val
|
11
|
+
|
12
|
+
@classmethod
|
13
|
+
def color_to_str(cls, val, *args):
|
14
|
+
return "&H{0:02X}{1:02X}{2:02X}{3:02X}".format(255 - val[3], val[2], val[1], val[0])
|
15
|
+
|
16
|
+
@classmethod
|
17
|
+
def str_to_color(cls, val, *args):
|
18
|
+
match = cls.__re_color_format.search(val)
|
19
|
+
if match:
|
20
|
+
hex_val = "{0:>08s}".format(match.group(1))
|
21
|
+
return (
|
22
|
+
int(hex_val[6:8], 16), # Red
|
23
|
+
int(hex_val[4:6], 16), # Green
|
24
|
+
int(hex_val[2:4], 16), # Blue
|
25
|
+
255 - int(hex_val[0:2], 16), # Alpha
|
26
|
+
)
|
27
|
+
# Return white (255, 255, 255, 255) for invalid input
|
28
|
+
return (255, 255, 255, 255)
|
29
|
+
|
30
|
+
@classmethod
|
31
|
+
def n1bool_to_str(cls, val, *args):
|
32
|
+
if val:
|
33
|
+
return "-1"
|
34
|
+
return "0"
|
35
|
+
|
36
|
+
@classmethod
|
37
|
+
def str_to_n1bool(cls, val, *args):
|
38
|
+
try:
|
39
|
+
val = int(val, 10)
|
40
|
+
except ValueError:
|
41
|
+
return False
|
42
|
+
return val != 0
|
43
|
+
|
44
|
+
@classmethod
|
45
|
+
def integer_to_str(cls, val, *args):
|
46
|
+
return str(int(val))
|
47
|
+
|
48
|
+
@classmethod
|
49
|
+
def str_to_integer(cls, val, *args):
|
50
|
+
try:
|
51
|
+
return int(val, 10)
|
52
|
+
except ValueError:
|
53
|
+
return 0
|
54
|
+
|
55
|
+
@classmethod
|
56
|
+
def number_to_str(cls, val, *args):
|
57
|
+
if int(val) == val:
|
58
|
+
return str(int(val))
|
59
|
+
# No decimal
|
60
|
+
return str(val)
|
61
|
+
|
62
|
+
@classmethod
|
63
|
+
def str_to_number(cls, val, *args):
|
64
|
+
try:
|
65
|
+
return float(val)
|
66
|
+
except ValueError:
|
67
|
+
return 0.0
|
68
|
+
|
69
|
+
@classmethod
|
70
|
+
def timecode_to_str_generic(
|
71
|
+
cls,
|
72
|
+
timecode,
|
73
|
+
decimal_length=2,
|
74
|
+
seconds_length=2,
|
75
|
+
minutes_length=2,
|
76
|
+
hours_length=1,
|
77
|
+
):
|
78
|
+
if decimal_length > 0:
|
79
|
+
total_length = seconds_length + decimal_length + 1
|
80
|
+
else:
|
81
|
+
total_length = seconds_length
|
82
|
+
|
83
|
+
tc_parts = [
|
84
|
+
"{{0:0{0:d}d}}".format(hours_length).format(int(timecode // 3600)),
|
85
|
+
"{{0:0{0:d}d}}".format(minutes_length).format(int((timecode // 60) % 60)),
|
86
|
+
"{{0:0{0:d}.{1:d}f}}".format(total_length, decimal_length).format(timecode % 60),
|
87
|
+
]
|
88
|
+
return ":".join(tc_parts)
|
89
|
+
|
90
|
+
@classmethod
|
91
|
+
def timecode_to_str(cls, val, *args):
|
92
|
+
return cls.timecode_to_str_generic(val, 2)
|
93
|
+
|
94
|
+
@classmethod
|
95
|
+
def str_to_timecode(cls, val, *args):
|
96
|
+
time = 0.0
|
97
|
+
mult = 1
|
98
|
+
|
99
|
+
for t in reversed(val.split(":")):
|
100
|
+
time += float(t) * mult
|
101
|
+
mult *= 60
|
102
|
+
|
103
|
+
return time
|
104
|
+
|
105
|
+
@classmethod
|
106
|
+
def style_to_str(cls, val, *args):
|
107
|
+
if val is None:
|
108
|
+
return ""
|
109
|
+
return val.Name
|
110
|
+
|
111
|
+
@classmethod
|
112
|
+
def str_to_style(cls, val, style_map, style_constructor, *args):
|
113
|
+
if val in style_map:
|
114
|
+
return style_map[val]
|
115
|
+
|
116
|
+
# Create fake
|
117
|
+
style = style_constructor()
|
118
|
+
style.fake = True
|
119
|
+
style.Name = val
|
120
|
+
|
121
|
+
# Add to map (will not be included in global style list, but allows for duplicate "fake" styles to reference the same object)
|
122
|
+
style_map[style.Name] = style
|
123
|
+
|
124
|
+
# Return the new style
|
125
|
+
return style
|
126
|
+
|
127
|
+
@classmethod
|
128
|
+
def tag_argument_to_number(cls, arg, default_value=None):
|
129
|
+
match = cls.__re_tag_number.match(arg)
|
130
|
+
if match is None:
|
131
|
+
return default_value
|
132
|
+
return float(match.group(1))
|
@@ -0,0 +1,219 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import Optional, Tuple, List
|
3
|
+
import logging
|
4
|
+
from datetime import timedelta
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
6
|
+
import os
|
7
|
+
|
8
|
+
from lyrics_transcriber.types import LyricsSegment
|
9
|
+
from lyrics_transcriber.output.ass.event import Event
|
10
|
+
from lyrics_transcriber.output.ass.style import Style
|
11
|
+
from lyrics_transcriber.output.ass.config import LineState, ScreenConfig
|
12
|
+
|
13
|
+
|
14
|
+
@dataclass
|
15
|
+
class LyricsLine:
|
16
|
+
"""Represents a single line of lyrics with timing and karaoke information."""
|
17
|
+
|
18
|
+
segment: LyricsSegment
|
19
|
+
screen_config: ScreenConfig
|
20
|
+
logger: Optional[logging.Logger] = None
|
21
|
+
previous_end_time: Optional[float] = None
|
22
|
+
|
23
|
+
def __post_init__(self):
|
24
|
+
"""Ensure logger is initialized"""
|
25
|
+
if self.logger is None:
|
26
|
+
self.logger = logging.getLogger(__name__)
|
27
|
+
|
28
|
+
def _get_font(self, style: Style) -> ImageFont.FreeTypeFont:
|
29
|
+
"""Get the font for text measurements."""
|
30
|
+
# ASS renders fonts about 70% of their actual size
|
31
|
+
ASS_FONT_SCALE = 0.70
|
32
|
+
|
33
|
+
# Scale down the font size to match ASS rendering
|
34
|
+
adjusted_size = int(style.Fontsize * ASS_FONT_SCALE)
|
35
|
+
self.logger.debug(f"Adjusting font size from {style.Fontsize} to {adjusted_size} to match ASS rendering")
|
36
|
+
|
37
|
+
try:
|
38
|
+
# Use the Fontpath property from Style class
|
39
|
+
if style.Fontpath and os.path.exists(style.Fontpath):
|
40
|
+
return ImageFont.truetype(style.Fontpath, size=adjusted_size)
|
41
|
+
self.logger.warning(f"Could not load font {style.Fontpath}, using default")
|
42
|
+
return ImageFont.load_default()
|
43
|
+
except (OSError, AttributeError) as e:
|
44
|
+
self.logger.warning(f"Font error ({e}), using default")
|
45
|
+
return ImageFont.load_default()
|
46
|
+
|
47
|
+
def _get_text_dimensions(self, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
|
48
|
+
"""Get the pixel dimensions of rendered text."""
|
49
|
+
# Create an image the same size as the video frame
|
50
|
+
img = Image.new("RGB", (self.screen_config.video_width, self.screen_config.video_height), color="black")
|
51
|
+
draw = ImageDraw.Draw(img)
|
52
|
+
|
53
|
+
# Get the bounding box
|
54
|
+
bbox = draw.textbbox((0, 0), text, font=font)
|
55
|
+
width = bbox[2] - bbox[0]
|
56
|
+
height = bbox[3] - bbox[1]
|
57
|
+
|
58
|
+
self.logger.debug(f"Text dimensions for '{text}': width={width}px, height={height}px")
|
59
|
+
self.logger.debug(f"Video dimensions: {self.screen_config.video_width}x{self.screen_config.video_height}")
|
60
|
+
return width, height
|
61
|
+
|
62
|
+
# fmt: off
|
63
|
+
def _create_lead_in_text(self, state: LineState) -> Tuple[str, bool]:
|
64
|
+
"""Create lead-in indicator text if needed.
|
65
|
+
|
66
|
+
Returns:
|
67
|
+
Tuple of (text, has_lead_in)
|
68
|
+
"""
|
69
|
+
has_lead_in = (self.previous_end_time is None or
|
70
|
+
self.segment.start_time - self.previous_end_time >= 5.0)
|
71
|
+
|
72
|
+
if not has_lead_in:
|
73
|
+
return "", False
|
74
|
+
|
75
|
+
# Add a hyphen with karaoke timing for the last 2 seconds before the line
|
76
|
+
lead_in_start = max(state.timing.fade_in_time, self.segment.start_time - 2.0)
|
77
|
+
gap_before_highlight = int((lead_in_start - state.timing.fade_in_time) * 100)
|
78
|
+
highlight_duration = int((self.segment.start_time - lead_in_start) * 100)
|
79
|
+
|
80
|
+
text = ""
|
81
|
+
# Add initial gap if needed
|
82
|
+
if gap_before_highlight > 0:
|
83
|
+
text += f"{{\\k{gap_before_highlight}}}"
|
84
|
+
# Add the hyphen with highlight
|
85
|
+
text += f"{{\\kf{highlight_duration}}}→ "
|
86
|
+
|
87
|
+
return text, True
|
88
|
+
|
89
|
+
def _create_lead_in_event(self, state: LineState, style: Style, video_width: int, config: ScreenConfig) -> Optional[Event]:
|
90
|
+
"""Create a separate event for the lead-in indicator if needed."""
|
91
|
+
if not (self.previous_end_time is None or
|
92
|
+
self.segment.start_time - self.previous_end_time >= 5.0):
|
93
|
+
return None
|
94
|
+
|
95
|
+
self.logger.debug(f"Creating lead-in indicator for line: '{self.segment.text}'")
|
96
|
+
|
97
|
+
# Calculate all timing points
|
98
|
+
line_start = self.segment.start_time
|
99
|
+
appear_time = line_start - 3.0 # Start 3 seconds before line
|
100
|
+
fade_in_end = appear_time + 0.8 # 800ms fade in
|
101
|
+
fade_out_start = line_start - 0.3 # Start fade 300ms before reaching final position
|
102
|
+
fade_out_end = line_start + 0.2 # Complete fade 200ms after line starts (500ms total fade)
|
103
|
+
|
104
|
+
self.logger.debug(f"Timing calculations:")
|
105
|
+
self.logger.debug(f" Line starts at: {line_start:.2f}s")
|
106
|
+
self.logger.debug(f" Rectangle appears at: {appear_time:.2f}s")
|
107
|
+
self.logger.debug(f" Fade in completes at: {fade_in_end:.2f}s")
|
108
|
+
self.logger.debug(f" Fade out starts at: {fade_out_start:.2f}s")
|
109
|
+
self.logger.debug(f" Rectangle reaches final position at: {line_start:.2f}s")
|
110
|
+
self.logger.debug(f" Rectangle fully faded out at: {fade_out_end:.2f}s")
|
111
|
+
|
112
|
+
# Calculate dimensions and positions
|
113
|
+
font = self._get_font(style)
|
114
|
+
main_text = self.segment.text
|
115
|
+
main_width, main_height = self._get_text_dimensions(main_text, font)
|
116
|
+
rect_width = int(self.screen_config.video_width * 0.035) # 3.5% of video width
|
117
|
+
rect_height = int(self.screen_config.video_height * 0.04) # 4% of video height
|
118
|
+
text_left = self.screen_config.video_width//2 - main_width//2
|
119
|
+
|
120
|
+
self.logger.debug(f"Position calculations:")
|
121
|
+
self.logger.debug(f" Video dimensions: {self.screen_config.video_width}x{self.screen_config.video_height}")
|
122
|
+
self.logger.debug(f" Main text width: {main_width}px")
|
123
|
+
self.logger.debug(f" Main text height: {main_height}px")
|
124
|
+
self.logger.debug(f" Rectangle dimensions: {rect_width}x{rect_height}px")
|
125
|
+
self.logger.debug(f" Text left edge: {text_left}px")
|
126
|
+
self.logger.debug(f" Vertical position: {state.y_position}px")
|
127
|
+
|
128
|
+
# Create main indicator event
|
129
|
+
main_event = Event()
|
130
|
+
main_event.type = "Dialogue"
|
131
|
+
main_event.Layer = 0
|
132
|
+
main_event.Style = style
|
133
|
+
main_event.Start = appear_time
|
134
|
+
main_event.End = fade_out_end
|
135
|
+
|
136
|
+
# Calculate movement duration in milliseconds
|
137
|
+
move_duration = int((line_start - appear_time) * 1000)
|
138
|
+
|
139
|
+
# Create indicator rectangle aligned to bottom
|
140
|
+
main_text = (
|
141
|
+
f"{{\\an8}}" # center-bottom alignment
|
142
|
+
f"{{\\move(0,{state.y_position + main_height},{text_left},{state.y_position + main_height},0,{move_duration})}}" # Move until line start
|
143
|
+
f"{{\\c&HF77070&}}" # Same color as karaoke highlight
|
144
|
+
f"{{\\alpha&H4D&}}" # 70% opacity (FF=0%, 00=100%)
|
145
|
+
f"{{\\fad(800,500)}}" # 800ms fade in, 500ms fade out
|
146
|
+
f"{{\\p1}}m {-rect_width} {-rect_height} l 0 {-rect_height} 0 0 {-rect_width} 0{{\\p0}}" # Draw up from bottom
|
147
|
+
)
|
148
|
+
main_event.Text = main_text
|
149
|
+
|
150
|
+
return [main_event]
|
151
|
+
|
152
|
+
def create_ass_events(
|
153
|
+
self,
|
154
|
+
state: LineState,
|
155
|
+
style: Style,
|
156
|
+
config: ScreenConfig,
|
157
|
+
previous_end_time: Optional[float] = None
|
158
|
+
) -> List[Event]:
|
159
|
+
"""Create ASS events for this line. Returns [main_event] or [lead_in_event, main_event]."""
|
160
|
+
self.previous_end_time = previous_end_time
|
161
|
+
events = []
|
162
|
+
|
163
|
+
# Create lead-in event if needed
|
164
|
+
lead_in_event = self._create_lead_in_event(state, style, config.video_width, config)
|
165
|
+
if lead_in_event:
|
166
|
+
events.extend(lead_in_event)
|
167
|
+
|
168
|
+
# Create main lyrics event
|
169
|
+
main_event = Event()
|
170
|
+
main_event.type = "Dialogue"
|
171
|
+
main_event.Layer = 0
|
172
|
+
main_event.Style = style
|
173
|
+
main_event.Start = state.timing.fade_in_time
|
174
|
+
main_event.End = state.timing.end_time
|
175
|
+
|
176
|
+
# Use absolute positioning
|
177
|
+
x_pos = config.video_width // 2 # Center horizontally
|
178
|
+
|
179
|
+
# Main lyrics text with positioning and fade
|
180
|
+
text = (
|
181
|
+
f"{{\\an8}}{{\\pos({x_pos},{state.y_position})}}"
|
182
|
+
f"{{\\fad({config.fade_in_ms},{config.fade_out_ms})}}"
|
183
|
+
)
|
184
|
+
|
185
|
+
# Add the main lyrics text with karaoke timing
|
186
|
+
text += self._create_ass_text(timedelta(seconds=state.timing.fade_in_time))
|
187
|
+
|
188
|
+
main_event.Text = text
|
189
|
+
events.append(main_event)
|
190
|
+
|
191
|
+
return events
|
192
|
+
|
193
|
+
def _create_ass_text(self, start_ts: timedelta) -> str:
|
194
|
+
"""Create the ASS text with karaoke timing tags."""
|
195
|
+
# Initial delay before first word
|
196
|
+
first_word_time = self.segment.start_time
|
197
|
+
|
198
|
+
# Add initial delay for regular lines
|
199
|
+
start_time = max(0, (first_word_time - start_ts.total_seconds()) * 100)
|
200
|
+
text = r"{\k" + str(int(round(start_time))) + r"}"
|
201
|
+
|
202
|
+
prev_end_time = first_word_time
|
203
|
+
|
204
|
+
for word in self.segment.words:
|
205
|
+
# Add gap between words if needed
|
206
|
+
gap = word.start_time - prev_end_time
|
207
|
+
if gap > 0.1: # Only add gap if significant
|
208
|
+
text += r"{\k" + str(int(round(gap * 100))) + r"}"
|
209
|
+
|
210
|
+
# Add the word with its duration
|
211
|
+
duration = int(round((word.end_time - word.start_time) * 100))
|
212
|
+
text += r"{\kf" + str(duration) + r"}" + word.text + " "
|
213
|
+
|
214
|
+
prev_end_time = word.end_time # Track the actual end time of the word
|
215
|
+
|
216
|
+
return text.rstrip()
|
217
|
+
|
218
|
+
def __str__(self):
|
219
|
+
return f"{{{self.segment.text}}}"
|