talks-reducer 0.6.1__py3-none-any.whl → 0.7.0__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.
@@ -0,0 +1,269 @@
1
+ """Theme utilities shared by the Tkinter GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from typing import Any, Callable, Mapping, Sequence
7
+
8
+ STATUS_COLORS = {
9
+ "idle": "#9ca3af",
10
+ "waiting": "#9ca3af",
11
+ "processing": "#af8e0e",
12
+ "success": "#178941",
13
+ "error": "#ad4f4f",
14
+ "aborted": "#6d727a",
15
+ }
16
+
17
+ LIGHT_THEME = {
18
+ "background": "#f5f5f5",
19
+ "foreground": "#1f2933",
20
+ "accent": "#2563eb",
21
+ "surface": "#ffffff",
22
+ "border": "#cbd5e1",
23
+ "hover": "#efefef",
24
+ "hover_text": "#000000",
25
+ "selection_background": "#2563eb",
26
+ "selection_foreground": "#ffffff",
27
+ }
28
+
29
+ DARK_THEME = {
30
+ "background": "#1e1e28",
31
+ "foreground": "#f3f4f6",
32
+ "accent": "#60a5fa",
33
+ "surface": "#2b2b3c",
34
+ "border": "#4b5563",
35
+ "hover": "#333333",
36
+ "hover_text": "#ffffff",
37
+ "selection_background": "#333333",
38
+ "selection_foreground": "#f3f4f6",
39
+ }
40
+
41
+
42
+ RegistryReader = Callable[[str, str], int]
43
+ DefaultsRunner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
44
+
45
+
46
+ def read_windows_theme_registry(key_path: str, value_name: str) -> int:
47
+ """Read *value_name* from the registry key at *key_path*."""
48
+
49
+ import winreg # type: ignore
50
+
51
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as key:
52
+ value, _ = winreg.QueryValueEx(key, value_name)
53
+ return int(value)
54
+
55
+
56
+ def run_defaults_command(args: Sequence[str]) -> subprocess.CompletedProcess[str]:
57
+ """Execute the macOS ``defaults`` command used to detect theme."""
58
+
59
+ return subprocess.run(args, capture_output=True, text=True, check=False)
60
+
61
+
62
+ def detect_system_theme(
63
+ env: Mapping[str, str],
64
+ platform: str,
65
+ registry_reader: RegistryReader,
66
+ defaults_runner: DefaultsRunner,
67
+ ) -> str:
68
+ """Detect the system theme for the provided *platform* and environment."""
69
+
70
+ if platform.startswith("win"):
71
+ try:
72
+ value = registry_reader(
73
+ r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
74
+ "AppsUseLightTheme",
75
+ )
76
+ return "light" if int(value) else "dark"
77
+ except OSError:
78
+ return "light"
79
+
80
+ if platform == "darwin":
81
+ try:
82
+ result = defaults_runner(["defaults", "read", "-g", "AppleInterfaceStyle"])
83
+ except Exception:
84
+ return "light"
85
+ if result.returncode == 0 and result.stdout.strip().lower() == "dark":
86
+ return "dark"
87
+ return "light"
88
+
89
+ theme = env.get("GTK_THEME", "").lower()
90
+ if "dark" in theme:
91
+ return "dark"
92
+ return "light"
93
+
94
+
95
+ def apply_theme(
96
+ style: Any,
97
+ palette: Mapping[str, str],
98
+ widgets: Mapping[str, Any],
99
+ ) -> Mapping[str, str]:
100
+ """Apply *palette* to *style* and update GUI *widgets*."""
101
+
102
+ root = widgets.get("root")
103
+ if root is not None:
104
+ root.configure(bg=palette["background"])
105
+
106
+ style.theme_use("clam")
107
+ style.configure(
108
+ ".", background=palette["background"], foreground=palette["foreground"]
109
+ )
110
+ style.configure("TFrame", background=palette["background"])
111
+ style.configure(
112
+ "TLabelframe",
113
+ background=palette["background"],
114
+ foreground=palette["foreground"],
115
+ borderwidth=0,
116
+ relief="flat",
117
+ )
118
+ style.configure(
119
+ "TLabelframe.Label",
120
+ background=palette["background"],
121
+ foreground=palette["foreground"],
122
+ )
123
+ style.configure(
124
+ "TLabel", background=palette["background"], foreground=palette["foreground"]
125
+ )
126
+ style.configure(
127
+ "TCheckbutton",
128
+ background=palette["background"],
129
+ foreground=palette["foreground"],
130
+ )
131
+ style.map(
132
+ "TCheckbutton",
133
+ background=[("active", palette.get("hover", palette["background"]))],
134
+ )
135
+ style.configure(
136
+ "TRadiobutton",
137
+ background=palette["background"],
138
+ foreground=palette["foreground"],
139
+ )
140
+ style.map(
141
+ "TRadiobutton",
142
+ background=[("active", palette.get("hover", palette["background"]))],
143
+ )
144
+ style.configure(
145
+ "Link.TButton",
146
+ background=palette["background"],
147
+ foreground=palette["accent"],
148
+ borderwidth=0,
149
+ relief="flat",
150
+ highlightthickness=0,
151
+ padding=2,
152
+ font=("TkDefaultFont", 8, "underline"),
153
+ )
154
+ style.map(
155
+ "Link.TButton",
156
+ background=[
157
+ ("active", palette.get("hover", palette["background"])),
158
+ ("disabled", palette["background"]),
159
+ ],
160
+ foreground=[
161
+ ("active", palette.get("accent", palette["foreground"])),
162
+ ("disabled", palette["foreground"]),
163
+ ],
164
+ )
165
+ style.configure(
166
+ "TButton",
167
+ background=palette["surface"],
168
+ foreground=palette["foreground"],
169
+ padding=4,
170
+ font=("TkDefaultFont", 8),
171
+ )
172
+ style.map(
173
+ "TButton",
174
+ background=[
175
+ ("active", palette.get("hover", palette["accent"])),
176
+ ("disabled", palette["surface"]),
177
+ ],
178
+ foreground=[
179
+ ("active", palette.get("hover_text", "#000000")),
180
+ ("disabled", palette["foreground"]),
181
+ ],
182
+ )
183
+ style.configure(
184
+ "TEntry",
185
+ fieldbackground=palette["surface"],
186
+ foreground=palette["foreground"],
187
+ )
188
+ style.configure(
189
+ "TCombobox",
190
+ fieldbackground=palette["surface"],
191
+ foreground=palette["foreground"],
192
+ )
193
+
194
+ style.configure(
195
+ "Idle.Horizontal.TProgressbar",
196
+ background=STATUS_COLORS["idle"],
197
+ troughcolor=palette["surface"],
198
+ borderwidth=0,
199
+ thickness=20,
200
+ )
201
+ style.configure(
202
+ "Processing.Horizontal.TProgressbar",
203
+ background=STATUS_COLORS["processing"],
204
+ troughcolor=palette["surface"],
205
+ borderwidth=0,
206
+ thickness=20,
207
+ )
208
+ style.configure(
209
+ "Success.Horizontal.TProgressbar",
210
+ background=STATUS_COLORS["success"],
211
+ troughcolor=palette["surface"],
212
+ borderwidth=0,
213
+ thickness=20,
214
+ )
215
+ style.configure(
216
+ "Error.Horizontal.TProgressbar",
217
+ background=STATUS_COLORS["error"],
218
+ troughcolor=palette["surface"],
219
+ borderwidth=0,
220
+ thickness=20,
221
+ )
222
+ style.configure(
223
+ "Aborted.Horizontal.TProgressbar",
224
+ background=STATUS_COLORS["aborted"],
225
+ troughcolor=palette["surface"],
226
+ borderwidth=0,
227
+ thickness=20,
228
+ )
229
+
230
+ drop_zone = widgets.get("drop_zone")
231
+ if drop_zone is not None:
232
+ drop_zone.configure(
233
+ bg=palette["surface"],
234
+ fg=palette["foreground"],
235
+ highlightthickness=0,
236
+ )
237
+
238
+ tk_module = widgets.get("tk")
239
+ slider_relief = getattr(tk_module, "FLAT", "flat") if tk_module else "flat"
240
+ sliders = widgets.get("sliders") or []
241
+ for slider in sliders:
242
+ slider.configure(
243
+ background=palette["border"],
244
+ troughcolor=palette["surface"],
245
+ activebackground=palette["border"],
246
+ sliderrelief=slider_relief,
247
+ bd=0,
248
+ )
249
+
250
+ log_text = widgets.get("log_text")
251
+ if log_text is not None:
252
+ log_text.configure(
253
+ bg=palette["surface"],
254
+ fg=palette["foreground"],
255
+ insertbackground=palette["foreground"],
256
+ highlightbackground=palette["border"],
257
+ highlightcolor=palette["border"],
258
+ )
259
+
260
+ status_label = widgets.get("status_label")
261
+ if status_label is not None:
262
+ status_label.configure(bg=palette["background"])
263
+
264
+ apply_status_style = widgets.get("apply_status_style")
265
+ status_state = widgets.get("status_state")
266
+ if callable(apply_status_style) and status_state is not None:
267
+ apply_status_style(status_state)
268
+
269
+ return palette
talks_reducer/models.py CHANGED
@@ -29,7 +29,7 @@ def default_temp_folder() -> Path:
29
29
  else:
30
30
  base = Path(tempfile.gettempdir()) / "talks-reducer"
31
31
 
32
- return base / "temp"
32
+ return base / "talks-reducer-temp"
33
33
 
34
34
 
35
35
  @dataclass(frozen=True)
talks_reducer/pipeline.py CHANGED
@@ -23,103 +23,39 @@ from .ffmpeg import (
23
23
  )
24
24
  from .models import ProcessingOptions, ProcessingResult
25
25
  from .progress import NullProgressReporter, ProgressReporter
26
+ from talks_reducer.version_utils import resolve_version
26
27
 
27
28
 
28
- def _input_to_output_filename(filename: Path, small: bool = False) -> Path:
29
- dot_index = filename.name.rfind(".")
30
- suffix_parts = []
31
-
32
- if small:
33
- suffix_parts.append("_small")
34
-
35
- if not suffix_parts:
36
- suffix_parts.append("") # Default case
37
-
38
- suffix = "_speedup" + "".join(suffix_parts)
39
- new_name = (
40
- filename.name[:dot_index] + suffix + filename.name[dot_index:]
41
- if dot_index != -1
42
- else filename.name + suffix
43
- )
44
- return filename.with_name(new_name)
45
-
46
-
47
- def _create_path(path: Path) -> None:
48
- try:
49
- path.mkdir(parents=True, exist_ok=True)
50
- except OSError as exc: # pragma: no cover - defensive logging
51
- raise AssertionError(
52
- "Creation of the directory failed. (The TEMP folder may already exist. Delete or rename it, and try again.)"
53
- ) from exc
54
-
55
-
56
- def _delete_path(path: Path) -> None:
57
- import time
58
- from shutil import rmtree
59
-
60
- try:
61
- rmtree(path, ignore_errors=False)
62
- for i in range(5):
63
- if not path.exists():
64
- return
65
- time.sleep(0.01 * i)
66
- except OSError as exc: # pragma: no cover - defensive logging
67
- print(f"Deletion of the directory {path} failed")
68
- print(exc)
69
-
70
-
71
- def _extract_video_metadata(input_file: Path, frame_rate: float) -> Dict[str, float]:
72
- from .ffmpeg import get_ffprobe_path
73
-
74
- ffprobe_path = get_ffprobe_path()
75
- command = [
76
- ffprobe_path,
77
- "-i",
78
- os.fspath(input_file),
79
- "-hide_banner",
80
- "-loglevel",
81
- "error",
82
- "-select_streams",
83
- "v",
84
- "-show_entries",
85
- "format=duration:stream=avg_frame_rate,nb_frames",
86
- ]
87
- process = subprocess.Popen(
88
- command,
89
- stdout=subprocess.PIPE,
90
- stderr=subprocess.PIPE,
91
- bufsize=1,
92
- universal_newlines=True,
93
- )
94
- stdout, _ = process.communicate()
29
+ class ProcessingAborted(RuntimeError):
30
+ """Raised when processing is cancelled by the caller."""
95
31
 
96
- match_frame_rate = re.search(r"frame_rate=(\d*)/(\d*)", str(stdout))
97
- if match_frame_rate is not None:
98
- frame_rate = float(match_frame_rate.group(1)) / float(match_frame_rate.group(2))
99
32
 
100
- match_duration = re.search(r"duration=([\d.]*)", str(stdout))
101
- original_duration = float(match_duration.group(1)) if match_duration else 0.0
33
+ def _stop_requested(reporter: ProgressReporter | None) -> bool:
34
+ """Return ``True`` when *reporter* indicates that processing should stop."""
102
35
 
103
- match_frames = re.search(r"nb_frames=(\d+)", str(stdout))
104
- frame_count = int(match_frames.group(1)) if match_frames else 0
36
+ if reporter is None:
37
+ return False
105
38
 
106
- return {
107
- "frame_rate": frame_rate,
108
- "duration": original_duration,
109
- "frame_count": frame_count,
110
- }
39
+ flag = getattr(reporter, "stop_requested", None)
40
+ if callable(flag):
41
+ try:
42
+ flag = flag()
43
+ except Exception: # pragma: no cover - defensive
44
+ flag = False
45
+ return bool(flag)
111
46
 
112
47
 
113
- def _ensure_two_dimensional(audio_data: np.ndarray) -> np.ndarray:
114
- if audio_data.ndim == 1:
115
- return audio_data[:, np.newaxis]
116
- return audio_data
48
+ def _raise_if_stopped(
49
+ reporter: ProgressReporter | None, *, temp_path: Path | None = None
50
+ ) -> None:
51
+ """Abort processing when the user has requested a stop."""
117
52
 
53
+ if not _stop_requested(reporter):
54
+ return
118
55
 
119
- def _prepare_output_audio(output_audio_data: np.ndarray) -> np.ndarray:
120
- if output_audio_data.ndim == 2 and output_audio_data.shape[1] == 1:
121
- return output_audio_data[:, 0]
122
- return output_audio_data
56
+ if temp_path is not None and temp_path.exists():
57
+ _delete_path(temp_path)
58
+ raise ProcessingAborted("Processing aborted by user request.")
123
59
 
124
60
 
125
61
  def speed_up_video(
@@ -152,10 +88,14 @@ def speed_up_video(
152
88
  original_duration = metadata["duration"]
153
89
  frame_count = metadata.get("frame_count", 0)
154
90
 
91
+ app_version = resolve_version()
92
+ if app_version and app_version != "unknown":
93
+ reporter.log(f"talks-reducer v{app_version}")
94
+
155
95
  reporter.log(
156
96
  (
157
- "Source metadata duration: {duration:.2f}s, frame rate: {fps:.3f} fps,"
158
- " reported frames: {frames}"
97
+ "Source metadata: duration: {duration:.2f}s, frame rate: {fps:.3f} fps,"
98
+ " frames: {frames}"
159
99
  ).format(
160
100
  duration=original_duration,
161
101
  fps=frame_rate,
@@ -186,6 +126,7 @@ def speed_up_video(
186
126
  ffmpeg_path=ffmpeg_path,
187
127
  )
188
128
 
129
+ _raise_if_stopped(reporter, temp_path=temp_path)
189
130
  reporter.log("Extracting audio...")
190
131
  process_callback = getattr(reporter, "process_callback", None)
191
132
  estimated_total_frames = frame_count
@@ -211,12 +152,13 @@ def speed_up_video(
211
152
  audio_sample_count = audio_data.shape[0]
212
153
  max_audio_volume = audio_utils.get_max_volume(audio_data)
213
154
 
214
- reporter.log("\nInformation:")
215
- reporter.log(f"- Max Audio Volume: {max_audio_volume}")
155
+ reporter.log(f"Max Audio Volume: {max_audio_volume}")
216
156
 
217
157
  samples_per_frame = wav_sample_rate / frame_rate
218
158
  audio_frame_count = int(math.ceil(audio_sample_count / samples_per_frame))
219
159
 
160
+ _raise_if_stopped(reporter, temp_path=temp_path)
161
+
220
162
  has_loud_audio = chunk_utils.detect_loud_frames(
221
163
  audio_data,
222
164
  audio_frame_count,
@@ -227,7 +169,9 @@ def speed_up_video(
227
169
 
228
170
  chunks, _ = chunk_utils.build_chunks(has_loud_audio, options.frame_spreadage)
229
171
 
230
- reporter.log(f"Generated {len(chunks)} chunks")
172
+ reporter.log(f"Processing {len(chunks)} chunks...")
173
+
174
+ _raise_if_stopped(reporter, temp_path=temp_path)
231
175
 
232
176
  new_speeds = [options.silent_speed, options.sounded_speed]
233
177
  output_audio_data, updated_chunks = audio_utils.process_audio_chunks(
@@ -248,6 +192,8 @@ def speed_up_video(
248
192
  _prepare_output_audio(output_audio_data),
249
193
  )
250
194
 
195
+ _raise_if_stopped(reporter, temp_path=temp_path)
196
+
251
197
  expression = chunk_utils.get_tree_expression(updated_chunks)
252
198
  filter_graph_path = temp_path / "filterGraph.txt"
253
199
  with open(filter_graph_path, "w", encoding="utf-8") as filter_graph_file:
@@ -285,6 +231,8 @@ def speed_up_video(
285
231
  _delete_path(temp_path)
286
232
  raise FileNotFoundError("Filter graph file was not generated")
287
233
 
234
+ _raise_if_stopped(reporter, temp_path=temp_path)
235
+
288
236
  try:
289
237
  final_total_frames = updated_chunks[-1][3] if updated_chunks else 0
290
238
  if final_total_frames > 0:
@@ -315,6 +263,8 @@ def speed_up_video(
315
263
  )
316
264
  except subprocess.CalledProcessError as exc:
317
265
  if fallback_command_str and use_cuda_encoder:
266
+ _raise_if_stopped(reporter, temp_path=temp_path)
267
+
318
268
  reporter.log("CUDA encoding failed, retrying with CPU encoder...")
319
269
  if final_total_frames > 0:
320
270
  reporter.log(
@@ -364,3 +314,103 @@ def speed_up_video(
364
314
  time_ratio=time_ratio,
365
315
  size_ratio=size_ratio,
366
316
  )
317
+
318
+
319
+ def _input_to_output_filename(filename: Path, small: bool = False) -> Path:
320
+ dot_index = filename.name.rfind(".")
321
+ suffix_parts = []
322
+
323
+ if small:
324
+ suffix_parts.append("_small")
325
+
326
+ if not suffix_parts:
327
+ suffix_parts.append("") # Default case
328
+
329
+ suffix = "_speedup" + "".join(suffix_parts)
330
+ new_name = (
331
+ filename.name[:dot_index] + suffix + filename.name[dot_index:]
332
+ if dot_index != -1
333
+ else filename.name + suffix
334
+ )
335
+ return filename.with_name(new_name)
336
+
337
+
338
+ def _create_path(path: Path) -> None:
339
+ try:
340
+ path.mkdir(parents=True, exist_ok=True)
341
+ except OSError as exc: # pragma: no cover - defensive logging
342
+ raise AssertionError(
343
+ "Creation of the directory failed. (The TEMP folder may already exist. Delete or rename it, and try again.)"
344
+ ) from exc
345
+
346
+
347
+ def _delete_path(path: Path) -> None:
348
+ import time
349
+ from shutil import rmtree
350
+
351
+ if not path.exists():
352
+ return
353
+
354
+ try:
355
+ rmtree(path, ignore_errors=False)
356
+ for i in range(5):
357
+ if not path.exists():
358
+ return
359
+ time.sleep(0.01 * i)
360
+ except OSError as exc: # pragma: no cover - defensive logging
361
+ print(f"Deletion of the directory {path} failed")
362
+ print(exc)
363
+
364
+
365
+ def _extract_video_metadata(input_file: Path, frame_rate: float) -> Dict[str, float]:
366
+ from .ffmpeg import get_ffprobe_path
367
+
368
+ ffprobe_path = get_ffprobe_path()
369
+ command = [
370
+ ffprobe_path,
371
+ "-i",
372
+ os.fspath(input_file),
373
+ "-hide_banner",
374
+ "-loglevel",
375
+ "error",
376
+ "-select_streams",
377
+ "v",
378
+ "-show_entries",
379
+ "format=duration:stream=avg_frame_rate,nb_frames",
380
+ ]
381
+ process = subprocess.Popen(
382
+ command,
383
+ stdout=subprocess.PIPE,
384
+ stderr=subprocess.PIPE,
385
+ bufsize=1,
386
+ universal_newlines=True,
387
+ )
388
+ stdout, _ = process.communicate()
389
+
390
+ match_frame_rate = re.search(r"frame_rate=(\d*)/(\d*)", str(stdout))
391
+ if match_frame_rate is not None:
392
+ frame_rate = float(match_frame_rate.group(1)) / float(match_frame_rate.group(2))
393
+
394
+ match_duration = re.search(r"duration=([\d.]*)", str(stdout))
395
+ original_duration = float(match_duration.group(1)) if match_duration else 0.0
396
+
397
+ match_frames = re.search(r"nb_frames=(\d+)", str(stdout))
398
+ frame_count = int(match_frames.group(1)) if match_frames else 0
399
+
400
+ return {
401
+ "frame_rate": frame_rate,
402
+ "duration": original_duration,
403
+ "frame_count": frame_count,
404
+ }
405
+
406
+
407
+ def _ensure_two_dimensional(audio_data: np.ndarray) -> np.ndarray:
408
+ if audio_data.ndim == 1:
409
+ return audio_data[:, np.newaxis]
410
+ return audio_data
411
+
412
+
413
+ def _prepare_output_audio(output_audio_data: np.ndarray) -> np.ndarray:
414
+ if output_audio_data.ndim == 2 and output_audio_data.shape[1] == 1:
415
+ return output_audio_data[:, 0]
416
+ return output_audio_data
File without changes