subtitld 0.0.0.dev0__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 (237) hide show
  1. subtitld/__init__.py +19 -0
  2. subtitld/__main__.py +240 -0
  3. subtitld/ftfy/char_classes.dat +0 -0
  4. subtitld/graphics/Montserrat-Black.ttf +0 -0
  5. subtitld/graphics/Montserrat-BlackItalic.ttf +0 -0
  6. subtitld/graphics/Montserrat-Bold.ttf +0 -0
  7. subtitld/graphics/Montserrat-BoldItalic.ttf +0 -0
  8. subtitld/graphics/Montserrat-ExtraBold.ttf +0 -0
  9. subtitld/graphics/Montserrat-ExtraBoldItalic.ttf +0 -0
  10. subtitld/graphics/Montserrat-ExtraLight.ttf +0 -0
  11. subtitld/graphics/Montserrat-ExtraLightItalic.ttf +0 -0
  12. subtitld/graphics/Montserrat-Italic.ttf +0 -0
  13. subtitld/graphics/Montserrat-Light.ttf +0 -0
  14. subtitld/graphics/Montserrat-LightItalic.ttf +0 -0
  15. subtitld/graphics/Montserrat-Medium.ttf +0 -0
  16. subtitld/graphics/Montserrat-MediumItalic.ttf +0 -0
  17. subtitld/graphics/Montserrat-Regular.ttf +0 -0
  18. subtitld/graphics/Montserrat-SemiBold.ttf +0 -0
  19. subtitld/graphics/Montserrat-SemiBoldItalic.ttf +0 -0
  20. subtitld/graphics/Montserrat-Thin.ttf +0 -0
  21. subtitld/graphics/Montserrat-ThinItalic.ttf +0 -0
  22. subtitld/graphics/Ubuntu-B.ttf +0 -0
  23. subtitld/graphics/Ubuntu-BI.ttf +0 -0
  24. subtitld/graphics/Ubuntu-C.ttf +0 -0
  25. subtitld/graphics/Ubuntu-L.ttf +0 -0
  26. subtitld/graphics/Ubuntu-LI.ttf +0 -0
  27. subtitld/graphics/Ubuntu-M.ttf +0 -0
  28. subtitld/graphics/Ubuntu-MI.ttf +0 -0
  29. subtitld/graphics/Ubuntu-R.ttf +0 -0
  30. subtitld/graphics/Ubuntu-RI.ttf +0 -0
  31. subtitld/graphics/Ubuntu-Th.ttf +0 -0
  32. subtitld/graphics/UbuntuMono-B.ttf +0 -0
  33. subtitld/graphics/UbuntuMono-BI.ttf +0 -0
  34. subtitld/graphics/UbuntuMono-R.ttf +0 -0
  35. subtitld/graphics/UbuntuMono-RI.ttf +0 -0
  36. subtitld/graphics/add_and_play.svg +4 -0
  37. subtitld/graphics/add_from_last.svg +4 -0
  38. subtitld/graphics/add_from_last_checked.svg +4 -0
  39. subtitld/graphics/add_subtitle_icon.svg +4 -0
  40. subtitld/graphics/add_subtitle_to_next_start.svg +4 -0
  41. subtitld/graphics/add_subtitle_to_next_start_checked.svg +4 -0
  42. subtitld/graphics/background_watermark.svg +9 -0
  43. subtitld/graphics/character_count_icon.svg +1 -0
  44. subtitld/graphics/checkbox_checked.svg +8 -0
  45. subtitld/graphics/checkbox_checked_hover.svg +8 -0
  46. subtitld/graphics/checkbox_disabled.svg +4 -0
  47. subtitld/graphics/checkbox_unchecked.svg +5 -0
  48. subtitld/graphics/checkbox_unchecked_hover.svg +5 -0
  49. subtitld/graphics/combobox_down_arrow.svg +4 -0
  50. subtitld/graphics/cps_icon.svg +1 -0
  51. subtitld/graphics/dialog_close_icon.svg +4 -0
  52. subtitld/graphics/dialog_close_icon_hover.svg +4 -0
  53. subtitld/graphics/dubbing_container_collapse_icon.svg +8 -0
  54. subtitld/graphics/dubbing_container_expand_icon.svg +4 -0
  55. subtitld/graphics/file.svg +4 -0
  56. subtitld/graphics/gap_add_icon.svg +4 -0
  57. subtitld/graphics/gap_remove_icon.svg +4 -0
  58. subtitld/graphics/grid_frames_icon.svg +4 -0
  59. subtitld/graphics/grid_frames_icon_checked.svg +4 -0
  60. subtitld/graphics/grid_frames_icon_disabled.svg +4 -0
  61. subtitld/graphics/grid_scenes_icon.svg +4 -0
  62. subtitld/graphics/grid_scenes_icon_checked.svg +4 -0
  63. subtitld/graphics/grid_scenes_icon_disabled.svg +4 -0
  64. subtitld/graphics/grid_seconds_icon.svg +4 -0
  65. subtitld/graphics/grid_seconds_icon_checked.svg +4 -0
  66. subtitld/graphics/grid_seconds_icon_disabled.svg +4 -0
  67. subtitld/graphics/last_end_to_current_position_icon.svg +4 -0
  68. subtitld/graphics/last_start_last_end_to_current_position_button.svg +9 -0
  69. subtitld/graphics/last_start_to_current_position_icon.svg +4 -0
  70. subtitld/graphics/left_panel_autosave.svg +4 -0
  71. subtitld/graphics/left_panel_dubbing.svg +4 -0
  72. subtitld/graphics/left_panel_export.svg +4 -0
  73. subtitld/graphics/left_panel_global.svg +4 -0
  74. subtitld/graphics/left_panel_import.svg +4 -0
  75. subtitld/graphics/left_panel_interface.svg +4 -0
  76. subtitld/graphics/left_panel_keyboard.svg +4 -0
  77. subtitld/graphics/left_panel_metadata.svg +4 -0
  78. subtitld/graphics/left_panel_navigation_background.svg +11 -0
  79. subtitld/graphics/left_panel_qualitycheck.svg +4 -0
  80. subtitld/graphics/left_panel_speakers.svg +4 -0
  81. subtitld/graphics/left_panel_subtitles.svg +1 -0
  82. subtitld/graphics/left_panel_transcription.svg +4 -0
  83. subtitld/graphics/left_panel_translation.svg +4 -0
  84. subtitld/graphics/merge_back_selected_subtitle_icon.svg +4 -0
  85. subtitld/graphics/merge_next_selected_subtitle_icon.svg +4 -0
  86. subtitld/graphics/move_backward_subtitle.svg +4 -0
  87. subtitld/graphics/move_end_back_subtitle.svg +4 -0
  88. subtitld/graphics/move_end_forward_subtitle.svg +4 -0
  89. subtitld/graphics/move_forward_subtitle.svg +4 -0
  90. subtitld/graphics/move_start_back_subtitle.svg +4 -0
  91. subtitld/graphics/move_start_forward_subtitle.svg +4 -0
  92. subtitld/graphics/music_voice_separation_music_icon.svg +4 -0
  93. subtitld/graphics/music_voice_separation_music_icon_disabled.svg +4 -0
  94. subtitld/graphics/music_voice_separation_voice_icon.svg +4 -0
  95. subtitld/graphics/music_voice_separation_voice_icon_disabled.svg +4 -0
  96. subtitld/graphics/new_line_icon.svg +1 -0
  97. subtitld/graphics/next_end_to_current_position_icon.svg +4 -0
  98. subtitld/graphics/next_start_next_end_to_current_position_button.svg +4 -0
  99. subtitld/graphics/next_start_to_current_position_icon.svg +4 -0
  100. subtitld/graphics/pause_icon.svg +12 -0
  101. subtitld/graphics/play_from_last_start_icon.svg +5 -0
  102. subtitld/graphics/play_from_next_start_icon.svg +7 -0
  103. subtitld/graphics/play_icon.svg +12 -0
  104. subtitld/graphics/play_icon_hover.svg +12 -0
  105. subtitld/graphics/play_icon_pressed.svg +12 -0
  106. subtitld/graphics/playback_repeat.svg +4 -0
  107. subtitld/graphics/playback_speed.svg +6 -0
  108. subtitld/graphics/playbackspeed_border.svg +5 -0
  109. subtitld/graphics/playbackspeed_border_checked.svg +5 -0
  110. subtitld/graphics/playbackspeed_border_hover.svg +5 -0
  111. subtitld/graphics/playercontrols_button_first.svg +5 -0
  112. subtitld/graphics/playercontrols_button_first_hover.svg +6 -0
  113. subtitld/graphics/playercontrols_button_first_pressed.svg +6 -0
  114. subtitld/graphics/playercontrols_button_last.svg +6 -0
  115. subtitld/graphics/playercontrols_button_last_hover.svg +6 -0
  116. subtitld/graphics/playercontrols_button_last_pressed.svg +6 -0
  117. subtitld/graphics/playercontrols_widget_center_bottom_line.svg +13 -0
  118. subtitld/graphics/playercontrols_widget_center_top_line.svg +17 -0
  119. subtitld/graphics/plus_icon.svg +4 -0
  120. subtitld/graphics/record_icon.svg +8 -0
  121. subtitld/graphics/record_icon_pressed.svg +5 -0
  122. subtitld/graphics/remove_selected_subtitle_icon.svg +6 -0
  123. subtitld/graphics/repeat_playback_border.svg +5 -0
  124. subtitld/graphics/repeat_playback_border_checked.svg +5 -0
  125. subtitld/graphics/repeat_playback_border_hover.svg +5 -0
  126. subtitld/graphics/save.svg +4 -0
  127. subtitld/graphics/send_text_to_last_subtitle_and_slice_button.svg +8 -0
  128. subtitld/graphics/send_text_to_last_subtitle_icon.svg +8 -0
  129. subtitld/graphics/send_text_to_next_subtitle_and_slice_button.svg +8 -0
  130. subtitld/graphics/send_text_to_next_subtitle_icon.svg +8 -0
  131. subtitld/graphics/slice_selected_subtitle_icon.svg +4 -0
  132. subtitld/graphics/snap_grid_icon.svg +4 -0
  133. subtitld/graphics/snap_grid_icon_checked.svg +4 -0
  134. subtitld/graphics/snap_grid_icon_disabled.svg +4 -0
  135. subtitld/graphics/snap_limits_icon.svg +4 -0
  136. subtitld/graphics/snap_limits_icon_checked.svg +4 -0
  137. subtitld/graphics/snap_limits_icon_disabled.svg +4 -0
  138. subtitld/graphics/snap_move_next.svg +4 -0
  139. subtitld/graphics/snap_move_next_checked.svg +4 -0
  140. subtitld/graphics/snap_move_next_disabled.svg +4 -0
  141. subtitld/graphics/snap_moving_icon.svg +4 -0
  142. subtitld/graphics/snap_moving_icon_checked.svg +4 -0
  143. subtitld/graphics/snap_moving_icon_disabled.svg +4 -0
  144. subtitld/graphics/speaker_list_item_change_color_icon.svg +4 -0
  145. subtitld/graphics/speaker_list_item_change_color_icon_hover.svg +4 -0
  146. subtitld/graphics/speaker_list_item_change_image_icon.svg +4 -0
  147. subtitld/graphics/speaker_list_item_change_image_icon_hover.svg +4 -0
  148. subtitld/graphics/speaker_list_item_export_icon.svg +4 -0
  149. subtitld/graphics/speaker_list_item_export_icon_hover.svg +4 -0
  150. subtitld/graphics/speaker_list_item_remove_icon.svg +4 -0
  151. subtitld/graphics/speaker_list_item_remove_icon_hover.svg +4 -0
  152. subtitld/graphics/speaker_list_item_rename_icon.svg +4 -0
  153. subtitld/graphics/speaker_list_item_rename_icon_hover.svg +4 -0
  154. subtitld/graphics/spinbox_arrow_down.svg +4 -0
  155. subtitld/graphics/spinbox_arrow_up.svg +4 -0
  156. subtitld/graphics/start_screen_top_shadow.svg +23 -0
  157. subtitld/graphics/stop_icon.svg +6 -0
  158. subtitld/graphics/stop_icon_hover.svg +6 -0
  159. subtitld/graphics/stop_icon_pressed.svg +6 -0
  160. subtitld/graphics/stylesheet.qss +1838 -0
  161. subtitld/graphics/subtitld.ico +0 -0
  162. subtitld/graphics/subtitld.png +0 -0
  163. subtitld/graphics/subtitles_panel_simplelist_properties_duration_timing_lock_closed_icon.svg +4 -0
  164. subtitld/graphics/subtitles_panel_simplelist_properties_duration_timing_lock_open_icon.svg +4 -0
  165. subtitld/graphics/subtitles_panel_simplelist_properties_duration_timing_lock_open_icon_hover.svg +4 -0
  166. subtitld/graphics/timeline_bottom_controls_background.svg +25 -0
  167. subtitld/graphics/timeline_cursor_back_frame.svg +4 -0
  168. subtitld/graphics/timeline_cursor_back_frame_hover.svg +4 -0
  169. subtitld/graphics/timeline_cursor_next_frame.svg +4 -0
  170. subtitld/graphics/timeline_cursor_next_frame_hover.svg +4 -0
  171. subtitld/graphics/timeline_show_speaker_color.svg +5 -0
  172. subtitld/graphics/timeline_show_speaker_color_checked.svg +5 -0
  173. subtitld/graphics/timeline_show_speaker_in_tracks.svg +4 -0
  174. subtitld/graphics/timeline_show_speaker_in_tracks_checked.svg +4 -0
  175. subtitld/graphics/timelinescrolling_follow_icon.svg +4 -0
  176. subtitld/graphics/timelinescrolling_follow_icon_checked.svg +4 -0
  177. subtitld/graphics/timelinescrolling_none_icon.svg +6 -0
  178. subtitld/graphics/timelinescrolling_none_icon_checked.svg +6 -0
  179. subtitld/graphics/timelinescrolling_page_icon.svg +4 -0
  180. subtitld/graphics/timelinescrolling_page_icon_checked.svg +4 -0
  181. subtitld/graphics/titleBar_left_container_border.svg +113 -0
  182. subtitld/graphics/unknown_speaker.svg +5 -0
  183. subtitld/graphics/vosk_transcription_download_model_icon.svg +4 -0
  184. subtitld/graphics/vosk_transcription_update_model_icon.svg +4 -0
  185. subtitld/graphics/word_count_icon.svg +1 -0
  186. subtitld/graphics/wpm_icon.svg +1 -0
  187. subtitld/graphics/zoom_in_icon.svg +4 -0
  188. subtitld/graphics/zoom_in_icon_disabled.svg +4 -0
  189. subtitld/graphics/zoom_out_icon.svg +4 -0
  190. subtitld/graphics/zoom_out_icon_disabled.svg +4 -0
  191. subtitld/interface/__init__.py +0 -0
  192. subtitld/interface/actionmanager.py +18 -0
  193. subtitld/interface/bottom_panel.py +43 -0
  194. subtitld/interface/left_panel.py +144 -0
  195. subtitld/interface/left_panel_autosave.py +149 -0
  196. subtitld/interface/left_panel_dubbing.py +469 -0
  197. subtitld/interface/left_panel_export.py +755 -0
  198. subtitld/interface/left_panel_global.py +358 -0
  199. subtitld/interface/left_panel_import.py +82 -0
  200. subtitld/interface/left_panel_interface.py +616 -0
  201. subtitld/interface/left_panel_keyboard.py +261 -0
  202. subtitld/interface/left_panel_metadata.py +74 -0
  203. subtitld/interface/left_panel_qualitycheck.py +262 -0
  204. subtitld/interface/left_panel_speakers.py +737 -0
  205. subtitld/interface/left_panel_subtitleslist.py +791 -0
  206. subtitld/interface/left_panel_transcription.py +763 -0
  207. subtitld/interface/left_panel_translation.py +294 -0
  208. subtitld/interface/playercontrols.py +2348 -0
  209. subtitld/interface/preview_panel.py +407 -0
  210. subtitld/interface/productionscreen.py +65 -0
  211. subtitld/interface/startscreen.py +273 -0
  212. subtitld/interface/timeline.py +1308 -0
  213. subtitld/interface/top_bar.py +329 -0
  214. subtitld/interface/translation.py +68 -0
  215. subtitld/interface/utils.py +286 -0
  216. subtitld/locale/en_US.json +301 -0
  217. subtitld/locale/pt_BR.json +269 -0
  218. subtitld/modules/__init__.py +0 -0
  219. subtitld/modules/audioengine.py +606 -0
  220. subtitld/modules/bounce.py +229 -0
  221. subtitld/modules/config.py +84 -0
  222. subtitld/modules/file_io.py +870 -0
  223. subtitld/modules/history.py +31 -0
  224. subtitld/modules/quality_check.py +53 -0
  225. subtitld/modules/session.py +270 -0
  226. subtitld/modules/shortcuts.py +84 -0
  227. subtitld/modules/subtitles.py +413 -0
  228. subtitld/modules/timecode.py +496 -0
  229. subtitld/modules/usf.py +366 -0
  230. subtitld/modules/utils.py +79 -0
  231. subtitld/modules/waveform.py +53 -0
  232. subtitld-0.0.0.dev0.dist-info/METADATA +97 -0
  233. subtitld-0.0.0.dev0.dist-info/RECORD +237 -0
  234. subtitld-0.0.0.dev0.dist-info/WHEEL +5 -0
  235. subtitld-0.0.0.dev0.dist-info/entry_points.txt +2 -0
  236. subtitld-0.0.0.dev0.dist-info/licenses/LICENSE +674 -0
  237. subtitld-0.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1308 @@
1
+ import hashlib
2
+ import os
3
+ from bisect import bisect
4
+ import numpy as np
5
+ import subprocess
6
+
7
+ from PySide6.QtWidgets import QWidget, QScrollArea, QSizePolicy
8
+ from PySide6.QtGui import QPainter, QPen, QColor, QFont, QPainterPath, QLinearGradient, QFontMetrics
9
+ from PySide6.QtCore import Qt, QRectF, QThread, Signal, QMarginsF, QTimer, QMargins
10
+
11
+ from subtitld.modules import session
12
+ from subtitld.modules import utils
13
+ from subtitld.modules import subtitles
14
+ from subtitld.modules import quality_check
15
+
16
+ from subtitld.interface import left_panel
17
+ from subtitld.interface import playercontrols
18
+ from subtitld.interface.translation import _
19
+
20
+
21
+ class TimelineScroll(QScrollArea):
22
+ """Class for timeline scroll area"""
23
+ def __init__(widget, parent=None):
24
+ super().__init__(parent)
25
+ # widget.setWidgetResizable(True)
26
+ widget.setObjectName('timeline_scroll')
27
+ widget.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
28
+ widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
29
+ widget.setSizePolicy(QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum))
30
+ widget.setMinimumHeight(70)
31
+
32
+ def enterEvent(widget, event):
33
+ event.accept()
34
+
35
+ def leaveEvent(widget, event):
36
+ event.accept()
37
+
38
+ def wheelEvent(widget, event):
39
+ if event.modifiers() & Qt.ControlModifier:
40
+ delta = event.angleDelta().y()
41
+ if delta != 0:
42
+ step = 10.0 if delta > 0 else -10.0
43
+ new_zoom = session.CONFIG.get('timeline_zoom', 100.0) + step
44
+ new_zoom = max(10.0, min(490.0, new_zoom))
45
+ if new_zoom != session.CONFIG.get('timeline_zoom', 100.0):
46
+ session.CONFIG['timeline_zoom'] = new_zoom
47
+ playercontrols.zoom_buttons_update(widget.window())
48
+ event.accept()
49
+ return
50
+ widget.horizontalScrollBar().setValue(widget.horizontalScrollBar().value() + event.angleDelta().y())
51
+ event.accept()
52
+
53
+ def resizeEvent(widget, event):
54
+ widget.window().timeline_widget.update_size()
55
+ event.accept()
56
+
57
+
58
+ class AudioLoaderThread(QThread):
59
+ """
60
+ Thread that loads audio samples and normalizes them for waveform visualization.
61
+ Emits (samples, samplerate) when done.
62
+ """
63
+ finished = Signal(object, int)
64
+
65
+ def __init__(self, samplerate=48000, parent=None):
66
+ super().__init__(parent)
67
+ self.filepath = None
68
+ self.target_samplerate = samplerate
69
+ self.proc = None
70
+ self._cancelled = False
71
+
72
+ def run(self):
73
+ try:
74
+ if self.filepath:
75
+ samples, samplerate = self._load_audio(self.filepath, self.target_samplerate)
76
+ if not self._cancelled:
77
+ self.finished.emit(samples, samplerate)
78
+ except Exception:
79
+ # You might emit a signal for error if needed
80
+ pass
81
+
82
+
83
+ def _load_audio(self, filepath, samplerate=48000):
84
+ cmd = [
85
+ session.FFMPEG_EXECUTABLE,
86
+ "-v", "error",
87
+ "-i", filepath,
88
+ "-ac", "1",
89
+ "-ar", str(samplerate),
90
+ "-f", "f32le", "-",
91
+ ]
92
+
93
+ # Use Popen to allow termination
94
+ self.proc = subprocess.Popen(
95
+ cmd,
96
+ stdout=subprocess.PIPE,
97
+ stderr=subprocess.PIPE,
98
+ startupinfo=session.STARTUPINFO
99
+ )
100
+
101
+ try:
102
+ stdout, _ = self.proc.communicate()
103
+ except Exception:
104
+ if self.proc:
105
+ self.proc.kill()
106
+ raise
107
+
108
+ if self._cancelled or self.proc.returncode not in (0, None):
109
+ return np.array([], dtype=np.float32), samplerate
110
+
111
+ raw = np.frombuffer(stdout, dtype=np.float32).copy()
112
+ if raw.size > 0:
113
+ raw /= np.max(np.abs(raw))
114
+ return raw, samplerate
115
+
116
+ def cancel(self):
117
+ """Terminate ffmpeg process if running and mark as cancelled."""
118
+ self._cancelled = True
119
+ if self.proc and self.proc.poll() is None: # still running
120
+ self.proc.terminate()
121
+ try:
122
+ self.proc.wait(timeout=1)
123
+ except subprocess.TimeoutExpired:
124
+ self.proc.kill()
125
+
126
+
127
+ class WaveformWorker(QThread):
128
+ """
129
+ Background worker to compute min/max buckets for a zoom level.
130
+ Emits (zoom_key, (mins, maxs, samples_per_bucket)) when done.
131
+ """
132
+ finished = Signal(object, object)
133
+
134
+ def __init__(self, samples, zoom_key, samples_per_bucket, parent=None):
135
+ super().__init__(parent)
136
+ self.samples = samples
137
+ self.zoom_key = zoom_key
138
+ self.samples_per_bucket = int(samples_per_bucket)
139
+
140
+ def run(self):
141
+ arr = np.asarray(self.samples, dtype=np.float32)
142
+ length = (len(arr) // self.samples_per_bucket) * self.samples_per_bucket
143
+ if length == 0:
144
+ mins = np.array([], dtype=np.float32)
145
+ maxs = np.array([], dtype=np.float32)
146
+ else:
147
+ arr = arr[:length]
148
+ buckets = arr.reshape(-1, self.samples_per_bucket)
149
+ mins = buckets.min(axis=1)
150
+ maxs = buckets.max(axis=1)
151
+ self.finished.emit(self.zoom_key, (mins, maxs, self.samples_per_bucket))
152
+
153
+
154
+ class WaveformManager:
155
+ def __init__(self, samples=None, filepath=None):
156
+ self.samples = None
157
+ self.filepath = filepath # Store filepath for cache key
158
+ self.levels = {} # zoom_key -> (mins, maxs, samples_per_bucket)
159
+ self.workers = {} # zoom_key -> worker thread
160
+ self.cache_dir = os.path.join(session.PATH_SUBTITLD_USER_CACHE, 'waveform')
161
+ if not os.path.exists(self.cache_dir):
162
+ os.makedirs(self.cache_dir)
163
+ if samples is not None:
164
+ self.set_samples(samples)
165
+
166
+
167
+ def _cache_path(self):
168
+ """Get the unified cache file path."""
169
+ if not self.cache_dir or not self.filepath:
170
+ return None
171
+ cache_key = utils.get_cache_key(self.filepath)
172
+ if not cache_key:
173
+ return None
174
+ return os.path.join(self.cache_dir, f"{cache_key}_waveform.npy")
175
+
176
+
177
+ def _load_from_cache(self):
178
+ """Load all cached data (samples + levels) from a single file."""
179
+ cache_path = self._cache_path()
180
+ if not cache_path or not os.path.exists(cache_path):
181
+ return False
182
+
183
+ try:
184
+ data = np.load(cache_path, allow_pickle=True).item()
185
+ self.samples = data.get('samples')
186
+ self.levels = data.get('levels', {})
187
+ return self.samples is not None
188
+ except Exception:
189
+ return False
190
+
191
+ def _save_to_cache(self):
192
+ """Save all data (samples + levels) to a single cache file."""
193
+ try:
194
+ os.makedirs(self.cache_dir, exist_ok=True)
195
+ cache_path = self._cache_path()
196
+ if cache_path and self.samples is not None:
197
+ data = {
198
+ 'samples': self.samples,
199
+ 'levels': self.levels
200
+ }
201
+ np.save(cache_path, data, allow_pickle=True)
202
+ except Exception:
203
+ pass # Silently fail if cache write fails
204
+
205
+ def set_samples(self, samples):
206
+ self.samples = np.asarray(samples, dtype=np.float32)
207
+ self._start_worker_if_missing(512)
208
+
209
+ def _start_worker_if_missing(self, zoom_key):
210
+ if zoom_key in self.levels or zoom_key in self.workers:
211
+ return
212
+ # No longer load individual levels from disk - all loaded at once via _load_from_cache
213
+ samples_per_bucket = zoom_key
214
+ worker = WaveformWorker(self.samples, zoom_key, samples_per_bucket)
215
+ worker.finished.connect(self._on_worker_finished)
216
+ self.workers[zoom_key] = worker
217
+ worker.start()
218
+
219
+ def _on_worker_finished(self, zoom_key, payload):
220
+ mins, maxs, samples_per_bucket = payload
221
+ # store; convert to numpy if possible for speed
222
+
223
+ mins = np.asarray(mins, dtype=np.float32)
224
+ maxs = np.asarray(maxs, dtype=np.float32)
225
+ self.levels[zoom_key] = (mins, maxs, samples_per_bucket)
226
+ # save to unified cache
227
+ self._save_to_cache()
228
+ # cleanup worker ref
229
+ if zoom_key in self.workers:
230
+ del self.workers[zoom_key]
231
+
232
+ def get_level(self, samples_per_pixel, start_sample=0, end_sample=None):
233
+ """
234
+ Get the best available level for a target samples_per_pixel (int).
235
+ - returns (mins_slice, maxs_slice, samples_per_bucket, available_zoom_key)
236
+ If level is not yet ready, returns the best coarser level slice (fallback).
237
+ Also triggers worker creation for exact level.
238
+ """
239
+ if self.samples is None:
240
+ return ([], [], 1, None)
241
+
242
+ target = max(1, int(samples_per_pixel))
243
+ # choose a zoom_key that we store = samples_per_bucket
244
+ # heuristic: pick power-of-two multiples
245
+ candidate = target
246
+ # normalize candidate to a reasonable bucket (power of two * base)
247
+ # simple normalization: round to nearest 1,2,4,8,16... * 128
248
+ base = 128
249
+ factor = max(1, int(candidate // base) or 1)
250
+ # find bucket as base * (lowest power of two >= factor)
251
+ p = 1
252
+ while p < factor:
253
+ p <<= 1
254
+ zoom_key = base * p
255
+
256
+ # ensure at least base baseline
257
+ if zoom_key < base:
258
+ zoom_key = base
259
+
260
+ # trigger generation for exact target (use target as zoom_key too), and for our normalised zoom_key
261
+ self._start_worker_if_missing(target)
262
+ self._start_worker_if_missing(zoom_key)
263
+
264
+ # choose best available level: prefer exact target, else nearest coarser (bigger samples_per_bucket)
265
+ available_keys = sorted(self.levels.keys())
266
+ if not available_keys:
267
+ # nothing ready yet — return empty fallback
268
+ return ([], [], 1, None)
269
+
270
+ # find smallest key >= target (coarser or equal)
271
+ chosen = None
272
+ for k in available_keys:
273
+ if k >= target:
274
+ chosen = k
275
+ break
276
+ if not chosen:
277
+ chosen = available_keys[-1]
278
+
279
+ mins, maxs, spb = self.levels[chosen]
280
+ # compute slices for start_sample:end_sample mapped to buckets
281
+ if end_sample is None:
282
+ end_sample = len(self.samples)
283
+ start_bucket = start_sample // spb
284
+ end_bucket = (end_sample // spb) + 1
285
+ # clamp
286
+ start_bucket = max(0, int(start_bucket))
287
+ end_bucket = min(len(mins), int(end_bucket))
288
+ mins_slice = mins[start_bucket:end_bucket]
289
+ maxs_slice = maxs[start_bucket:end_bucket]
290
+ return (mins_slice, maxs_slice, spb, chosen)
291
+
292
+ def ensure_level(self, samples_per_pixel):
293
+ self._start_worker_if_missing(int(max(1, samples_per_pixel)))
294
+
295
+
296
+ DUB_PEAKS_SAMPLERATE = 8000
297
+
298
+
299
+ class DubPeaksWorker(QThread):
300
+ """Loads a small dub WAV via ffmpeg and computes min/max peaks + duration."""
301
+ finished = Signal(str, object, object, float) # path, mins, maxs, duration_sec
302
+
303
+ def __init__(self, path, target_buckets=400, parent=None):
304
+ super().__init__(parent)
305
+ self.path = path
306
+ self.target_buckets = int(target_buckets)
307
+ self.proc = None
308
+
309
+ def run(self):
310
+ try:
311
+ cmd = [
312
+ session.FFMPEG_EXECUTABLE,
313
+ "-v", "error",
314
+ "-i", self.path,
315
+ "-ac", "1",
316
+ "-ar", str(DUB_PEAKS_SAMPLERATE),
317
+ "-f", "f32le", "-",
318
+ ]
319
+ self.proc = subprocess.Popen(
320
+ cmd,
321
+ stdout=subprocess.PIPE,
322
+ stderr=subprocess.PIPE,
323
+ startupinfo=session.STARTUPINFO,
324
+ )
325
+ stdout, _ = self.proc.communicate()
326
+ if self.proc.returncode not in (0, None):
327
+ return
328
+
329
+ samples = np.frombuffer(stdout, dtype=np.float32).copy()
330
+ if samples.size == 0:
331
+ return
332
+
333
+ duration = samples.size / float(DUB_PEAKS_SAMPLERATE)
334
+
335
+ peak = float(np.max(np.abs(samples)))
336
+ if peak > 0:
337
+ samples /= peak
338
+
339
+ spb = max(1, samples.size // self.target_buckets)
340
+ length = (samples.size // spb) * spb
341
+ buckets = samples[:length].reshape(-1, spb)
342
+ mins = buckets.min(axis=1).astype(np.float32)
343
+ maxs = buckets.max(axis=1).astype(np.float32)
344
+ self.finished.emit(self.path, mins, maxs, duration)
345
+ except Exception:
346
+ pass
347
+
348
+
349
+ class Timeline(QWidget):
350
+ seek = Signal(float)
351
+ subtitle_clicked = Signal()
352
+
353
+ def __init__(widget, parent=None):
354
+ super().__init__(parent)
355
+ widget.setSizePolicy(QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum))
356
+ widget.setMouseTracking(True)
357
+ widget.setObjectName('timeline_widget')
358
+ widget.setAutoFillBackground(False)
359
+ widget.subtitle_is_clicked = False
360
+ widget.subtitle_start_is_clicked = False
361
+ widget.subtitle_end_is_clicked = False
362
+ widget.subtitle_height = 50
363
+ widget.subtitle_y = 55
364
+ widget.offset = 0.0
365
+ widget.show_limiters = False
366
+ widget.show_tug_of_war = False
367
+ widget.tug_of_war_pressed = False
368
+ widget.is_cursor_pressing = False
369
+ widget.is_smart_splicing = False
370
+ widget.subtitle_under_the_cursor = False
371
+ widget.show_speaker_color = session.CONFIG['timeline'].get('show_speaker_color', False)
372
+ widget.show_speaker_tracks = session.CONFIG['timeline'].get('show_speaker_tracks', False)
373
+ widget.width_proportion = widget.width() / session.VIDEO.get('duration', 0.01)
374
+ widget.subtitle_alignment = {'left' : Qt.AlignLeft, 'center' : Qt.AlignCenter, 'right' : Qt.AlignRight}[session.CONFIG.get('default_values', {}).get('subtitle_alignment', 'left')]
375
+
376
+ widget.audio_thread = AudioLoaderThread()
377
+ widget.audio_thread.finished.connect(widget.on_waveform_loaded)
378
+
379
+ widget.waveform_manager = WaveformManager(filepath=session.VIDEO.get("filepath"))
380
+ widget.waveform_height = widget.height() - widget.subtitle_y - 10
381
+ widget.waveform_y = 45
382
+
383
+ widget.dub_peaks = {} # path -> (mins, maxs, duration)
384
+ widget.dub_workers = {} # path -> DubPeaksWorker
385
+ widget.dub_start_is_clicked = False
386
+ widget.dragging_dub = None
387
+ widget.dragging_dub_offset = 0.0
388
+
389
+ def paintEvent(widget, event):
390
+ if not widget.isVisible() or widget.width() <= 0 or widget.height() <= 0:
391
+ return
392
+
393
+ painter = QPainter(widget)
394
+ scroll_position = widget.parent().parent().horizontalScrollBar().value()
395
+ scroll_width = widget.parent().parent().width()
396
+
397
+ painter.setRenderHint(QPainter.Antialiasing)
398
+
399
+ grid_pen = QPen(QColor(session.CONFIG.get('timeline', {}).get('grid_color', '#336a7483')), 1, Qt.SolidLine)
400
+ painter.setFont(QFont('Ubuntu Mono', 8))
401
+
402
+ xpos = 0
403
+ for sec in range(int(session.VIDEO.get('duration', 60))):
404
+ if xpos >= scroll_position and xpos <= (scroll_position + widget.parent().parent().width()):
405
+ if (session.CONFIG.get('timeline_zoom', 1) > 75) or (session.CONFIG.get('timeline_zoom', 1) > 50 and session.CONFIG.get('timeline_zoom', 1) <= 75 and not int((sec % 2))) or (session.CONFIG.get('timeline_zoom', 1) > 25 and session.CONFIG.get('timeline_zoom', 1) <= 50 and not int((sec % 4))) or (session.CONFIG.get('timeline_zoom', 1) <= 25 and not int((sec % 8))):
406
+ lim_rect = QRectF(
407
+ xpos + 3,
408
+ 27,
409
+ 50,
410
+ 20
411
+ )
412
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('time_text_color', '#806a7483')))
413
+ painter.drawText(lim_rect, Qt.AlignLeft, utils.get_timeline_time_str(sec))
414
+
415
+ if session.CONFIG.get('timeline', {}).get('show_grid', False) and session.CONFIG.get('timeline', {}).get('grid_type', False) == 'seconds':
416
+ painter.setPen(grid_pen)
417
+ painter.drawLine(xpos, 0, xpos, widget.height())
418
+
419
+ xpos += widget.width_proportion
420
+
421
+ if session.CONFIG.get('timeline', {}).get('show_grid', False):
422
+ if session.CONFIG.get('timeline', {}).get('grid_type', False) == 'frames':
423
+ painter.setPen(grid_pen)
424
+ xpos = 0.0
425
+ for _ in range(int(session.VIDEO.get('duration', 60) * session.VIDEO['framerate'])):
426
+ if xpos >= scroll_position and xpos <= (scroll_position + widget.parent().parent().width()):
427
+ painter.drawLine(xpos, 0, xpos, widget.height())
428
+ xpos += widget.width_proportion / session.VIDEO['framerate']
429
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'scenes' and session.VIDEO['scenes']:
430
+ painter.setPen(grid_pen)
431
+ for scene in session.VIDEO['scenes']:
432
+ xpos = (scene * widget.width_proportion)
433
+ if xpos >= scroll_position and xpos <= (scroll_position + widget.parent().parent().width()):
434
+ painter.drawLine(xpos, 0, xpos, widget.height())
435
+
436
+ if session.REPEAT_DURATION_BUFFER:
437
+ rep_rect = QRectF(
438
+ session.REPEAT_DURATION_BUFFER[0][0] * widget.width_proportion,
439
+ 0,
440
+ (session.REPEAT_DURATION_BUFFER[0][1] - session.REPEAT_DURATION_BUFFER[0][0]) * widget.width_proportion,
441
+ widget.height()
442
+ )
443
+
444
+ grad = QLinearGradient(0, 0, 0, 100)
445
+ c1 = QColor(session.CONFIG.get('timeline', {}).get('cursor_color', '#ccff0000'))
446
+ c1.setAlpha(80)
447
+ grad.setColorAt(0, c1)
448
+ c2 = QColor(session.CONFIG.get('timeline', {}).get('cursor_color', '#ccff0000'))
449
+ c2.setAlpha(0)
450
+ grad.setColorAt(1, c2)
451
+
452
+ painter.fillRect(rep_rect, grad)
453
+
454
+ if widget.waveform_manager.samples is not None:
455
+ visible_start_sec = scroll_position / widget.width_proportion
456
+ visible_end_sec = (scroll_position + scroll_width) / widget.width_proportion
457
+ sr = session.VIDEO.get('samplerate', 48000)
458
+ start_sample = int(visible_start_sec * sr)
459
+ end_sample = int(visible_end_sec * sr)
460
+ samples_per_pixel = max(1, int(sr / widget.width_proportion)) # heuristic: how many samples map to one timeline pixel
461
+ mins, maxs, spb, chosen = widget.waveform_manager.get_level(samples_per_pixel, start_sample, end_sample)
462
+ if len(mins) > 0:
463
+ center = widget.waveform_y + (widget.waveform_height * 0.5)
464
+ scale = widget.waveform_height * 0.9 / 2.0 # scale factor to map normalized -1..1 to pixels
465
+ buckets_count = len(mins)
466
+ if buckets_count > 0:
467
+ pixel_per_bucket = (spb / sr) * widget.width_proportion
468
+ path = QPainterPath()
469
+ upper = []
470
+ x = start_sample_to_x = (start_sample / sr) * widget.width_proportion
471
+ for i in range(buckets_count):
472
+ val = float(maxs[i])
473
+ y = center - (val * scale)
474
+ upper.append((x, y))
475
+ x += pixel_per_bucket
476
+ lower = []
477
+ x = x - pixel_per_bucket # last x
478
+ for i in range(buckets_count - 1, -1, -1):
479
+ val = float(mins[i])
480
+ y = center - (val * scale)
481
+ lower.append((x, y))
482
+ x -= pixel_per_bucket
483
+ if upper:
484
+ path.moveTo(upper[0][0], upper[0][1])
485
+ for (xx, yy) in upper[1:]:
486
+ path.lineTo(xx, yy)
487
+ for (xx, yy) in lower:
488
+ path.lineTo(xx, yy)
489
+ path.closeSubpath()
490
+ painter.setPen(QPen(QColor(session.CONFIG.get('timeline', {}).get('waveform_border_color', '#ff153450')), 1))
491
+ painter.setBrush(QColor(session.CONFIG.get('timeline', {}).get('waveform_fill_color', '#cc153450')))
492
+ painter.drawPath(path)
493
+
494
+ if widget.show_speaker_tracks and widget.show_speaker_color:
495
+ for i, speaker in enumerate(list(session.SPEAKERS.keys())):
496
+ track_height = widget.subtitle_height / len(list(session.SPEAKERS.keys()))
497
+
498
+ grad = QLinearGradient(0, widget.subtitle_y + (track_height*i), 0, widget.subtitle_y + (track_height*i) + track_height)
499
+
500
+ c1 = QColor(session.SPEAKERS[speaker].get('color', '#b8cee0'))
501
+ c1.setAlpha(0)
502
+ grad.setColorAt(0, c1)
503
+ c2 = QColor(session.SPEAKERS[speaker].get('color', '#b8cee0'))
504
+ c2.setAlpha(15)
505
+ grad.setColorAt(1, c2)
506
+
507
+ track_rect = QRectF(
508
+ 0,
509
+ widget.subtitle_y + (track_height*i),
510
+ widget.width(),
511
+ track_height
512
+ )
513
+
514
+ painter.fillRect(track_rect, grad)
515
+ if session.SPEAKERS[speaker].get('image', None):
516
+ qimage = session.SPEAKERS[speaker]['image']
517
+ qimage_size = int(track_height * .3)
518
+ qimage = qimage.scaled(qimage_size, qimage_size)
519
+
520
+ x = scroll_position + 6
521
+ y = widget.subtitle_y + (track_height * (i+1)) - qimage_size + 6
522
+
523
+ painter.save()
524
+
525
+ path = QPainterPath()
526
+ path.addEllipse(x, y, qimage_size, qimage_size)
527
+ painter.setClipPath(path)
528
+
529
+ painter.drawImage(x, y, qimage)
530
+
531
+ painter.restore()
532
+
533
+
534
+ if session.SUBTITLE['segments']:
535
+ painter.setOpacity(1)
536
+ painter.setFont(QFont('Montserrat', 10))
537
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('time_text_color', '#304251')))
538
+
539
+ for subtitle in session.SUBTITLE['segments']:
540
+ if (subtitle['start'] / session.VIDEO.get('duration', 0.01)) > ((scroll_position + scroll_width) / widget.width()):
541
+ break
542
+ elif (subtitle['end']) / session.VIDEO.get('duration', 0.01) < (scroll_position / widget.width()):
543
+ continue
544
+ else:
545
+ if session.SUBTITLE.get('selected', False) == subtitle:
546
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_border_color', '#ff304251')))
547
+ painter.setBrush(QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_fill_color', '#cc3e5363')))
548
+ else:
549
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('subtitle_border_color', '#ff6a7483')))
550
+ painter.setBrush(QColor(session.CONFIG.get('timeline', {}).get('subtitle_fill_color', '#ccb8cee0')))
551
+
552
+ subtitle_track = [0, 1] # [index, number of tracks]
553
+ if widget.show_speaker_tracks and session.SPEAKERS:
554
+ speakers_keys = list(session.SPEAKERS.keys())
555
+ speaker_name = subtitle.get('speaker', 'A')
556
+ subtitle_track = [
557
+ speakers_keys.index(speaker_name) if speaker_name in speakers_keys else 0,
558
+ len(speakers_keys)
559
+ ]
560
+
561
+ subtitle_rect = QRectF(
562
+ subtitle['start'] * widget.width_proportion,
563
+ widget.subtitle_y + ((widget.subtitle_height / subtitle_track[1]) * subtitle_track[0]),
564
+ (subtitle['end'] - subtitle['start']) * widget.width_proportion,
565
+ widget.subtitle_height / subtitle_track[1]
566
+ )
567
+
568
+ painter.drawRoundedRect(subtitle_rect, 2.0, 2.0, Qt.AbsoluteSize)
569
+
570
+ if subtitle.get('dubbing'):
571
+ dub = subtitle['dubbing'][0]
572
+ dub_path = dub.get('path')
573
+ if dub_path:
574
+ widget._request_dub_peaks(dub_path)
575
+ peaks = widget.dub_peaks.get(dub_path)
576
+ if peaks is not None:
577
+ mins, maxs, duration = peaks
578
+ dub_start = dub.get('start', subtitle['start'])
579
+ dub_x = dub_start * widget.width_proportion
580
+ dub_w = duration * widget.width_proportion
581
+ if dub_w > 1:
582
+ show_translations = session.CONFIG['translation'].get('engine_options', {}).get('show_translations', False)
583
+ band_ratio = 0.25 if show_translations else 0.5
584
+ dub_inset = QRectF(
585
+ dub_x,
586
+ subtitle_rect.top() + subtitle_rect.height() * (1.0 - band_ratio),
587
+ dub_w,
588
+ subtitle_rect.height() * band_ratio - 2,
589
+ )
590
+ speaker_color = QColor(session.SPEAKERS.get(subtitle.get('speaker', 'A'), {}).get('color', '#1a73a8'))
591
+ border_color = QColor(speaker_color)
592
+ border_color.setAlpha(255)
593
+ fill_color = QColor(speaker_color)
594
+ fill_color.setAlpha(204)
595
+ painter.save()
596
+ painter.setPen(QPen(border_color, 1))
597
+ painter.setBrush(fill_color)
598
+ painter.drawRoundedRect(dub_inset, 2.0, 2.0, Qt.AbsoluteSize)
599
+
600
+ handle_w = 4
601
+ handle_rect = QRectF(dub_inset.left() + 1, dub_inset.top() + 2, handle_w, max(0.0, dub_inset.height() - 4))
602
+ painter.setPen(Qt.NoPen)
603
+ painter.setBrush(QColor(255, 255, 255, 180))
604
+ painter.drawRoundedRect(handle_rect, 1.5, 1.5, Qt.AbsoluteSize)
605
+
606
+ count = len(mins)
607
+ if count > 0:
608
+ painter.setClipRect(dub_inset)
609
+ center = dub_inset.center().y()
610
+ scale = dub_inset.height() * 0.45
611
+ pixel_per_bucket = dub_inset.width() / count
612
+ wf = QPainterPath()
613
+ upper = []
614
+ x = dub_inset.left()
615
+ for i in range(count):
616
+ upper.append((x, center - float(maxs[i]) * scale))
617
+ x += pixel_per_bucket
618
+ lower = []
619
+ x -= pixel_per_bucket
620
+ for i in range(count - 1, -1, -1):
621
+ lower.append((x, center - float(mins[i]) * scale))
622
+ x -= pixel_per_bucket
623
+ wf.moveTo(upper[0][0], upper[0][1])
624
+ for (xx, yy) in upper[1:]:
625
+ wf.lineTo(xx, yy)
626
+ for (xx, yy) in lower:
627
+ wf.lineTo(xx, yy)
628
+ wf.closeSubpath()
629
+ painter.setPen(Qt.NoPen)
630
+ painter.setBrush(QColor(session.CONFIG.get('timeline', {}).get('dub_waveform_color', '#ffffffff')))
631
+ painter.drawPath(wf)
632
+ painter.restore()
633
+
634
+ if widget.show_speaker_color and session.SPEAKERS.get(subtitle.get('speaker', 'A'), {}).get('color', None):
635
+ pen = QPen(QColor(session.SPEAKERS[subtitle.get('speaker', 'A')].get('color', '#b8cee0')))
636
+ pen.setWidth(4)
637
+ pen.setCapStyle(Qt.RoundCap)
638
+ painter.setPen(pen)
639
+ painter.drawLine(subtitle_rect.left() + 4, subtitle_rect.top() + 3, subtitle_rect.right() - 2, subtitle_rect.top() + 3)
640
+
641
+ if session.CONFIG.get('quality_check', {}).get('enabled', False):
642
+ approved, _, _ = quality_check.check_subtitle(subtitle)
643
+ if not approved:
644
+ painter.setPen(QColor('#9e1a1a'))
645
+ elif session.SUBTITLE.get('selected', False) == subtitle:
646
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_text_color', '#ffffffff')))
647
+ else:
648
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('subtitle_text_color', '#ff304251')))
649
+ else:
650
+ if session.SUBTITLE.get('selected', False) == subtitle:
651
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_text_color', '#b8cee0')))
652
+ else:
653
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('subtitle_text_color', '#304251')))
654
+
655
+ if subtitle.get('dubbing'):
656
+ subtitle_rect.setHeight(subtitle_rect.height() * 0.75)
657
+
658
+ subtitle_rect -= QMarginsF(26, 6, 26, 6)
659
+
660
+ painter.setFont(QFont('Montserrat', 10))
661
+
662
+ if session.CONFIG['translation'].get('engine_options', {}).get('show_translations', False):
663
+ original_subtitle_rect = subtitle_rect - QMarginsF(0, 0, 0, subtitle_rect.height()*.5)
664
+
665
+ if widget.is_smart_splicing and isinstance(widget.is_smart_splicing, dict) and (subtitle_rect.x() < widget.is_smart_splicing.get('position', original_subtitle_rect.x() + (original_subtitle_rect.width() / 2)) < (subtitle_rect.x() + subtitle_rect.width())):
666
+ pos = widget.is_smart_splicing.get('position', original_subtitle_rect.x() + (original_subtitle_rect.width() / 2))
667
+ if 'left' in widget.is_smart_splicing and 'right' in widget.is_smart_splicing:
668
+ left_side = widget.is_smart_splicing['left']
669
+ right_side = widget.is_smart_splicing['right']
670
+ painter.drawText(original_subtitle_rect - QMarginsF(0, 0, (left_side[0] * original_subtitle_rect.width()) + 5, 0), Qt.AlignRight | Qt.AlignTop | Qt.TextWordWrap, left_side[1])
671
+ painter.drawText(original_subtitle_rect - QMarginsF((right_side[0] * original_subtitle_rect.width()) + 5, 0, 0, 0), Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap, right_side[1])
672
+ painter.setPen(QColor("#1a000000"))
673
+ painter.drawLine(original_subtitle_rect.x() + ((1 - left_side[0]) * original_subtitle_rect.width()), subtitle_rect.top(), original_subtitle_rect.x() + ((1 - left_side[0]) * original_subtitle_rect.width()), subtitle_rect.bottom())
674
+ if widget.is_smart_splicing['mode'] == 'split':
675
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('subtitle_fill_color', '#ccb8cee0')))
676
+ painter.drawLine(pos, subtitle_rect.top() - 6, pos, subtitle_rect.bottom() + 6)
677
+ else:
678
+ # painter.drawText(original_subtitle_rect, Qt.AlignLeft | Qt.TextWordWrap, subtitle['text'])
679
+ painter.drawText(original_subtitle_rect - QMarginsF(0, 5, 0, 5), widget.subtitle_alignment | Qt.TextWordWrap, subtitle['text'])
680
+
681
+ translated_subtitle_rect = subtitle_rect - QMarginsF(0, subtitle_rect.height()*.5, 0, 0)
682
+
683
+ if session.SUBTITLE.get('selected', False) == subtitle:
684
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_text_color', '#b8cee0')))
685
+ else:
686
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('subtitle_text_color', '#304251')))
687
+
688
+ painter.drawText(translated_subtitle_rect - QMarginsF(0, 5, 0, 5), widget.subtitle_alignment | Qt.TextWordWrap, subtitle.get('translations', {}).get(session.CONFIG['translation'].get('engine_options', {}).get('target_language', 'en-us'), ''))
689
+
690
+ painter.setBrush(QColor(session.CONFIG.get('timeline', {}).get('subtitle_text_color', "#40304251")))
691
+ painter.setPen(Qt.NoPen)
692
+
693
+ r = 3
694
+
695
+ lfont = QFont('Montserrat', 6)
696
+ lfont.setBold(True)
697
+ painter.setFont(lfont)
698
+
699
+ language_name = session.CONFIG['translation'].get('engine_options', {}).get('target_language', 'en-us').upper()
700
+
701
+ lang_text_metrics = QFontMetrics(lfont).boundingRect(language_name)
702
+ lang_text_metrics += QMargins(1, 2, 1, 2)
703
+
704
+ badge = QPainterPath()
705
+ badge.moveTo(translated_subtitle_rect.left(), translated_subtitle_rect.top() - 1)
706
+ badge.lineTo(translated_subtitle_rect.right(), translated_subtitle_rect.top() - 1)
707
+ badge.lineTo(translated_subtitle_rect.right(), translated_subtitle_rect.top() + lang_text_metrics.height() - r)
708
+ badge.arcTo(translated_subtitle_rect.right() - (r*2), translated_subtitle_rect.top() + lang_text_metrics.height() - (r*2), (r*2), (r*2), 0, -90)
709
+ badge.lineTo(translated_subtitle_rect.right() - r, translated_subtitle_rect.top() + lang_text_metrics.height())
710
+ badge.arcTo(translated_subtitle_rect.right() - lang_text_metrics.width() - r, translated_subtitle_rect.top() + lang_text_metrics.height() - (r*2), (r*2), (r*2), -90, -90)
711
+ badge.lineTo(translated_subtitle_rect.right() - lang_text_metrics.width() - r, translated_subtitle_rect.top())
712
+ badge.lineTo(translated_subtitle_rect.left(), translated_subtitle_rect.top())
713
+ badge.closeSubpath()
714
+
715
+ painter.drawPath(badge)
716
+
717
+ painter.setPen(QColor('#80ffffff'))
718
+ painter.drawText(translated_subtitle_rect.right() - lang_text_metrics.width(), translated_subtitle_rect.top(), lang_text_metrics.width(), lang_text_metrics.height(), Qt.AlignLeft | Qt.AlignVCenter, language_name)
719
+ else:
720
+ original_subtitle_rect = subtitle_rect - QMarginsF(0, 5, 0, 5)
721
+ if widget.is_smart_splicing and isinstance(widget.is_smart_splicing, dict) and (subtitle_rect.x() < widget.is_smart_splicing.get('position', original_subtitle_rect.x() + (original_subtitle_rect.width() / 2)) < (subtitle_rect.x() + subtitle_rect.width())):
722
+ pos = widget.is_smart_splicing.get('position', original_subtitle_rect.x() + (original_subtitle_rect.width() / 2))
723
+ if 'left' in widget.is_smart_splicing and 'right' in widget.is_smart_splicing:
724
+ left_side = widget.is_smart_splicing['left']
725
+ right_side = widget.is_smart_splicing['right']
726
+ painter.drawText(original_subtitle_rect - QMarginsF(0, 0, (left_side[0] * original_subtitle_rect.width()) + 5, 0), Qt.AlignRight | Qt.AlignTop | Qt.TextWordWrap, left_side[1])
727
+ painter.drawText(original_subtitle_rect - QMarginsF((right_side[0] * original_subtitle_rect.width()) + 5, 0, 0, 0), Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap, right_side[1])
728
+ painter.setPen(QColor("#1a000000"))
729
+ painter.drawLine(original_subtitle_rect.x() + ((1 - left_side[0]) * original_subtitle_rect.width()), subtitle_rect.top(), original_subtitle_rect.x() + ((1 - left_side[0]) * original_subtitle_rect.width()), subtitle_rect.bottom())
730
+ if widget.is_smart_splicing['mode'] == 'split':
731
+ painter.setPen(QColor(session.CONFIG.get('timeline', {}).get('subtitle_fill_color', '#ccb8cee0')))
732
+ painter.drawLine(pos, subtitle_rect.top() - 6, pos, subtitle_rect.bottom() + 6)
733
+ else:
734
+ painter.drawText(original_subtitle_rect, widget.subtitle_alignment | Qt.TextWordWrap, subtitle['text'])
735
+
736
+ if subtitle == widget.subtitle_under_the_cursor and widget.show_limiters and ((subtitle['end'] - subtitle['start']) * widget.width_proportion) > 40:
737
+ if session.SUBTITLE.get('selected', False) == subtitle:
738
+ painter.setBrush(QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_fill_color', '#cc3e5363')))
739
+ else:
740
+ painter.setBrush(QColor(session.CONFIG.get('timeline', {}).get('subtitle_fill_color', '#ccb8cee0')))
741
+
742
+ painter.setPen(Qt.NoPen)
743
+ lim_rect = QRectF(
744
+ (subtitle['start'] * widget.width_proportion) + 2,
745
+ widget.subtitle_y + ((widget.subtitle_height / subtitle_track[1]) * subtitle_track[0]) + 2,
746
+ 18,
747
+ widget.subtitle_height / subtitle_track[1] - 4
748
+ )
749
+
750
+ painter.drawRoundedRect(lim_rect, 1.0, 1.0, Qt.AbsoluteSize)
751
+
752
+ lx = 1
753
+ for _ in range(2):
754
+ if session.SUBTITLE.get('selected', False) == subtitle:
755
+ lpen = QPen(QColor('#07000000') if lx % 2 else QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_arrow_color', '#ff969696')), 2)
756
+ else:
757
+ lpen = QPen(QColor('#07000000') if lx % 2 else QColor(session.CONFIG.get('timeline', {}).get('subtitle_arrow_color', '#ff969696')), 2)
758
+
759
+ painter.setPen(lpen)
760
+ painter.setBrush(Qt.NoBrush)
761
+ path = QPainterPath()
762
+ path.moveTo(lim_rect.center().x() + 2 + lx, lim_rect.center().y() - 10)
763
+ path.lineTo(lim_rect.center().x() - 1 + lx, lim_rect.center().y())
764
+ path.lineTo(lim_rect.center().x() + 2 + lx, lim_rect.center().y() + 10)
765
+ painter.drawPath(path)
766
+ lx -= 1
767
+
768
+ if session.SUBTITLE.get('selected', False) == subtitle:
769
+ painter.setBrush(QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_fill_color', '#cc3e5363')))
770
+ else:
771
+ painter.setBrush(QColor(session.CONFIG.get('timeline', {}).get('subtitle_fill_color', '#ccb8cee0')))
772
+
773
+ painter.setPen(Qt.NoPen)
774
+ lim_rect = QRectF(
775
+ (subtitle['start'] * widget.width_proportion) + ((subtitle['end'] - subtitle['start']) * widget.width_proportion) - 20,
776
+ widget.subtitle_y + ((widget.subtitle_height / subtitle_track[1]) * subtitle_track[0]) + 2,
777
+ 18,
778
+ widget.subtitle_height / subtitle_track[1] - 4
779
+ )
780
+
781
+ painter.drawRoundedRect(lim_rect, 1.0, 1.0, Qt.AbsoluteSize)
782
+
783
+ lx = 1
784
+ for _ in range(2):
785
+ if session.SUBTITLE.get('selected', False) == subtitle:
786
+ lpen = QPen(QColor('#07000000') if lx % 2 else QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_arrow_color', '#ff969696')), 2)
787
+ else:
788
+ lpen = QPen(QColor('#07000000') if lx % 2 else QColor(session.CONFIG.get('timeline', {}).get('subtitle_arrow_color', '#ff969696')), 2)
789
+
790
+ painter.setPen(lpen)
791
+ painter.setBrush(Qt.NoBrush)
792
+ path = QPainterPath()
793
+ path.moveTo(lim_rect.center().x() + lx, lim_rect.center().y() - 10)
794
+ path.lineTo(lim_rect.center().x() + 3 + lx, lim_rect.center().y())
795
+ path.lineTo(lim_rect.center().x() + lx, lim_rect.center().y() + 10)
796
+ painter.drawPath(path)
797
+ lx -= 1
798
+
799
+ painter.setOpacity(1)
800
+
801
+ if bool(widget.show_tug_of_war):
802
+ tug_of_war_pen = QPen(QColor(session.CONFIG.get('timeline', {}).get('selected_subtitle_arrow_color', '#ff969696')), 4, Qt.SolidLine, Qt.RoundCap)
803
+ painter.setPen(tug_of_war_pen)
804
+ xpos = int(widget.show_tug_of_war * widget.width_proportion) - 4
805
+ y_tug_pos = 0
806
+ for _ in range(6):
807
+ painter.drawLine(xpos, int(widget.subtitle_y + 8 + y_tug_pos), int(xpos + 8), int(widget.subtitle_y + 8 + y_tug_pos))
808
+ # painter.drawLine(xpos, widget.subtitle_y + 8 + y_tug_pos, xpos + 8, widget.subtitle_y + widget.subtitle_height - 8)
809
+ y_tug_pos += (widget.subtitle_height - 8) / 6
810
+
811
+ if session.SUBTITLE.get('position', 0) is not None:
812
+ painter.setPen(QPen(QColor(session.CONFIG.get('timeline', {}).get('cursor_color', '#ccff0000')), 2, Qt.SolidLine))
813
+ cursor_pos = int(session.SUBTITLE.get('position', 0) * widget.width_proportion)
814
+ painter.drawLine(cursor_pos, 0, cursor_pos, widget.height())
815
+
816
+ if (session.REPEAT_DURATION_BUFFER and session.SUBTITLE.get('position', 0) > session.REPEAT_DURATION_BUFFER[0][0]) or (not session.CONFIG['playback_speed'] == 1.0):
817
+ cfont = QFont('Ubuntu Mono', 10)
818
+ cfont.setBold(True)
819
+
820
+ text = ''
821
+ if session.CONFIG['repeat_activated']:
822
+ text += f'⤺{len(session.REPEAT_DURATION_BUFFER)}'
823
+
824
+ if not session.CONFIG['playback_speed'] == 1.0:
825
+ text += (' ' if text else '') + f"x{session.CONFIG['playback_speed']}"
826
+
827
+ cfont_metr = QFontMetrics(cfont).horizontalAdvance(text)
828
+
829
+ c_ind_color = QColor(session.CONFIG.get('timeline', {}).get('cursor_color', '#ccff0000'))
830
+ c_ind_color.setAlpha(150)
831
+ painter.setBrush(c_ind_color)
832
+ painter.setPen(Qt.NoPen)
833
+
834
+ path = QPainterPath()
835
+ path.moveTo(cursor_pos, 25)
836
+ path.lineTo(cursor_pos - cfont_metr - 12, 25)
837
+ path.lineTo(cursor_pos - cfont_metr - 7, 47)
838
+ path.lineTo(cursor_pos, 47)
839
+ path.lineTo(cursor_pos, 25)
840
+ painter.drawPath(path)
841
+
842
+ painter.setFont(cfont)
843
+ painter.setPen(QPen(QColor(session.CONFIG.get('timeline', {}).get('cursor_text_indicator', '#ffffffff'))))
844
+ text_rect = path.boundingRect() + QMarginsF(5, 0, 5, 0)
845
+ painter.drawText(text_rect, Qt.AlignCenter | Qt.TextWordWrap, text)
846
+
847
+ painter.end()
848
+ event.accept()
849
+
850
+ def mousePressEvent(widget, event):
851
+ dub_hit = widget._dub_handle_at_position(event.pos())
852
+ if dub_hit is not None:
853
+ _, dub = dub_hit
854
+ widget.dub_start_is_clicked = True
855
+ widget.dragging_dub = dub
856
+ widget.dragging_dub_offset = event.pos().x() - (dub.get('start', 0.0) * widget.width_proportion)
857
+ widget.is_cursor_pressing = True
858
+ event.accept()
859
+ return
860
+
861
+ scroll_position = widget.parent().parent().horizontalScrollBar().value()
862
+ scroll_width = widget.parent().parent().width()
863
+
864
+ cursor_is_out_of_view = bool(session.SUBTITLE.get('position', 0) * widget.width_proportion < widget.parent().parent().horizontalScrollBar().value() or session.SUBTITLE.get('position', 0) * widget.width_proportion > widget.parent().parent().width() + widget.parent().parent().horizontalScrollBar().value())
865
+
866
+ cursor_time_position = event.pos().x() / widget.width_proportion
867
+ widget.subtitle_under_the_cursor = subtitles.subtitle_under_current_position(position=cursor_time_position)
868
+
869
+ widget.is_cursor_pressing = True
870
+ session.SUBTITLE['selected'] = None
871
+
872
+ for subtitle in session.SUBTITLE['segments']:
873
+ if (subtitle['start'] / session.VIDEO.get('duration', 0.01)) > ((scroll_position + scroll_width) / widget.width()):
874
+ break
875
+ elif (subtitle['end']) / session.VIDEO.get('duration', 0.01) < (scroll_position / widget.width()):
876
+ continue
877
+ elif widget.subtitle_under_the_cursor:
878
+ subtitle_track = [0, 1]
879
+ if widget.show_speaker_tracks and session.SPEAKERS:
880
+ speakers_keys = list(session.SPEAKERS.keys())
881
+ speaker_name = widget.subtitle_under_the_cursor.get('speaker', 'A')
882
+ subtitle_track = [
883
+ speakers_keys.index(speaker_name) if speaker_name in speakers_keys else 0,
884
+ len(speakers_keys)
885
+ ]
886
+ y = widget.subtitle_y + ((widget.subtitle_height / subtitle_track[1]) * subtitle_track[0])
887
+ h = widget.subtitle_height / subtitle_track[1]
888
+ widget.show_limiters = bool(y < event.pos().y() < (y + h)) and widget._dub_hit_at_position(event.pos()) is None
889
+
890
+ if widget.show_limiters and subtitle == widget.subtitle_under_the_cursor:
891
+
892
+ # if event.pos().y() > widget.subtitle_y and event.pos().y() < (widget.subtitle_height + widget.subtitle_y) and (((event.pos().x()) / widget.width_proportion) > subtitle['start'] and ((event.pos().x()) / widget.width_proportion) < (subtitle['end'])):
893
+ session.SUBTITLE['selected'] = subtitle
894
+ if event.pos().x() / widget.width_proportion > (subtitle['end']) - (20 / widget.width_proportion):
895
+ widget.subtitle_end_is_clicked = True
896
+ widget.offset = ((session.SUBTITLE['selected']['end']) * widget.width_proportion) - event.pos().x()
897
+ widget.tug_of_war_pressed = widget.show_tug_of_war
898
+ else:
899
+ widget.offset = event.pos().x() - session.SUBTITLE['selected']['start'] * widget.width_proportion
900
+ if event.pos().x() / widget.width_proportion < subtitle['start'] + (20 / widget.width_proportion):
901
+ widget.subtitle_start_is_clicked = True
902
+ widget.tug_of_war_pressed = widget.show_tug_of_war
903
+ else:
904
+ widget.subtitle_is_clicked = True
905
+ break
906
+
907
+ if (widget.subtitle_is_clicked or widget.subtitle_start_is_clicked or widget.subtitle_end_is_clicked):
908
+ widget.subtitle_clicked.emit()
909
+ else:
910
+ session.SUBTITLE['position'] = (event.pos().x() / widget.width()) * session.VIDEO.get('duration', 60)
911
+ if session.CONFIG['repeat_activated']:
912
+ session.REPEAT_DURATION_BUFFER = []
913
+ widget.seek.emit(session.SUBTITLE.get('position', 0))
914
+
915
+ widget.update()
916
+
917
+ def mouseReleaseEvent(widget, event):
918
+ if widget.dub_start_is_clicked:
919
+ widget.dub_start_is_clicked = False
920
+ widget.dragging_dub = None
921
+ widget.is_cursor_pressing = False
922
+ session.set_unsaved()
923
+ widget.update()
924
+ event.accept()
925
+ return
926
+
927
+ if (widget.subtitle_is_clicked or widget.subtitle_start_is_clicked or widget.subtitle_end_is_clicked):
928
+ session.set_unsaved()
929
+ widget.subtitle_is_clicked = False
930
+ widget.subtitle_start_is_clicked = False
931
+ widget.subtitle_end_is_clicked = False
932
+ widget.is_cursor_pressing = False
933
+ widget.tug_of_war_pressed = False
934
+ subtitle_under_position = subtitles.subtitle_under_current_position(position=event.pos().x() / widget.width_proportion)
935
+ if widget.is_smart_splicing and widget.is_smart_splicing['mode'] == 'words':
936
+ widget.is_smart_splicing['boundaries'] = [
937
+ subtitle_under_position['start'] * widget.width_proportion,
938
+ subtitle_under_position['end'] * widget.width_proportion
939
+ ]
940
+ widget.is_smart_splicing['mode'] = 'split'
941
+ elif widget.is_smart_splicing and widget.is_smart_splicing['mode'] == 'split':
942
+ last_text = widget.is_smart_splicing['left'][1].strip()
943
+ next_text = widget.is_smart_splicing['right'][1].strip()
944
+ pos = widget.is_smart_splicing['position'] / widget.width_proportion
945
+ session.SUBTITLE['selected'] = subtitles.slice_subtitle(selected_subtitle=subtitle_under_position, position=pos, next_text=next_text, last_text=last_text)
946
+ left_panel.update(widget.window())
947
+ widget.setFocus(Qt.TabFocusReason)
948
+ session.set_unsaved()
949
+ widget.is_smart_splicing = False
950
+ playercontrols.slice_selected_subtitle_button_update(widget.window())
951
+ widget.update()
952
+ if not subtitle_under_position:
953
+ session.SUBTITLE['selected'] = None
954
+ left_panel.update(widget.window())
955
+ event.accept()
956
+
957
+ def mouseMoveEvent(widget, event):
958
+ if widget.dub_start_is_clicked and widget.dragging_dub is not None:
959
+ new_start = (event.pos().x() - widget.dragging_dub_offset) / widget.width_proportion
960
+ widget.dragging_dub['start'] = max(0.0, new_start)
961
+ widget.update()
962
+ return
963
+
964
+ if not widget.is_cursor_pressing:
965
+ if widget._dub_handle_at_position(event.pos()) is not None:
966
+ widget.setCursor(Qt.SizeHorCursor)
967
+ else:
968
+ widget.unsetCursor()
969
+
970
+ cursor_time_position = event.pos().x() / widget.width_proportion #(event.pos().x() / widget.width()) * session.VIDEO.get('duration', 60)
971
+ cursor_tug_of_war_range = 10 / widget.width_proportion
972
+
973
+ widget.subtitle_under_the_cursor = subtitles.subtitle_under_current_position(position=cursor_time_position)
974
+ last, next = subtitles.get_adjacent_subtitles(position=cursor_time_position)
975
+
976
+ if not widget.tug_of_war_pressed:
977
+ widget.show_tug_of_war = False
978
+
979
+ if widget.subtitle_under_the_cursor:
980
+ subtitle_track = [0, 1]
981
+ if widget.show_speaker_tracks and session.SPEAKERS:
982
+ speakers_keys = list(session.SPEAKERS.keys())
983
+ speaker_name = widget.subtitle_under_the_cursor.get('speaker', 'A')
984
+ subtitle_track = [
985
+ speakers_keys.index(speaker_name) if speaker_name in speakers_keys else 0,
986
+ len(speakers_keys)
987
+ ]
988
+ y = widget.subtitle_y + ((widget.subtitle_height / subtitle_track[1]) * subtitle_track[0])
989
+ h = widget.subtitle_height / subtitle_track[1]
990
+ widget.show_limiters = bool(y < event.pos().y() < (y + h)) and widget._dub_hit_at_position(event.pos()) is None
991
+
992
+ if widget.show_limiters:
993
+ if next and widget.subtitle_under_the_cursor['end'] - (cursor_tug_of_war_range * .5) < cursor_time_position < widget.subtitle_under_the_cursor['end'] + (cursor_tug_of_war_range*.5) and widget.subtitle_under_the_cursor['end'] + .001 > next['start'] - .02:
994
+ widget.show_tug_of_war = widget.subtitle_under_the_cursor['end'] + .0005
995
+
996
+ if last and widget.subtitle_under_the_cursor['start'] - (cursor_tug_of_war_range*.5) < cursor_time_position < widget.subtitle_under_the_cursor['start'] + (cursor_tug_of_war_range*.5) and widget.subtitle_under_the_cursor['start'] - .001 < last['end'] + .02:
997
+ widget.show_tug_of_war = widget.subtitle_under_the_cursor['start'] - .0005
998
+
999
+ if widget.is_smart_splicing:
1000
+ cursor_position_in_subtitle = (event.pos().x() - (widget.subtitle_under_the_cursor['start'] * widget.width_proportion))
1001
+ subtitle_width = ((widget.subtitle_under_the_cursor['end'] - widget.subtitle_under_the_cursor['start']) * widget.width_proportion)
1002
+
1003
+ number_of_characters = len(widget.subtitle_under_the_cursor['text'].replace(' ', ''))
1004
+ if isinstance(widget.is_smart_splicing, dict) and widget.is_smart_splicing['mode'] == 'words':
1005
+ character_width = subtitle_width / number_of_characters
1006
+ left_words = ''
1007
+ right_words = ''
1008
+ for word in widget.subtitle_under_the_cursor['text'].split():
1009
+ if len((left_words + word).replace(' ', '')) * character_width > cursor_position_in_subtitle:
1010
+ break
1011
+ left_words += ' ' + word
1012
+
1013
+ right_words = widget.subtitle_under_the_cursor['text'][len(left_words):]
1014
+ proportion = cursor_position_in_subtitle / subtitle_width
1015
+ widget.is_smart_splicing = {
1016
+ 'mode': 'words',
1017
+ 'position': event.pos().x(),
1018
+ 'left': [1 - proportion, left_words],
1019
+ 'right': [proportion, right_words]
1020
+ }
1021
+ elif isinstance(widget.is_smart_splicing, dict) and widget.is_smart_splicing['mode'] == 'split' and (widget.is_smart_splicing['boundaries'][0] <= event.pos().x() <= widget.is_smart_splicing['boundaries'][1]):
1022
+ widget.is_smart_splicing['position'] = event.pos().x()
1023
+
1024
+ if session.SUBTITLE.get('selected', None) is not None:
1025
+ i = session.SUBTITLE['segments'].index(session.SUBTITLE['selected'])
1026
+ last = session.SUBTITLE['segments'][session.SUBTITLE['segments'].index(session.SUBTITLE['selected']) - 1] if session.SUBTITLE['segments'].index(session.SUBTITLE['selected']) > 0 else {'start': 0, 'end': 0, 'text': ''}
1027
+ nextsub = session.SUBTITLE['segments'][session.SUBTITLE['segments'].index(session.SUBTITLE['selected']) + 1] if session.SUBTITLE['segments'].index(session.SUBTITLE['selected']) < len(session.SUBTITLE['segments']) - 1 else {'start': session.VIDEO.get('duration', 60), 'end': 0, 'text': ''}
1028
+ scenes_list = session.VIDEO['scenes'] if len(session.VIDEO['scenes']) > 1 else [0.0]
1029
+ scenes_list.append(session.VIDEO.get('duration', 60))
1030
+ start_position = (event.pos().x() - widget.offset) / widget.width_proportion
1031
+ last_scene = scenes_list[bisect(scenes_list, start_position) - 1]
1032
+ next_scene = scenes_list[bisect(scenes_list, start_position)]
1033
+
1034
+ if widget.subtitle_start_is_clicked:
1035
+ end = session.SUBTITLE['selected']['end']
1036
+ if not start_position > (end - session.CONFIG.get('default_values', {}).get('minimum_subtitle_width', 1)):
1037
+ if not (bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed) and round(last['end'] + .001, 3) == round(session.SUBTITLE['selected']['start'], 3)) and session.CONFIG.get('timeline', {}).get('snap', True) and session.CONFIG.get('timeline', {}).get('snap_limits', True) and (last['end'] + session.CONFIG.get('timeline', {}).get('snap_value', .1)) > start_position:
1038
+ subtitles.move_start_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=last['end'] + 0.001, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1039
+ elif session.CONFIG.get('timeline', {}).get('snap', True) and session.CONFIG.get('timeline', {}).get('snap_grid', False):
1040
+ if session.CONFIG.get('timeline', {}).get('grid_type', False) == 'frames':
1041
+ difference = start_position % (1.0 / session.VIDEO['framerate'])
1042
+ subtitles.move_start_subtitle(selected_subtitle=session.SUBTITLE['selected'], amount=difference, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1043
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'seconds' and float(start_position) > float(float(int(start_position) + 1) - float(session.CONFIG.get('timeline', {}).get('snap_value', .1))):
1044
+ subtitles.move_start_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=float(int(start_position) + 1), move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1045
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'seconds' and float(start_position) < float(float(int(start_position)) + float(session.CONFIG.get('timeline', {}).get('snap_value', .1))):
1046
+ subtitles.move_start_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=float(int(start_position)), move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1047
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'scenes' and start_position > next_scene - session.CONFIG.get('timeline', {}).get('snap_value', .1):
1048
+ subtitles.move_start_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=next_scene, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1049
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'scenes' and start_position < last_scene + session.CONFIG.get('timeline', {}).get('snap_value', .1):
1050
+ subtitles.move_start_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=last_scene, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1051
+ else:
1052
+ subtitles.move_start_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=start_position, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1053
+ else:
1054
+ subtitles.move_start_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=start_position, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1055
+ if widget.tug_of_war_pressed:
1056
+ widget.show_tug_of_war = session.SUBTITLE['selected']['start']
1057
+ elif widget.subtitle_end_is_clicked:
1058
+ end_position = (event.pos().x() + widget.offset) / widget.width_proportion
1059
+ if not end_position < (session.SUBTITLE['selected']['start'] + session.CONFIG.get('default_values', {}).get('minimum_subtitle_width', 1)):
1060
+ if not (bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed) and round(end_position, 3) >= round(nextsub['start'] - 0.001, 3)) and session.CONFIG.get('timeline', {}).get('snap', True) and session.CONFIG.get('timeline', {}).get('snap_limits', True) and (nextsub['start'] - session.CONFIG.get('timeline', {}).get('snap_value', .1)) < end_position:
1061
+ subtitles.move_end_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=(nextsub['start'] - 0.001), move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1062
+ elif session.CONFIG.get('timeline', {}).get('snap', True) and session.CONFIG.get('timeline', {}).get('snap_grid', False):
1063
+ if session.CONFIG.get('timeline', {}).get('grid_type', False) == 'frames':
1064
+ difference = end_position % (1.0 / session.VIDEO['framerate'])
1065
+ subtitles.move_end_subtitle(selected_subtitle=session.SUBTITLE['selected'], amount=difference, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1066
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'seconds' and float(end_position) > float(float(int(end_position) + 1) - float(session.CONFIG.get('timeline', {}).get('snap_value', .1))):
1067
+ subtitles.move_end_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=float(int(end_position) + 1), move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1068
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'seconds' and float(end_position) < float(float(int(end_position)) + float(session.CONFIG.get('timeline', {}).get('snap_value', .1))):
1069
+ subtitles.move_end_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=float(int(end_position)), move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1070
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'scenes' and end_position > next_scene - session.CONFIG.get('timeline', {}).get('snap_value', .1):
1071
+ subtitles.move_end_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=next_scene, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1072
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'scenes' and end_position < last_scene + session.CONFIG.get('timeline', {}).get('snap_value', .1):
1073
+ subtitles.move_end_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=last_scene, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1074
+ else:
1075
+ subtitles.move_end_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=end_position, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1076
+ else:
1077
+ subtitles.move_end_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=end_position, move_nereast=bool(session.CONFIG.get('timeline', {}).get('snap_move_nereast', False) or widget.tug_of_war_pressed))
1078
+ if widget.tug_of_war_pressed:
1079
+ widget.show_tug_of_war = session.SUBTITLE['selected']['end']
1080
+ elif widget.subtitle_is_clicked:
1081
+ if session.CONFIG.get('timeline', {}).get('snap', True) and session.CONFIG.get('timeline', {}).get('snap_moving', True) and ((nextsub['start'] - session.CONFIG.get('timeline', {}).get('snap_value', .1)) < (start_position + (session.SUBTITLE['selected']['end'] - session.SUBTITLE['selected']['start'])) or (last['end'] + session.CONFIG.get('timeline', {}).get('snap_value', .1)) > start_position):
1082
+ if (nextsub['start'] - session.CONFIG.get('timeline', {}).get('snap_value', .1)) < (start_position + (session.SUBTITLE['selected']['end'] - session.SUBTITLE['selected']['start'])):
1083
+ subtitles.move_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=nextsub['start'] - (session.SUBTITLE['selected']['end'] - session.SUBTITLE['selected']['start']) - 0.001)
1084
+ else:
1085
+ subtitles.move_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=last['end'] + 0.001)
1086
+ elif session.CONFIG.get('timeline', {}).get('snap', True) and session.CONFIG.get('timeline', {}).get('snap_grid', False):
1087
+ if session.CONFIG.get('timeline', {}).get('grid_type', False) == 'frames':
1088
+ difference = start_position % (1.0 / session.VIDEO['framerate'])
1089
+ subtitles.move_subtitle(selected_subtitle=session.SUBTITLE['selected'], amount=difference)
1090
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'seconds' and float(start_position) > float(float(int(start_position) + 1) - float(session.CONFIG.get('timeline', {}).get('snap_value', .1))):
1091
+ subtitles.move_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=float(int(start_position) + 1))
1092
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'seconds' and float(start_position) < float(float(int(start_position)) + float(session.CONFIG.get('timeline', {}).get('snap_value', .1))):
1093
+ subtitles.move_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=float(int(start_position)))
1094
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'scenes' and start_position > next_scene - session.CONFIG.get('timeline', {}).get('snap_value', .1):
1095
+ subtitles.move_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=next_scene)
1096
+ elif session.CONFIG.get('timeline', {}).get('grid_type', False) == 'scenes' and start_position < last_scene + session.CONFIG.get('timeline', {}).get('snap_value', .1):
1097
+ subtitles.move_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=last_scene)
1098
+ else:
1099
+ subtitles.move_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=start_position)
1100
+ else:
1101
+ subtitles.move_subtitle(selected_subtitle=session.SUBTITLE['selected'], absolute_time=start_position)
1102
+
1103
+
1104
+ if widget.is_cursor_pressing and not (widget.subtitle_start_is_clicked or widget.subtitle_end_is_clicked or widget.subtitle_is_clicked):
1105
+ session.SUBTITLE['position'] = (event.pos().x() / widget.width()) * session.VIDEO.get('duration', 60)
1106
+ widget.seek.emit(session.SUBTITLE.get('position', 0))
1107
+
1108
+ if widget.window().preview_panel_player.is_paused():
1109
+ widget.update()
1110
+
1111
+ def mouseDoubleClickEvent(widget, event):
1112
+ widget.update()
1113
+ event.accept()
1114
+
1115
+ def resizeEvent(widget, event):
1116
+ """Function to call when timeline is resized"""
1117
+ widget.width_proportion = widget.width() / session.VIDEO.get('duration', 0.01)
1118
+ widget.subtitle_height = widget.height() - 80
1119
+ widget.waveform_height = widget.height() - widget.subtitle_y - 10
1120
+ event.accept()
1121
+
1122
+ def update_size(widget):
1123
+ widget.setGeometry(0, 0, int(session.VIDEO.get('duration', 0.01) * session.CONFIG.get('timeline_zoom', 1)), widget.parent().parent().height())
1124
+
1125
+ def load_waveform(widget):
1126
+ filepath = session.VIDEO.get("filepath")
1127
+ if not filepath:
1128
+ return
1129
+
1130
+ # Update waveform manager filepath for cache key
1131
+ widget.waveform_manager.filepath = filepath
1132
+
1133
+ # Try to load everything from cache (samples + levels)
1134
+ if widget.waveform_manager._load_from_cache():
1135
+ # Cache hit - start worker for base zoom level if needed
1136
+ widget.waveform_manager._start_worker_if_missing(512)
1137
+ QTimer.singleShot(100, lambda: widget.update())
1138
+ return
1139
+
1140
+ # Cache miss - load audio from file
1141
+ widget.audio_thread.filepath = filepath
1142
+ widget.audio_thread.start()
1143
+
1144
+ def on_waveform_loaded(widget, samples, samplerate):
1145
+ session.VIDEO["samplerate"] = samplerate
1146
+ widget.waveform_manager.set_samples(samples)
1147
+ # Save samples to cache (levels will be saved as workers complete)
1148
+ widget.waveform_manager._save_to_cache()
1149
+ QTimer.singleShot(1000, lambda: widget.update())
1150
+
1151
+ def _dub_hit_at_position(widget, pos, handle_only=False):
1152
+ """Return (subtitle, dub) if pos is over a dub clip; if handle_only, restrict to the leftmost 8px."""
1153
+ show_translations = session.CONFIG['translation'].get('engine_options', {}).get('show_translations', False)
1154
+ band_ratio = 0.25 if show_translations else 0.5
1155
+ speakers = list(session.SPEAKERS.keys()) if widget.show_speaker_tracks and session.SPEAKERS else []
1156
+
1157
+ for subtitle in session.SUBTITLE['segments']:
1158
+ if not subtitle.get('dubbing'):
1159
+ continue
1160
+ dub = subtitle['dubbing'][0]
1161
+ dub_path = dub.get('path')
1162
+ if not dub_path:
1163
+ continue
1164
+ peaks = widget.dub_peaks.get(dub_path)
1165
+ if peaks is None:
1166
+ continue
1167
+ _, _, duration = peaks
1168
+
1169
+ dub_x = dub.get('start', subtitle['start']) * widget.width_proportion
1170
+ hit_w = 8 if handle_only else duration * widget.width_proportion
1171
+
1172
+ if speakers:
1173
+ speaker_name = subtitle.get('speaker', 'A')
1174
+ track_index = speakers.index(speaker_name) if speaker_name in speakers else 0
1175
+ track_count = len(speakers)
1176
+ else:
1177
+ track_index, track_count = 0, 1
1178
+ bar_y = widget.subtitle_y + ((widget.subtitle_height / track_count) * track_index)
1179
+ bar_h = widget.subtitle_height / track_count
1180
+ top = bar_y + bar_h * (1.0 - band_ratio)
1181
+ bottom = bar_y + bar_h - 2
1182
+
1183
+ if dub_x <= pos.x() <= dub_x + hit_w and top <= pos.y() <= bottom:
1184
+ return (subtitle, dub)
1185
+
1186
+ return None
1187
+
1188
+ def _dub_handle_at_position(widget, pos):
1189
+ return widget._dub_hit_at_position(pos, handle_only=True)
1190
+
1191
+ def _dub_cache_path(widget, path):
1192
+ cache_dir = os.path.join(session.PATH_SUBTITLD_USER_CACHE, 'waveform')
1193
+ os.makedirs(cache_dir, exist_ok=True)
1194
+ stem = os.path.splitext(os.path.basename(path))[0]
1195
+ return os.path.join(cache_dir, f'dub_{stem}_peaks.npy')
1196
+
1197
+ def _request_dub_peaks(widget, path):
1198
+ if path in widget.dub_peaks or path in widget.dub_workers:
1199
+ return
1200
+ cache_file = widget._dub_cache_path(path)
1201
+ if cache_file and os.path.exists(cache_file):
1202
+ try:
1203
+ data = np.load(cache_file, allow_pickle=True).item()
1204
+ if 'duration' in data:
1205
+ widget.dub_peaks[path] = (data['mins'], data['maxs'], float(data['duration']))
1206
+ return
1207
+ except Exception:
1208
+ pass
1209
+ if not os.path.exists(path):
1210
+ return
1211
+ worker = DubPeaksWorker(path)
1212
+ worker.finished.connect(widget._on_dub_peaks_ready)
1213
+ widget.dub_workers[path] = worker
1214
+ worker.start()
1215
+
1216
+ def _on_dub_peaks_ready(widget, path, mins, maxs, duration):
1217
+ widget.dub_peaks[path] = (mins, maxs, duration)
1218
+ widget.dub_workers.pop(path, None)
1219
+ cache_file = widget._dub_cache_path(path)
1220
+ if cache_file:
1221
+ try:
1222
+ np.save(cache_file, {'mins': mins, 'maxs': maxs, 'duration': duration}, allow_pickle=True)
1223
+ except Exception:
1224
+ pass
1225
+ widget.update()
1226
+
1227
+
1228
+ def update_subtitles_panel_subtitle_selected(self):
1229
+ self.subtitles_panel_qlistwidget.update_content()
1230
+ left_panel.update(self)
1231
+
1232
+ def load(self):
1233
+ self.timeline_scroll = TimelineScroll()
1234
+
1235
+ self.timeline_widget = Timeline()
1236
+ self.timeline_widget.setObjectName('timeline_widget')
1237
+ self.timeline_widget.seek.connect(lambda pos: self.preview_panel_player.seek(pos))
1238
+ self.timeline_widget.subtitle_clicked.connect(lambda: update_subtitles_panel_subtitle_selected(self))
1239
+
1240
+ self.preview_panel_player.position_changed_signal.connect(lambda: self.timeline_widget.update())
1241
+
1242
+ self.timeline_scroll.setWidget(self.timeline_widget)
1243
+
1244
+
1245
+ def update_scrollbar(self, position=0):
1246
+ """Function to update scrollbar of timeline"""
1247
+ current_position_in_timeline_widget = (session.SUBTITLE.get('position', 0) / session.VIDEO.get('duration', 0.01)) * self.timeline_widget.width()
1248
+ offset = 0
1249
+
1250
+ if position == 'middle':
1251
+ if (self.timeline_widget.width() - (self.timeline_scroll.width() * .5)) > current_position_in_timeline_widget > self.timeline_scroll.width() * .5:
1252
+ offset = self.timeline_scroll.width() * .5
1253
+ elif isinstance(position, float):
1254
+ offset = self.timeline_scroll.width() * position
1255
+ elif isinstance(position, int):
1256
+ offset = position
1257
+
1258
+ self.timeline_scroll.horizontalScrollBar().setValue(int(current_position_in_timeline_widget - offset))
1259
+
1260
+
1261
+ def update(self):
1262
+ """Function to update timeline"""
1263
+ if session.CONFIG['repeat_activated']:
1264
+ if not session.REPEAT_DURATION_BUFFER:
1265
+ session.REPEAT_DURATION_BUFFER = [[session.SUBTITLE.get('position', 0), session.SUBTITLE.get('position', 0) + session.CONFIG['playback_repeat_duration']] for i in range(session.CONFIG['playback_repeat_times'])]
1266
+ else:
1267
+ last_pos = session.REPEAT_DURATION_BUFFER[0][1]
1268
+ if session.SUBTITLE.get('position', 0) > last_pos:
1269
+ self.preview_panel_player.set_position(session.REPEAT_DURATION_BUFFER[0][0])
1270
+ # self.timeline_widget.seek.emit(session.SUBTITLE.get('position', 0))
1271
+ del session.REPEAT_DURATION_BUFFER[0]
1272
+ if not len(session.REPEAT_DURATION_BUFFER):
1273
+ for i in range(session.CONFIG['playback_repeat_times']):
1274
+ session.REPEAT_DURATION_BUFFER.append([last_pos, last_pos + session.CONFIG['playback_repeat_duration']])
1275
+
1276
+ if not self.preview_panel_player.is_paused():
1277
+ current_position_in_timeline_widget = (session.SUBTITLE.get('position', 0) * (self.timeline_widget.width() / session.VIDEO.get('duration', 0.01)))
1278
+ if session.CONFIG.get('timeline', {}).get('scrolling', 'page') == 'follow':
1279
+ update_scrollbar(self, position='middle')
1280
+ elif session.CONFIG.get('timeline', {}).get('scrolling', 'page') == 'page' and current_position_in_timeline_widget > self.timeline_scroll.width() + self.timeline_scroll.horizontalScrollBar().value():
1281
+ update_scrollbar(self)
1282
+
1283
+ self.timeline_widget.update()
1284
+
1285
+
1286
+ def load_audio_for_timeline(filepath: str, samplerate: int = 48000):
1287
+ """
1288
+ Uses ffmpeg to decode audio into mono float32 PCM for waveform visualization.
1289
+ Requires ffmpeg installed in PATH.
1290
+ """
1291
+ cmd = [
1292
+ session.FFMPEG_EXECUTABLE, "-v", "error", "-i", filepath,
1293
+ "-ac", "1", # mono
1294
+ "-ar", str(samplerate), # resample rate
1295
+ "-f", "f32le", "-" # raw 32-bit float to stdout
1296
+ ]
1297
+ proc = subprocess.run(
1298
+ cmd,
1299
+ stdout=subprocess.PIPE,
1300
+ stderr=subprocess.DEVNULL,
1301
+ check=True,
1302
+ startupinfo=session.STARTUPINFO,
1303
+ )
1304
+ raw = np.frombuffer(proc.stdout, dtype=np.float32).copy()
1305
+ # Normalize
1306
+ if raw.size > 0:
1307
+ raw /= np.max(np.abs(raw))
1308
+ return raw, samplerate