karaoke-gen 0.57.0__py3-none-any.whl → 0.71.27__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.
Files changed (268) hide show
  1. karaoke_gen/audio_fetcher.py +461 -0
  2. karaoke_gen/audio_processor.py +407 -30
  3. karaoke_gen/config.py +62 -113
  4. karaoke_gen/file_handler.py +32 -59
  5. karaoke_gen/karaoke_finalise/karaoke_finalise.py +148 -67
  6. karaoke_gen/karaoke_gen.py +270 -61
  7. karaoke_gen/lyrics_processor.py +13 -1
  8. karaoke_gen/metadata.py +78 -73
  9. karaoke_gen/pipeline/__init__.py +87 -0
  10. karaoke_gen/pipeline/base.py +215 -0
  11. karaoke_gen/pipeline/context.py +230 -0
  12. karaoke_gen/pipeline/executors/__init__.py +21 -0
  13. karaoke_gen/pipeline/executors/local.py +159 -0
  14. karaoke_gen/pipeline/executors/remote.py +257 -0
  15. karaoke_gen/pipeline/stages/__init__.py +27 -0
  16. karaoke_gen/pipeline/stages/finalize.py +202 -0
  17. karaoke_gen/pipeline/stages/render.py +165 -0
  18. karaoke_gen/pipeline/stages/screens.py +139 -0
  19. karaoke_gen/pipeline/stages/separation.py +191 -0
  20. karaoke_gen/pipeline/stages/transcription.py +191 -0
  21. karaoke_gen/style_loader.py +531 -0
  22. karaoke_gen/utils/bulk_cli.py +6 -0
  23. karaoke_gen/utils/cli_args.py +424 -0
  24. karaoke_gen/utils/gen_cli.py +26 -261
  25. karaoke_gen/utils/remote_cli.py +1965 -0
  26. karaoke_gen/video_background_processor.py +351 -0
  27. karaoke_gen-0.71.27.dist-info/METADATA +610 -0
  28. karaoke_gen-0.71.27.dist-info/RECORD +275 -0
  29. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.27.dist-info}/WHEEL +1 -1
  30. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.27.dist-info}/entry_points.txt +1 -0
  31. lyrics_transcriber/__init__.py +10 -0
  32. lyrics_transcriber/cli/__init__.py +0 -0
  33. lyrics_transcriber/cli/cli_main.py +285 -0
  34. lyrics_transcriber/core/__init__.py +0 -0
  35. lyrics_transcriber/core/config.py +50 -0
  36. lyrics_transcriber/core/controller.py +520 -0
  37. lyrics_transcriber/correction/__init__.py +0 -0
  38. lyrics_transcriber/correction/agentic/__init__.py +9 -0
  39. lyrics_transcriber/correction/agentic/adapter.py +71 -0
  40. lyrics_transcriber/correction/agentic/agent.py +313 -0
  41. lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
  42. lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
  43. lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
  44. lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
  45. lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
  46. lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
  47. lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
  48. lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
  49. lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
  50. lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
  51. lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
  52. lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
  53. lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
  54. lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
  55. lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
  56. lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
  57. lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
  58. lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
  59. lyrics_transcriber/correction/agentic/models/enums.py +38 -0
  60. lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
  61. lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
  62. lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
  63. lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
  64. lyrics_transcriber/correction/agentic/models/utils.py +19 -0
  65. lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
  66. lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
  67. lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
  68. lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
  69. lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
  70. lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
  71. lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
  72. lyrics_transcriber/correction/agentic/providers/base.py +36 -0
  73. lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
  74. lyrics_transcriber/correction/agentic/providers/config.py +73 -0
  75. lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
  76. lyrics_transcriber/correction/agentic/providers/health.py +28 -0
  77. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
  78. lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
  79. lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
  80. lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
  81. lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
  82. lyrics_transcriber/correction/agentic/router.py +35 -0
  83. lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
  84. lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
  85. lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
  86. lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
  87. lyrics_transcriber/correction/anchor_sequence.py +1043 -0
  88. lyrics_transcriber/correction/corrector.py +760 -0
  89. lyrics_transcriber/correction/feedback/__init__.py +2 -0
  90. lyrics_transcriber/correction/feedback/schemas.py +107 -0
  91. lyrics_transcriber/correction/feedback/store.py +236 -0
  92. lyrics_transcriber/correction/handlers/__init__.py +0 -0
  93. lyrics_transcriber/correction/handlers/base.py +52 -0
  94. lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
  95. lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
  96. lyrics_transcriber/correction/handlers/llm.py +293 -0
  97. lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
  98. lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
  99. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
  100. lyrics_transcriber/correction/handlers/repeat.py +88 -0
  101. lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
  102. lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
  103. lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
  104. lyrics_transcriber/correction/handlers/word_operations.py +187 -0
  105. lyrics_transcriber/correction/operations.py +352 -0
  106. lyrics_transcriber/correction/phrase_analyzer.py +435 -0
  107. lyrics_transcriber/correction/text_utils.py +30 -0
  108. lyrics_transcriber/frontend/.gitignore +23 -0
  109. lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
  110. lyrics_transcriber/frontend/.yarnrc.yml +3 -0
  111. lyrics_transcriber/frontend/README.md +50 -0
  112. lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
  113. lyrics_transcriber/frontend/__init__.py +25 -0
  114. lyrics_transcriber/frontend/eslint.config.js +28 -0
  115. lyrics_transcriber/frontend/index.html +18 -0
  116. lyrics_transcriber/frontend/package.json +42 -0
  117. lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
  118. lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
  119. lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
  120. lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
  121. lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
  122. lyrics_transcriber/frontend/public/favicon.ico +0 -0
  123. lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
  124. lyrics_transcriber/frontend/src/App.tsx +212 -0
  125. lyrics_transcriber/frontend/src/api.ts +239 -0
  126. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
  127. lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
  128. lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
  129. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
  130. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
  131. lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
  132. lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
  133. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
  134. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
  135. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
  136. lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
  137. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
  138. lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
  139. lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
  140. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
  141. lyrics_transcriber/frontend/src/components/Header.tsx +387 -0
  142. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1373 -0
  143. lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
  144. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
  145. lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
  146. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
  147. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
  148. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +688 -0
  149. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
  150. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
  151. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
  152. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
  153. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
  154. lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
  155. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
  156. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
  157. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
  158. lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
  159. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
  160. lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
  161. lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
  162. lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
  163. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
  164. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
  165. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
  166. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
  167. lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
  168. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
  169. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
  170. lyrics_transcriber/frontend/src/main.tsx +17 -0
  171. lyrics_transcriber/frontend/src/theme.ts +177 -0
  172. lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
  173. lyrics_transcriber/frontend/src/types.js +2 -0
  174. lyrics_transcriber/frontend/src/types.ts +199 -0
  175. lyrics_transcriber/frontend/src/validation.ts +132 -0
  176. lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
  177. lyrics_transcriber/frontend/tsconfig.app.json +26 -0
  178. lyrics_transcriber/frontend/tsconfig.json +25 -0
  179. lyrics_transcriber/frontend/tsconfig.node.json +23 -0
  180. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
  181. lyrics_transcriber/frontend/update_version.js +11 -0
  182. lyrics_transcriber/frontend/vite.config.d.ts +2 -0
  183. lyrics_transcriber/frontend/vite.config.js +10 -0
  184. lyrics_transcriber/frontend/vite.config.ts +11 -0
  185. lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
  186. lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
  187. lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
  188. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js +42039 -0
  189. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
  191. lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
  192. lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
  193. lyrics_transcriber/frontend/web_assets/index.html +18 -0
  194. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
  195. lyrics_transcriber/frontend/yarn.lock +3752 -0
  196. lyrics_transcriber/lyrics/__init__.py +0 -0
  197. lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
  198. lyrics_transcriber/lyrics/file_provider.py +95 -0
  199. lyrics_transcriber/lyrics/genius.py +384 -0
  200. lyrics_transcriber/lyrics/lrclib.py +231 -0
  201. lyrics_transcriber/lyrics/musixmatch.py +156 -0
  202. lyrics_transcriber/lyrics/spotify.py +290 -0
  203. lyrics_transcriber/lyrics/user_input_provider.py +44 -0
  204. lyrics_transcriber/output/__init__.py +0 -0
  205. lyrics_transcriber/output/ass/__init__.py +21 -0
  206. lyrics_transcriber/output/ass/ass.py +2088 -0
  207. lyrics_transcriber/output/ass/ass_specs.txt +732 -0
  208. lyrics_transcriber/output/ass/config.py +180 -0
  209. lyrics_transcriber/output/ass/constants.py +23 -0
  210. lyrics_transcriber/output/ass/event.py +94 -0
  211. lyrics_transcriber/output/ass/formatters.py +132 -0
  212. lyrics_transcriber/output/ass/lyrics_line.py +265 -0
  213. lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
  214. lyrics_transcriber/output/ass/section_detector.py +89 -0
  215. lyrics_transcriber/output/ass/section_screen.py +106 -0
  216. lyrics_transcriber/output/ass/style.py +187 -0
  217. lyrics_transcriber/output/cdg.py +619 -0
  218. lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
  219. lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
  220. lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
  221. lyrics_transcriber/output/cdgmaker/config.py +151 -0
  222. lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
  223. lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
  224. lyrics_transcriber/output/cdgmaker/pack.py +507 -0
  225. lyrics_transcriber/output/cdgmaker/render.py +346 -0
  226. lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
  227. lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
  228. lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
  229. lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
  230. lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
  231. lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
  232. lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
  233. lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
  234. lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
  235. lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
  236. lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
  237. lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
  238. lyrics_transcriber/output/cdgmaker/utils.py +132 -0
  239. lyrics_transcriber/output/countdown_processor.py +267 -0
  240. lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
  241. lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
  242. lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
  243. lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
  244. lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
  245. lyrics_transcriber/output/fonts/arial.ttf +0 -0
  246. lyrics_transcriber/output/fonts/georgia.ttf +0 -0
  247. lyrics_transcriber/output/fonts/verdana.ttf +0 -0
  248. lyrics_transcriber/output/generator.py +257 -0
  249. lyrics_transcriber/output/lrc_to_cdg.py +61 -0
  250. lyrics_transcriber/output/lyrics_file.py +102 -0
  251. lyrics_transcriber/output/plain_text.py +96 -0
  252. lyrics_transcriber/output/segment_resizer.py +431 -0
  253. lyrics_transcriber/output/subtitles.py +397 -0
  254. lyrics_transcriber/output/video.py +544 -0
  255. lyrics_transcriber/review/__init__.py +0 -0
  256. lyrics_transcriber/review/server.py +676 -0
  257. lyrics_transcriber/storage/__init__.py +0 -0
  258. lyrics_transcriber/storage/dropbox.py +225 -0
  259. lyrics_transcriber/transcribers/__init__.py +0 -0
  260. lyrics_transcriber/transcribers/audioshake.py +290 -0
  261. lyrics_transcriber/transcribers/base_transcriber.py +157 -0
  262. lyrics_transcriber/transcribers/whisper.py +330 -0
  263. lyrics_transcriber/types.py +648 -0
  264. lyrics_transcriber/utils/__init__.py +0 -0
  265. lyrics_transcriber/utils/word_utils.py +27 -0
  266. karaoke_gen-0.57.0.dist-info/METADATA +0 -167
  267. karaoke_gen-0.57.0.dist-info/RECORD +0 -23
  268. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.27.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,180 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ class ScreenConfig:
5
+ """Configuration for screen timing and layout.
6
+
7
+ Lead-in Indicator Configuration:
8
+ lead_in_enabled: bool - Enable/disable the lead-in indicator entirely (default: True)
9
+ lead_in_width_percent: float - Width as percentage of screen width (default: 3.5)
10
+ lead_in_height_percent: float - Height as percentage of screen height (default: 4.0)
11
+ lead_in_opacity_percent: float - Opacity percentage, 0-100 (default: 70.0)
12
+ lead_in_outline_thickness: int - Outline thickness in pixels, 0 for no outline (default: 0)
13
+ lead_in_outline_color: str - Outline color in RGB format "R, G, B" (default: "0, 0, 0")
14
+ lead_in_gap_threshold: float - Minimum gap in seconds to show lead-in (default: 5.0)
15
+ lead_in_color: str - Fill color in RGB format "R, G, B" (default: "112, 112, 247")
16
+ lead_in_horiz_offset_percent: float - Horizontal offset as percentage of screen width, can be negative (default: 0.0)
17
+ lead_in_vert_offset_percent: float - Vertical offset as percentage of screen height, can be negative (default: 0.0)
18
+
19
+ Example JSON configuration:
20
+ {
21
+ "karaoke": {
22
+ "lead_in_enabled": true,
23
+ "lead_in_width_percent": 4.0,
24
+ "lead_in_height_percent": 5.0,
25
+ "lead_in_opacity_percent": 80,
26
+ "lead_in_outline_thickness": 2,
27
+ "lead_in_outline_color": "255, 255, 255",
28
+ "lead_in_gap_threshold": 3.0,
29
+ "lead_in_color": "230, 139, 33",
30
+ "lead_in_horiz_offset_percent": -2.0,
31
+ "lead_in_vert_offset_percent": 1.0
32
+ }
33
+ }
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ line_height: int = 50,
39
+ max_visible_lines: int = 4,
40
+ top_padding: int = None,
41
+ video_width: int = 640,
42
+ video_height: int = 360,
43
+ screen_gap_threshold: float = 5.0,
44
+ post_roll_time: float = 1.0,
45
+ fade_in_ms: int = 200,
46
+ fade_out_ms: int = 300,
47
+ lead_in_color: str = "112, 112, 247", # Default blue color in RGB format
48
+ text_case_transform: str = "none", # Options: "none", "uppercase", "lowercase", "propercase"
49
+ # New lead-in indicator configuration options
50
+ lead_in_enabled: bool = True,
51
+ lead_in_width_percent: float = 3.5,
52
+ lead_in_height_percent: float = 4.0,
53
+ lead_in_opacity_percent: float = 70.0,
54
+ lead_in_outline_thickness: int = 0,
55
+ lead_in_outline_color: str = "0, 0, 0",
56
+ lead_in_gap_threshold: float = 5.0,
57
+ lead_in_horiz_offset_percent: float = 0.0,
58
+ lead_in_vert_offset_percent: float = 0.0,
59
+ ):
60
+ # Screen layout
61
+ self.max_visible_lines = max_visible_lines
62
+ self.line_height = line_height
63
+ self.top_padding = top_padding if top_padding is not None else line_height
64
+ self.video_height = video_height
65
+ self.video_width = video_width
66
+ # Timing configuration
67
+ self.screen_gap_threshold = screen_gap_threshold
68
+ self.post_roll_time = post_roll_time
69
+ self.fade_in_ms = fade_in_ms
70
+ self.fade_out_ms = fade_out_ms
71
+ # Lead-in configuration
72
+ self.lead_in_color = lead_in_color
73
+ self.lead_in_enabled = lead_in_enabled
74
+ self.lead_in_width_percent = lead_in_width_percent
75
+ self.lead_in_height_percent = lead_in_height_percent
76
+ self.lead_in_opacity_percent = lead_in_opacity_percent
77
+ self.lead_in_outline_thickness = lead_in_outline_thickness
78
+ self.lead_in_outline_color = lead_in_outline_color
79
+ self.lead_in_gap_threshold = lead_in_gap_threshold
80
+ self.lead_in_horiz_offset_percent = lead_in_horiz_offset_percent
81
+ self.lead_in_vert_offset_percent = lead_in_vert_offset_percent
82
+ # Text formatting configuration
83
+ self.text_case_transform = text_case_transform
84
+
85
+ def get_lead_in_color_ass_format(self) -> str:
86
+ """Convert RGB lead-in color to ASS format.
87
+
88
+ Accepts either:
89
+ - RGB format: "112, 112, 247"
90
+ - ASS format: "&HF77070&" (for backward compatibility)
91
+
92
+ Returns ASS format color string.
93
+ """
94
+ color_str = self.lead_in_color.strip()
95
+
96
+ # If already in ASS format, return as-is
97
+ if color_str.startswith("&H") and color_str.endswith("&"):
98
+ return color_str
99
+
100
+ # Parse RGB format "R, G, B" or "R, G, B, A"
101
+ try:
102
+ parts = [int(x.strip()) for x in color_str.split(",")]
103
+ if len(parts) == 3:
104
+ r, g, b = parts
105
+ a = 255 # Default full opacity
106
+ elif len(parts) == 4:
107
+ r, g, b, a = parts
108
+ else:
109
+ raise ValueError(f"Invalid color format: {color_str}")
110
+
111
+ # Convert to ASS format: &H{alpha}{blue}{green}{red}&
112
+ # Note: alpha is inverted in ASS (255-a)
113
+ return f"&H{255-a:02X}{b:02X}{g:02X}{r:02X}&"
114
+
115
+ except (ValueError, TypeError) as e:
116
+ # Fallback to default blue if parsing fails
117
+ return "&HF77070&"
118
+
119
+ def get_lead_in_outline_color_ass_format(self) -> str:
120
+ """Convert RGB lead-in outline color to ASS format.
121
+
122
+ Accepts either:
123
+ - RGB format: "0, 0, 0"
124
+ - ASS format: "&H000000&" (for backward compatibility)
125
+
126
+ Returns ASS format color string.
127
+ """
128
+ color_str = self.lead_in_outline_color.strip()
129
+
130
+ # If already in ASS format, return as-is
131
+ if color_str.startswith("&H") and color_str.endswith("&"):
132
+ return color_str
133
+
134
+ # Parse RGB format "R, G, B" or "R, G, B, A"
135
+ try:
136
+ parts = [int(x.strip()) for x in color_str.split(",")]
137
+ if len(parts) == 3:
138
+ r, g, b = parts
139
+ a = 255 # Default full opacity
140
+ elif len(parts) == 4:
141
+ r, g, b, a = parts
142
+ else:
143
+ raise ValueError(f"Invalid color format: {color_str}")
144
+
145
+ # Convert to ASS format: &H{alpha}{blue}{green}{red}&
146
+ # Note: alpha is inverted in ASS (255-a)
147
+ return f"&H{255-a:02X}{b:02X}{g:02X}{r:02X}&"
148
+
149
+ except (ValueError, TypeError) as e:
150
+ # Fallback to default black if parsing fails
151
+ return "&H000000&"
152
+
153
+ def get_lead_in_opacity_ass_format(self) -> str:
154
+ """Convert opacity percentage to ASS alpha format.
155
+
156
+ Returns ASS alpha value (e.g., &H4D& for 70% opacity).
157
+ """
158
+ # ASS alpha is inverted: 0=opaque, 255=transparent
159
+ # Convert percentage to alpha value
160
+ alpha = int((100 - self.lead_in_opacity_percent) / 100 * 255)
161
+ return f"&H{alpha:02X}&"
162
+
163
+
164
+ @dataclass
165
+ class LineTimingInfo:
166
+ """Timing information for a single line."""
167
+
168
+ fade_in_time: float
169
+ end_time: float
170
+ fade_out_time: float
171
+ clear_time: float
172
+
173
+
174
+ @dataclass
175
+ class LineState:
176
+ """Complete state for a single line."""
177
+
178
+ text: str
179
+ timing: LineTimingInfo
180
+ 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,265 @@
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 >= self.screen_config.lead_in_gap_threshold)
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
+ # Check if lead-in is enabled
92
+ if not config.lead_in_enabled:
93
+ return None
94
+
95
+ # Check if there's a sufficient gap to show lead-in
96
+ if not (self.previous_end_time is None or
97
+ self.segment.start_time - self.previous_end_time >= config.lead_in_gap_threshold):
98
+ return None
99
+
100
+ self.logger.debug(f"Creating lead-in indicator for line: '{self.segment.text}'")
101
+
102
+ # Calculate all timing points
103
+ line_start = self.segment.start_time
104
+ appear_time = line_start - 3.0 # Start 3 seconds before line
105
+ fade_in_end = appear_time + 0.8 # 800ms fade in
106
+ fade_out_start = line_start - 0.3 # Start fade 300ms before reaching final position
107
+ fade_out_end = line_start + 0.2 # Complete fade 200ms after line starts (500ms total fade)
108
+
109
+ self.logger.debug(f"Timing calculations:")
110
+ self.logger.debug(f" Line starts at: {line_start:.2f}s")
111
+ self.logger.debug(f" Rectangle appears at: {appear_time:.2f}s")
112
+ self.logger.debug(f" Fade in completes at: {fade_in_end:.2f}s")
113
+ self.logger.debug(f" Fade out starts at: {fade_out_start:.2f}s")
114
+ self.logger.debug(f" Rectangle reaches final position at: {line_start:.2f}s")
115
+ self.logger.debug(f" Rectangle fully faded out at: {fade_out_end:.2f}s")
116
+
117
+ # Calculate dimensions and positions using configurable percentages
118
+ font = self._get_font(style)
119
+ # Apply case transformation to match the actual rendered text
120
+ main_text = self._apply_case_transform(self.segment.text)
121
+ main_width, main_height = self._get_text_dimensions(main_text, font)
122
+ rect_width = int(self.screen_config.video_width * (config.lead_in_width_percent / 100))
123
+ rect_height = int(self.screen_config.video_height * (config.lead_in_height_percent / 100))
124
+ # Calculate where the left edge of the centered text will be
125
+ text_left = self.screen_config.video_width//2 - main_width//2
126
+ # Apply horizontal offset if configured
127
+ horizontal_offset = int(self.screen_config.video_width * (config.lead_in_horiz_offset_percent / 100))
128
+ final_x_position = text_left + horizontal_offset
129
+ # Apply vertical offset if configured
130
+ vertical_offset = int(self.screen_config.video_height * (config.lead_in_vert_offset_percent / 100))
131
+ final_y_position = state.y_position + main_height + vertical_offset
132
+
133
+ self.logger.debug(f"Position calculations:")
134
+ self.logger.debug(f" Video dimensions: {self.screen_config.video_width}x{self.screen_config.video_height}")
135
+ self.logger.debug(f" Original text: '{self.segment.text}'")
136
+ self.logger.debug(f" Transformed text: '{main_text}'")
137
+ self.logger.debug(f" Main text width: {main_width}px")
138
+ self.logger.debug(f" Main text height: {main_height}px")
139
+ self.logger.debug(f" Rectangle dimensions: {rect_width}x{rect_height}px (from {config.lead_in_width_percent}% x {config.lead_in_height_percent}%)")
140
+ self.logger.debug(f" Text left edge: {text_left}px")
141
+ self.logger.debug(f" Horizontal offset: {horizontal_offset}px ({config.lead_in_horiz_offset_percent}% of screen width)")
142
+ self.logger.debug(f" Final X position: {final_x_position}px")
143
+ self.logger.debug(f" Vertical offset: {vertical_offset}px ({config.lead_in_vert_offset_percent}% of screen height)")
144
+ self.logger.debug(f" Final Y position: {final_y_position}px")
145
+ self.logger.debug(f" Vertical position: {state.y_position}px")
146
+
147
+ # Create main indicator event
148
+ main_event = Event()
149
+ main_event.type = "Dialogue"
150
+ main_event.Layer = 0
151
+ main_event.Style = style
152
+ main_event.Start = appear_time
153
+ main_event.End = fade_out_end
154
+
155
+ # Calculate movement duration in milliseconds
156
+ move_duration = int((line_start - appear_time) * 1000)
157
+
158
+ # Build the indicator rectangle text with configurable styling
159
+ main_text = (
160
+ f"{{\\an8}}" # center-bottom alignment
161
+ f"{{\\move(0,{final_y_position},{final_x_position},{final_y_position},0,{move_duration})}}" # Move until line start
162
+ f"{{\\c{config.get_lead_in_color_ass_format()}}}" # Configurable lead-in color in ASS format
163
+ f"{{\\alpha{config.get_lead_in_opacity_ass_format()}}}" # Configurable opacity
164
+ f"{{\\fad(800,500)}}" # 800ms fade in, 500ms fade out
165
+ )
166
+
167
+ # Add outline if thickness > 0
168
+ if config.lead_in_outline_thickness > 0:
169
+ main_text += (
170
+ f"{{\\3c{config.get_lead_in_outline_color_ass_format()}}}" # Outline color
171
+ f"{{\\bord{config.lead_in_outline_thickness}}}" # Outline thickness
172
+ )
173
+ else:
174
+ main_text += f"{{\\bord0}}" # No outline
175
+
176
+ # Add the rectangle shape
177
+ main_text += f"{{\\p1}}m {-rect_width} {-rect_height} l 0 {-rect_height} 0 0 {-rect_width} 0{{\\p0}}" # Draw up from bottom
178
+
179
+ main_event.Text = main_text
180
+
181
+ return [main_event]
182
+
183
+ def create_ass_events(
184
+ self,
185
+ state: LineState,
186
+ style: Style,
187
+ config: ScreenConfig,
188
+ previous_end_time: Optional[float] = None
189
+ ) -> List[Event]:
190
+ """Create ASS events for this line. Returns [main_event] or [lead_in_event, main_event]."""
191
+ self.previous_end_time = previous_end_time
192
+ events = []
193
+
194
+ # Create lead-in event if needed
195
+ lead_in_event = self._create_lead_in_event(state, style, config.video_width, config)
196
+ if lead_in_event:
197
+ events.extend(lead_in_event)
198
+
199
+ # Create main lyrics event
200
+ main_event = Event()
201
+ main_event.type = "Dialogue"
202
+ main_event.Layer = 0
203
+ main_event.Style = style
204
+ main_event.Start = state.timing.fade_in_time
205
+ main_event.End = state.timing.end_time
206
+
207
+ # Use absolute positioning
208
+ x_pos = config.video_width // 2 # Center horizontally
209
+
210
+ # Main lyrics text with positioning and fade
211
+ text = (
212
+ f"{{\\an8}}{{\\pos({x_pos},{state.y_position})}}"
213
+ f"{{\\fad({config.fade_in_ms},{config.fade_out_ms})}}"
214
+ )
215
+
216
+ # Add the main lyrics text with karaoke timing
217
+ text += self._create_ass_text(timedelta(seconds=state.timing.fade_in_time))
218
+
219
+ main_event.Text = text
220
+ events.append(main_event)
221
+
222
+ return events
223
+
224
+ def _apply_case_transform(self, text: str) -> str:
225
+ """Apply case transformation to text based on screen config setting."""
226
+ transform = getattr(self.screen_config, 'text_case_transform', 'none')
227
+
228
+ if transform == "uppercase":
229
+ return text.upper()
230
+ elif transform == "lowercase":
231
+ return text.lower()
232
+ elif transform == "propercase":
233
+ return text.title()
234
+ else: # "none" or any other value
235
+ return text
236
+
237
+ def _create_ass_text(self, start_ts: timedelta) -> str:
238
+ """Create the ASS text with karaoke timing tags."""
239
+ # Initial delay before first word
240
+ first_word_time = self.segment.start_time
241
+
242
+ # Add initial delay for regular lines
243
+ start_time = max(0, (first_word_time - start_ts.total_seconds()) * 100)
244
+ text = r"{\k" + str(int(round(start_time))) + r"}"
245
+
246
+ prev_end_time = first_word_time
247
+
248
+ for word in self.segment.words:
249
+ # Add gap between words if needed
250
+ gap = word.start_time - prev_end_time
251
+ if gap > 0.1: # Only add gap if significant
252
+ text += r"{\k" + str(int(round(gap * 100))) + r"}"
253
+
254
+ # Add the word with its duration
255
+ duration = int(round((word.end_time - word.start_time) * 100))
256
+ # Apply case transformation to the word text
257
+ transformed_text = self._apply_case_transform(word.text)
258
+ text += r"{\kf" + str(duration) + r"}" + transformed_text + " "
259
+
260
+ prev_end_time = word.end_time # Track the actual end time of the word
261
+
262
+ return text.rstrip()
263
+
264
+ def __str__(self):
265
+ return f"{{{self.segment.text}}}"