-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgui.py
More file actions
1912 lines (1611 loc) · 90.2 KB
/
Copy pathgui.py
File metadata and controls
1912 lines (1611 loc) · 90.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""ytdlpSpout GUI アプリケーション"""
from __future__ import annotations
import os
import queue
import ssl
import subprocess
import sys
import threading
import time
from typing import TYPE_CHECKING, Any
import cv2
import customtkinter as ctk
import tkinter as tk
import yt_dlp
# SpoutGLは使用しない(C++ DLLがSpout送信を担当)
# import SpoutGL
from PIL import Image
from ytdlpSpout.core import (
DEFAULT_SENDER_NAME,
DEFAULT_VIDEO_URL,
Streamer,
get_optimal_format_string,
)
# 分離したGUIモジュールからのインポート
from ytdlpSpout_gui.constants import (
UIConfig,
PREFERRED_JAPANESE_FONTS,
PREFERRED_MONOSPACE_FONTS,
)
from ytdlpSpout_gui.fonts import get_best_japanese_font, get_best_monospace_font
from ytdlpSpout_gui.logger import YtdlpLogger
from ytdlpSpout_gui.utils import clean_playlist_url
# =============================================================================
# C++ DLLバックエンド切り替え設定
# =============================================================================
# True: C++ DLLを使用(高パフォーマンス、要ビルド済みDLL)
# False: Python Streamerを使用(従来の動作)
USE_NATIVE_BACKEND = True
# NativeStreamerWrapper のインポート
NATIVE_BACKEND_AVAILABLE = False
if USE_NATIVE_BACKEND:
try:
from python.native_streamer_wrapper import NativeStreamerWrapper
NATIVE_BACKEND_AVAILABLE = True
print("[INFO] C++ DLLバックエンドが利用可能です")
except ImportError as e:
print(f"[WARNING] NativeStreamerWrapper import failed: {e}")
print("[WARNING] Falling back to Python Streamer")
USE_NATIVE_BACKEND = False
# YtDlpAsyncResolver のインポート(非同期URL解決用)
YTDLP_RESOLVER_AVAILABLE = False
try:
from python.ytdlp_resolver import YtDlpAsyncResolver, ResolvedInfo
YTDLP_RESOLVER_AVAILABLE = True
except ImportError as e:
print(f"[WARNING] YtDlpAsyncResolver import failed: {e}")
# =============================================================================
if TYPE_CHECKING:
import numpy as np
# SSL証明書の設定(Windows環境での証明書問題を回避)
try:
ssl._create_default_https_context = ssl._create_unverified_context
except AttributeError:
pass # 古いPythonバージョンでは無視
class App:
"""ytdlpSpout GUIアプリケーションメインクラス"""
def __init__(self, root: ctk.CTk) -> None:
self.root = root
self.root.title(UIConfig.WINDOW_TITLE)
self.streamer: Streamer | None = None
self._seeking = False
# メインスレッド監視用変数
self.main_thread_monitor_active = False
self.main_thread_last_heartbeat: float | None = None
self.main_thread_monitor_thread: threading.Thread | None = None
self._last_heartbeat_time: float = 0.0
# 設定フラグ(サブプロセスダウンロードは常時有効)
self.use_subprocess_download = True # 常時有効
self.seek_value = 0.0
self.duration_cache = 0.0
self.local_video_path: str | None = None
self.download_in_progress = False
self.original_url: str | None = None
# ログキュー(ログ処理の非同期化)
self.log_queue: queue.Queue[str] = queue.Queue()
self.log_processing = False
# デバッグ用
self.preview_update_disabled = False
# 共有SpoutSender
self.spout_sender = None
# プログレス管理用
self._subprocess_progress_active = False
self._download_process: subprocess.Popen | None = None # ダウンロードプロセスを保持
self._download_cancelled = False # ダウンロードキャンセルフラグ
self.current_seekbar_knob_color = UIConfig.SEEKBAR_KNOB_STANDBY # 現在のシークバーノブ色
# yt-dlp非同期リゾルバー(URL解決中のインスタンス保持)
self._ytdlp_resolver: YtDlpAsyncResolver | None = None
self._url_resolving = False # URL解決中フラグ
# プログレスデータ初期化
self.download_progress: dict[str, Any] = self._create_empty_progress()
# UI初期化
self._setup_appearance()
self._setup_fonts()
self._setup_window()
self._setup_widgets()
self._setup_event_handlers()
def _create_empty_progress(self) -> dict[str, Any]:
"""空の進捗データを作成"""
return {
'percent': 0.0,
'downloaded_bytes': 0,
'total_bytes': 0,
'speed': '',
'eta': '',
'filename': ''
}
def update_seekbar_color(self, color: str) -> None:
"""シークバーのノブ色を更新して状態を表示"""
try:
self.current_seekbar_knob_color = color
self.seek_slider.configure(button_color=color, button_hover_color=color)
except Exception:
pass
def _setup_appearance(self) -> None:
"""CustomTkinterの外観設定"""
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
def _setup_fonts(self) -> None:
"""フォント設定"""
self.japanese_font = get_best_japanese_font()
self.monospace_font = get_best_monospace_font()
self.log_font = self.japanese_font
def _setup_window(self) -> None:
"""ウィンドウ設定"""
self.root.geometry(UIConfig.INITIAL_SIZE)
self.root.minsize(UIConfig.MIN_WIDTH, UIConfig.MIN_HEIGHT)
def _setup_widgets(self) -> None:
"""ウィジェットのセットアップ"""
self._setup_control_frame()
self._setup_progress_frame()
self._setup_info_labels()
self._setup_main_content()
def _setup_event_handlers(self) -> None:
"""イベントハンドラの設定"""
self.root.protocol("WM_DELETE_WINDOW", self.on_close)
self.root.after(UIConfig.PREVIEW_UPDATE_INTERVAL, self.update_preview)
def _setup_control_frame(self) -> None:
"""コントロールフレームのセットアップ"""
frm = ctk.CTkFrame(self.root)
frm.pack(fill="x", padx=8, pady=4) # pady reduced
# Grid configuration
frm.grid_columnconfigure(1, weight=1)
# Column weights for balanced layout
# 0: Label, 1: Entry/Data, 2: Label/Btn, 3: Entry/Btn, 4: Checkbox, 5: Checkbox
# Row 0: URL (Full width)
ctk.CTkLabel(frm, text="URL", font=ctk.CTkFont(family=self.japanese_font, size=12)).grid(
row=0, column=0, sticky="w", padx=6, pady=2)
self.url_var = ctk.StringVar(value=DEFAULT_VIDEO_URL)
ctk.CTkEntry(frm, textvariable=self.url_var, width=400, font=ctk.CTkFont(family=self.japanese_font, size=11)).grid(
row=0, column=1, columnspan=5, sticky="ew", padx=6, pady=2)
# Row 1: Sender | Start Button | Stop Button
ctk.CTkLabel(frm, text="Sender", font=ctk.CTkFont(family=self.japanese_font, size=12)).grid(
row=1, column=0, sticky="w", padx=6, pady=2)
self.sender_var = ctk.StringVar(value=DEFAULT_SENDER_NAME)
ctk.CTkEntry(frm, textvariable=self.sender_var, width=200, font=ctk.CTkFont(family=self.japanese_font, size=11)).grid(
row=1, column=1, sticky="ew", padx=6, pady=2)
# Buttons moved to Row 1
self.btn_start = ctk.CTkButton(
frm, text="Start", command=self.on_stream, width=100,
font=ctk.CTkFont(family=self.japanese_font, size=UIConfig.FONT_SIZE_NORMAL, weight="bold")
)
self.btn_stop = ctk.CTkButton(
frm, text="Stop", command=self.on_stop, state="disabled", width=100,
font=ctk.CTkFont(family=self.japanese_font, size=UIConfig.FONT_SIZE_NORMAL, weight="bold")
)
self.btn_start.grid(row=1, column=2, columnspan=2, padx=6, pady=2, sticky="ew")
self.btn_stop.grid(row=1, column=4, columnspan=2, padx=6, pady=2, sticky="ew")
# Row 2: Max W | Max H | Use Max Cap | 1440p Limit
ctk.CTkLabel(frm, text="Max W", font=ctk.CTkFont(family=self.japanese_font, size=12)).grid(
row=2, column=0, sticky="w", padx=6, pady=2)
self.maxw_var = ctk.StringVar(value="1920")
ctk.CTkEntry(frm, textvariable=self.maxw_var, width=70, font=ctk.CTkFont(family=self.monospace_font, size=11)).grid(
row=2, column=1, sticky="w", padx=6, pady=2)
ctk.CTkLabel(frm, text="Max H", font=ctk.CTkFont(family=self.japanese_font, size=12)).grid(
row=2, column=2, sticky="w", padx=6, pady=2)
self.maxh_var = ctk.StringVar(value="1080")
ctk.CTkEntry(frm, textvariable=self.maxh_var, width=70, font=ctk.CTkFont(family=self.monospace_font, size=11)).grid(
row=2, column=3, sticky="w", padx=6, pady=2)
self.max_enable = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(frm, text="Cap", variable=self.max_enable, width=60, font=ctk.CTkFont(family=self.japanese_font, size=12)).grid(
row=2, column=4, sticky="w", padx=6, pady=2)
self.perf_limit = ctk.BooleanVar(value=True) # デフォルトで有効
perf_cb = ctk.CTkCheckBox(frm, text="1440p Limit", variable=self.perf_limit, width=100,
font=ctk.CTkFont(family=self.japanese_font, size=12))
perf_cb.grid(row=2, column=5, sticky="w", padx=6, pady=2)
# Row 3: Manual W | Manual H | Use Manual | Loop VOD
ctk.CTkLabel(frm, text="Manual W", font=ctk.CTkFont(family=self.japanese_font, size=12)).grid(
row=3, column=0, sticky="w", padx=6, pady=2)
self.manw_var = ctk.StringVar(value="")
ctk.CTkEntry(frm, textvariable=self.manw_var, width=70, font=ctk.CTkFont(family=self.monospace_font, size=11)).grid(
row=3, column=1, sticky="w", padx=6, pady=2)
ctk.CTkLabel(frm, text="Manual H", font=ctk.CTkFont(family=self.japanese_font, size=12)).grid(
row=3, column=2, sticky="w", padx=6, pady=2)
self.manh_var = ctk.StringVar(value="")
ctk.CTkEntry(frm, textvariable=self.manh_var, width=70, font=ctk.CTkFont(family=self.monospace_font, size=11)).grid(
row=3, column=3, sticky="w", padx=6, pady=2)
self.manual_enable = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(frm, text="Manual", variable=self.manual_enable, width=60, font=ctk.CTkFont(family=self.japanese_font, size=12)).grid(
row=3, column=4, sticky="w", padx=6, pady=2)
self.vod_loop = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(frm, text="Loop", variable=self.vod_loop, width=60,
font=ctk.CTkFont(family=self.japanese_font, size=12)).grid(
row=3, column=5, sticky="w", padx=6, pady=2)
# サブプロセスダウンロードは常時有効
self.use_subprocess_download = True
def _setup_progress_frame(self) -> None:
"""ダウンロード進捗表示エリアのセットアップ(互換性のため残すが何もしない)"""
pass
def _setup_progress_frame_in_preview(self, parent: ctk.CTkFrame) -> None:
"""ダウンロード進捗表示エリアのセットアップ(プレビュー最下部)"""
self.progress_frame = ctk.CTkFrame(
parent,
fg_color=UIConfig.PROGRESS_FRAME_BG,
border_width=2,
border_color=UIConfig.PROGRESS_FRAME_BORDER,
height=0 # 初期状態では高さ0
)
# 最下部に常に配置(高さ0で非表示)
self.progress_frame.pack(fill="x", side="bottom", padx=6, pady=0)
self.progress_frame.pack_propagate(False) # 高さ0を強制
# 進捗エリアのタイトル
ctk.CTkLabel(
self.progress_frame,
text="🔄 ダウンロード進捗:",
font=ctk.CTkFont(family=self.japanese_font, size=UIConfig.FONT_SIZE_SMALL, weight="bold"),
text_color=UIConfig.PROGRESS_BAR_COLOR
).pack(anchor="w", padx=6, pady=(3, 0))
# プログレスバー
self.progress_bar = ctk.CTkProgressBar(
self.progress_frame,
progress_color=UIConfig.PROGRESS_BAR_COLOR,
height=20,
mode="determinate" # 初期は確定モード
)
self.progress_bar.pack(fill="x", padx=6, pady=3)
self.progress_bar.set(0)
# 進捗詳細情報
self.progress_info = ctk.CTkLabel(
self.progress_frame,
text="",
font=ctk.CTkFont(family=self.monospace_font, size=UIConfig.FONT_SIZE_SMALL),
text_color="#ffffff"
)
self.progress_info.pack(padx=6, pady=(0, 3))
def _setup_info_labels(self) -> None:
"""情報ラベルのセットアップ"""
self.info_label = ctk.CTkLabel(
self.root,
text="",
font=ctk.CTkFont(family=self.japanese_font, size=UIConfig.FONT_SIZE_LARGE)
)
self.info_label.pack(padx=8, pady=4)
# コーデック対応状況を表示
_, codec_info = get_optimal_format_string()
self.codec_label = ctk.CTkLabel(
self.root,
text=f"コーデック対応: {codec_info}",
font=ctk.CTkFont(family=self.japanese_font, size=UIConfig.FONT_SIZE_NORMAL),
text_color="cyan"
)
self.codec_label.pack(padx=8, pady=2)
def _setup_main_content(self) -> None:
"""メインコンテンツエリア(プレビューとログ)のセットアップ"""
# メインコンテンツエリア(プレビューとログを分割可能なPanedWindow)
main_paned = tk.PanedWindow(
self.root,
orient=tk.VERTICAL,
sashwidth=5,
sashrelief=tk.RAISED,
bg=UIConfig.PANED_BG
)
main_paned.pack(fill="both", expand=True, padx=8, pady=8)
self._setup_preview_area(main_paned)
self._setup_log_area(main_paned)
# 初期の分割比率を設定(下からログエリアの高さを確保)
# 初期の分割比率を設定(下からログエリアの高さを確保)
def on_paned_configure(event):
# イベントがPanedWindow自身のものか確認
if event.widget != main_paned:
return
height = event.height
if height < 100: # まだ小さすぎる場合は無視
return
# すでにサッシが設定されているかチェックするために、イベントハンドラを解除
main_paned.unbind("<Configure>")
# ログエリアの高さを確保(150px)
# PanedWindowの高さ - 150px の位置にサッシを設定
desired_log_height = 150
sash_pos = max(UIConfig.PREVIEW_MIN_HEIGHT + UIConfig.SEEKBAR_MIN_HEIGHT, height - desired_log_height)
try:
main_paned.sash_place(0, 0, sash_pos)
except Exception:
pass
# Configureイベントにバインドして、レイアウト確定後にサッシを設定
main_paned.bind("<Configure>", on_paned_configure)
def _setup_preview_area(self, parent: tk.PanedWindow) -> None:
"""プレビューエリアのセットアップ"""
# プレビューコンテナ(シークバーとプレビューを含む)
preview_container = ctk.CTkFrame(parent, fg_color="transparent")
parent.add(preview_container, minsize=UIConfig.PREVIEW_MIN_HEIGHT + UIConfig.SEEKBAR_MIN_HEIGHT)
# ★重要: pack()は下から上へ配置する(side="bottom"の要素を先に配置)
# 進捗バーエリア(プレビュー最下部)- 最初に配置
self._setup_progress_frame_in_preview(preview_container)
# シークバーエリア(最小高さを保証)- 進捗バーの上に配置
self.seek_container = ctk.CTkFrame(preview_container, fg_color="transparent", height=UIConfig.SEEKBAR_MIN_HEIGHT)
self.seek_container.pack(fill="x", side="bottom", before=self.progress_frame)
self.seek_container.pack_propagate(False) # 最小高さを強制
seek_frame = ctk.CTkFrame(self.seek_container, fg_color="transparent")
seek_frame.pack(fill="both", expand=True, padx=10, pady=5)
self.seek_slider = ctk.CTkSlider(
seek_frame, from_=0, to=100, state="disabled", command=self.on_seek_drag,
button_color=self.current_seekbar_knob_color,
button_hover_color=self.current_seekbar_knob_color
)
self.seek_slider.pack(fill="x", expand=True, side="left", padx=(0, 10))
self.seek_slider.bind("<ButtonPress-1>", self.on_seek_press)
self.seek_slider.bind("<ButtonRelease-1>", self.on_seek_release)
self.time_label = ctk.CTkLabel(
seek_frame,
text="--:-- / --:--",
font=ctk.CTkFont(family=self.monospace_font, size=UIConfig.FONT_SIZE_NORMAL)
)
self.time_label.pack(side="right")
# プレビュー画面 - 最後に配置(残りのスペースを占有)
self.preview_frame = ctk.CTkFrame(preview_container, fg_color="black")
self.preview_frame.pack(fill="both", expand=True, padx=0, pady=0)
self.preview_label = ctk.CTkLabel(
self.preview_frame,
text="No Signal",
text_color="white",
font=ctk.CTkFont(family=self.japanese_font, size=UIConfig.FONT_SIZE_TITLE)
)
self.preview_label.pack(fill="both", expand=True)
self.preview_imgtk = None
self._no_signal_shown = True
def _setup_log_area(self, parent: tk.PanedWindow) -> None:
"""ログエリアのセットアップ"""
log_frame = ctk.CTkFrame(parent)
parent.add(log_frame, minsize=UIConfig.LOG_MIN_HEIGHT)
# ログエリアのタイトル
ctk.CTkLabel(
log_frame,
text="ログ出力:",
font=ctk.CTkFont(family=self.japanese_font, size=UIConfig.FONT_SIZE_NORMAL, weight="bold")
).pack(anchor="w", padx=5, pady=(5, 0))
# ログテキストエリア
self.log_text = ctk.CTkTextbox(
log_frame,
height=150,
font=ctk.CTkFont(family=self.log_font, size=UIConfig.FONT_SIZE_NORMAL)
)
self.log_text.pack(fill="both", expand=True, padx=5, pady=5)
def log(self, msg: str) -> None:
"""ログメッセージを完全に非同期で処理"""
try:
if threading.current_thread() == threading.main_thread():
self._log_direct(msg)
else:
self.log_queue.put(msg)
if not self.log_processing:
self.root.after(0, self.process_log_queue)
except Exception:
pass
def debug_log(self, msg: str) -> None:
"""デバッグ専用ログ(常に出力)"""
try:
timestamp = time.strftime("%H:%M:%S") + f".{int(time.time() * 1000) % 1000:03d}"
debug_msg = f"[{timestamp}] {msg}"
self._log_direct(debug_msg)
except Exception:
pass
def _start_main_thread_monitor(self) -> None:
"""メインスレッドのブロッキングを監視"""
if self.main_thread_monitor_active:
return
self.main_thread_monitor_active = True
def monitor_main_thread() -> None:
"""メインスレッドのハートビートとフレーム送信を監視"""
last_frame_check = 0
last_frame_count = 0
while self.main_thread_monitor_active:
current_time = time.time()
# ハートビート更新をメインスレッドに依頼
heartbeat_start = current_time
self.root.after(0, self._main_thread_heartbeat)
# Spout送信状態を定期的にログ(削除:ログが多すぎるため)
# if current_time - last_frame_check > 2.0: # 2秒に1回
# try:
# if self.streamer and hasattr(self.streamer, 'latest_frame_bgr'):
# frame_info = "フレーム有" if self.streamer.latest_frame_bgr is not None else "フレーム無"
# playback_time = getattr(self.streamer, 'playback_time', 0)
# self.root.after(0, self.log, f"[SPOUT] {frame_info}, 再生時間: {playback_time:.1f}秒")
# last_frame_check = current_time
# except Exception:
# pass
time.sleep(0.2) # 200ms間隔でチェック
# ハートビートが更新されているかチェック
if hasattr(self, '_last_heartbeat_time'):
elapsed = current_time - self._last_heartbeat_time
if elapsed > 1.0: # 1秒以上応答がない場合
if elapsed > 3.0:
self.root.after(0, self.log, f"[CRITICAL] メインスレッドが {elapsed:.3f}秒間ブロックされています!")
else:
self.root.after(0, self.log, f"[WARNING] メインスレッドブロック: {elapsed:.3f}秒")
self.main_thread_monitor_thread = threading.Thread(target=monitor_main_thread, daemon=True)
self.main_thread_monitor_thread.start()
self.log("[DEBUG] メインスレッド+Spout監視を開始しました")
def _main_thread_heartbeat(self) -> None:
"""メインスレッドのハートビート(GUI スレッドで実行される)"""
self._last_heartbeat_time = time.time()
def _stop_main_thread_monitor(self) -> None:
"""メインスレッド監視を停止"""
self.main_thread_monitor_active = False
if self.main_thread_monitor_thread:
self.main_thread_monitor_thread = None
self.log("[DEBUG] メインスレッド監視を停止しました")
def _cancel_download(self) -> None:
"""ダウンロードプロセスをキャンセルする"""
self._download_cancelled = True
self._subprocess_progress_active = False
proc = self._download_process
if proc is not None:
try:
proc.terminate()
self.log("ダウンロードプロセスを終了しました")
# プロセスの終了を待機(最大2秒)
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
self.log("ダウンロードプロセスを強制終了しました")
except Exception as e:
self.log(f"ダウンロードキャンセルエラー: {e}")
finally:
self._download_process = None
# ダウンロード中フラグをリセット
self.download_in_progress = False
# 進捗バーを非表示
self.hide_download_progress()
# 一時ダウンロードファイルを削除(部分的にダウンロードされたファイル)
try:
import glob
partial_files = glob.glob("data/*.part") + glob.glob("data/*.ytdl")
for f in partial_files:
try:
os.remove(f)
self.log(f"部分ダウンロードファイルを削除: {f}")
except Exception:
pass
except Exception:
pass
def _find_yt_dlp_executable(self) -> str:
"""yt-dlp実行ファイルのパスを検索(exe環境対応)"""
from ytdlpSpout.core import get_executable_dir
exe_dir = get_executable_dir()
yt_dlp_name = "yt-dlp.exe" if sys.platform == "win32" else "yt-dlp"
# 1. exe同階層のbinディレクトリを確認
bin_dir = os.path.join(exe_dir, 'bin')
yt_dlp_in_bin = os.path.join(bin_dir, yt_dlp_name)
if os.path.exists(yt_dlp_in_bin):
return yt_dlp_in_bin
# 2. exe同階層を確認
yt_dlp_in_exe_dir = os.path.join(exe_dir, yt_dlp_name)
if os.path.exists(yt_dlp_in_exe_dir):
return yt_dlp_in_exe_dir
# 3. システムPATHから検索(Python環境のyt-dlpを含む)
import shutil
yt_dlp_path = shutil.which("yt-dlp")
if yt_dlp_path:
return yt_dlp_path
# 4. 見つからなければ名前だけ返す(PATHに存在することを期待)
return yt_dlp_name
def start_subprocess_download(self, url: str) -> None:
"""別プロセスでダウンロードを実行(yt-dlp CLIを使用、exe環境対応)"""
import json
from ytdlpSpout.core import get_optimal_format_string
# キャンセルフラグをリセット
self._download_cancelled = False
def run_subprocess() -> None:
try:
# キャンセルチェック
if self._download_cancelled:
return
# URLをクリーンアップ(プレイリストパラメータを除去)
cleaned_url = clean_playlist_url(url)
if cleaned_url != url:
self.root.after(0, self.log, "プレイリストURL検出:単体動画として処理します")
self.root.after(0, self.log, f"別プロセスダウンロード開始: {cleaned_url}")
# 進捗バーを表示して初期状態を設定
def init_subprocess_progress():
# 進捗バーを表示(高さを復元)
self.progress_frame.configure(height=80) # 適切な高さに設定
self.progress_frame.pack_configure(pady=4) # パディングを復元
# 初期進捗情報を設定
initial_progress = {
'percent': 0.0,
'downloaded_bytes': 0,
'total_bytes': 0,
'speed': '準備中...',
'eta': '',
'filename': 'ダウンロード準備中...'
}
self.update_download_progress(initial_progress)
self.update_seekbar_color(UIConfig.SEEKBAR_KNOB_DOWNLOADING)
self.root.after(0, init_subprocess_progress)
self._subprocess_progress_active = True
self.download_in_progress = True
# dataディレクトリを作成
os.makedirs("data", exist_ok=True)
# yt-dlp実行ファイルを検索
yt_dlp_path = self._find_yt_dlp_executable()
self.root.after(0, self.log, f"yt-dlp パス: {yt_dlp_path}")
# ストリーミング時の解像度を取得してダウンロード解像度を制限
# (4Kデコードは重いので、ストリーミング時と同じ解像度でダウンロード)
max_download_height = 1440 # デフォルト
if self.streamer and hasattr(self.streamer, 'height'):
streaming_height = self.streamer.height
if streaming_height > 0:
max_download_height = streaming_height
self.root.after(0, self.log, f"ダウンロード解像度制限: {max_download_height}p (ストリーミング解像度に合わせる)")
# フォーマット文字列を取得
format_str, codec_info = get_optimal_format_string(max_height=max_download_height)
self.root.after(0, self.log, f"フォーマット設定: {codec_info}")
# yt-dlp コマンドを構築(CLIモード、進捗出力付き)
cmd = [
yt_dlp_path,
"--format", format_str,
"--output", "data/%(id)s.%(ext)s",
"--no-playlist",
"--newline", # 進捗を新しい行で出力
"--progress", # 進捗を表示
"--no-colors", # カラー出力を無効化
"--no-warnings",
cleaned_url
]
# cookiesファイルが存在すれば使用
cookie_file = os.path.join("data", "cookies.txt")
if os.path.exists(cookie_file):
cmd.insert(-1, "--cookies")
cmd.insert(-1, cookie_file)
self.root.after(0, self.log, f"ダウンロードコマンド: {' '.join(cmd[:5])}...")
# 別プロセスで実行
creationflags = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # stderrもstdoutにマージ
text=True,
bufsize=1,
creationflags=creationflags
)
# プロセスを保持(キャンセル用)
self._download_process = process
downloaded_file = None
last_progress_update = 0
# 出力を読み取りながら進捗を解析
try:
for line in process.stdout:
# キャンセルチェック
if self._download_cancelled:
process.terminate()
self.root.after(0, self.log, "ダウンロードがキャンセルされました")
return
line = line.strip()
if not line:
continue
# yt-dlpの進捗出力を解析
# 例: [download] 45.3% of 123.45MiB at 5.67MiB/s ETA 00:15
if "[download]" in line and "%" in line:
try:
import re
# パーセンテージを抽出
percent_match = re.search(r'(\d+\.?\d*)%', line)
# サイズを抽出(例: "of 123.45MiB" または "of ~123.45MiB")
size_match = re.search(r'of\s+~?(\d+\.?\d*)(Ki?B|Mi?B|Gi?B)', line)
# 速度を抽出
speed_match = re.search(r'at\s+(\d+\.?\d*\s*\w+/s)', line)
# ETAを抽出
eta_match = re.search(r'ETA\s+(\S+)', line)
percent = float(percent_match.group(1)) if percent_match else 0
# サイズをバイトに変換
total_bytes = 0
if size_match:
size_val = float(size_match.group(1))
size_unit = size_match.group(2).upper()
if 'G' in size_unit:
total_bytes = int(size_val * 1024 * 1024 * 1024)
elif 'M' in size_unit:
total_bytes = int(size_val * 1024 * 1024)
elif 'K' in size_unit:
total_bytes = int(size_val * 1024)
else:
total_bytes = int(size_val)
downloaded_bytes = int(total_bytes * percent / 100) if total_bytes > 0 else 0
speed = speed_match.group(1) if speed_match else ""
eta = eta_match.group(1) if eta_match else ""
# 進捗更新(頻度制限:0.5秒に1回)
current_time = time.time()
if current_time - last_progress_update >= 0.5:
last_progress_update = current_time
gui_progress = {
'percent': percent,
'downloaded_bytes': downloaded_bytes,
'total_bytes': total_bytes,
'speed': speed,
'eta': eta,
'filename': ''
}
self.root.after(0, self.update_download_progress, gui_progress)
except Exception:
pass
# ダウンロード先ファイルパスを抽出
# 例: [download] Destination: data/xxxxx.mp4
elif "[download] Destination:" in line:
downloaded_file = line.split("Destination:")[-1].strip()
elif "[Merger]" in line and "Merging formats into" in line:
# マージ後のファイル名を抽出
merge_match = re.search(r'"([^"]+)"', line)
if merge_match:
downloaded_file = merge_match.group(1)
elif "has already been downloaded" in line:
# 既にダウンロード済みの場合
match = re.search(r'\[download\]\s+(.+?)\s+has already been downloaded', line)
if match:
downloaded_file = match.group(1)
except Exception as e:
self.root.after(0, self.log, f"進捗読み取りエラー: {e}")
# プロセス完了を待機
return_code = process.wait()
self._download_process = None
# キャンセルチェック
if self._download_cancelled:
return
if return_code == 0:
# ダウンロード成功
# ファイルパスが取得できなかった場合、dataフォルダから最新ファイルを探す
if not downloaded_file or not os.path.exists(downloaded_file):
import glob
data_files = [f for f in glob.glob("data/*")
if not f.endswith('.part') and not f.endswith('.ytdl')
and os.path.isfile(f)]
if data_files:
downloaded_file = max(data_files, key=os.path.getctime)
if downloaded_file and os.path.exists(downloaded_file):
# 模擬進捗を停止
self._subprocess_progress_active = False
# 完了時の進捗表示(切り替え中メッセージ)
completion_progress = {
'percent': 100.0,
'downloaded_bytes': 0,
'total_bytes': 0,
'speed': '完了 - ローカル再生に切り替え中...',
'eta': '',
'filename': os.path.basename(downloaded_file)
}
self.root.after(0, self.update_download_progress, completion_progress)
# 進捗バーを不確定モード(アニメーション)に変更
self.root.after(0, lambda: self.progress_bar.configure(mode="indeterminate"))
self.root.after(0, lambda: self.progress_bar.start())
# 進捗バーは切り替え完了後に非表示にする(hide_download_progressは削除)
self.root.after(0, self.log, f"別プロセスダウンロード完了: {downloaded_file}")
# シームレス切り替えを実行
self.root.after(0, self.switch_to_local_file, downloaded_file)
else:
self._subprocess_progress_active = False
self.root.after(0, self.hide_download_progress)
self.root.after(0, self.log, "ダウンロード完了しましたが、ファイルが見つかりませんでした")
else:
# エラー時も進捗バーを非表示
self._subprocess_progress_active = False
self.root.after(0, self.hide_download_progress)
self.root.after(0, self.log, f"ダウンロードエラー(終了コード: {return_code})")
except Exception as e:
# エラー時も進捗バーを非表示
self._subprocess_progress_active = False
self._download_process = None
self.root.after(0, self.hide_download_progress)
self.root.after(0, self.log, f"別プロセス実行エラー: {e}")
finally:
self.download_in_progress = False
# バックグラウンドで実行
threading.Thread(target=run_subprocess, daemon=True).start()
def switch_to_local_file(self, file_path: str):
"""ダウンロード完了後にローカルファイルへ切り替え(簡略版)
C++ DLLバックエンドではスライスローディングにより
シームレス切り替えが不要になったため、単純な切り替えを行う。
"""
try:
self.log(f"ローカルファイルへの切り替え開始: {file_path}")
if not os.path.exists(file_path):
self.log(f"エラー: ローカルファイルが見つかりません: {file_path}")
return
abs_file_path = os.path.abspath(file_path)
self.local_video_path = abs_file_path
# 旧ストリーマーの現在位置を取得
current_position = 0.0
old_streamer = self.streamer
if old_streamer:
try:
current_position = old_streamer.playback_time
except Exception:
pass
# 旧ストリーマーのstop_cbを無効化(新ストリーマーを誤って停止しないため)
old_streamer._stop_cb = None
# 旧ストリーマーを停止
try:
old_streamer.stop()
except Exception as e:
self.log(f"旧ストリーマー停止エラー: {e}")
# 新しいストリーマーを作成・開始
max_res, manual_res = self._get_resolution_settings()
new_streamer = self._create_streamer(
abs_file_path,
self.sender_var.get(),
max_resolution=max_res,
manual_resolution=manual_res,
loop_vod=self.vod_loop.get(),
log_cb=lambda m: self.root.after(0, self.log, m),
stop_cb=self.on_auto_stop,
init_ok_cb=lambda: self.root.after(0, self.on_stream_start_success),
external_spout_sender=None
)
self.streamer = new_streamer
new_streamer.start()
# 前の再生位置にシーク
if current_position > 0:
new_streamer.seek(current_position)
self.log(f"再生位置を復元: {self.format_time(current_position)}")
# ステータス更新
original_url_display = self.original_url if self.original_url else "不明"
self.info_label.configure(
text=f"ローカルファイル再生中({original_url_display} からダウンロード済み)"
)
self.update_seekbar_color(UIConfig.SEEKBAR_KNOB_LOCAL)
# 進捗バーを非表示
self.hide_download_progress()
self.log(f"ローカルファイルへの切り替え完了: {file_path}")
except Exception as e:
self.log(f"ローカルファイル切り替えエラー: {e}")
import traceback
self.log(traceback.format_exc())
def _log_direct(self, msg: str) -> None:
"""メインスレッドからの直接ログ処理"""
try:
self.log_text.insert("end", msg + "\n")
self.log_text.see("end")
except Exception:
pass
def process_log_queue(self) -> None:
"""ログキューを処理"""
try:
self.log_processing = True
processed_count = 0
max_process_per_batch = 5
while not self.log_queue.empty() and processed_count < max_process_per_batch:
try:
msg = self.log_queue.get_nowait()
self.log_text.insert("end", msg + "\n")
processed_count += 1
except Exception:
break
if processed_count > 0:
self.log_text.see("end")
# まだログが残っている場合は次のバッチを予約
if not self.log_queue.empty():
self.root.after(25, self.process_log_queue) # より短い間隔で処理
else:
self.log_processing = False
except Exception:
self.log_processing = False
def get_shared_spout_sender(self, sender_name: str):
"""共有SpoutSenderを取得または作成する
注意: C++ DLLがSpout送信を担当するため、このメソッドは何もしない。
互換性のためにNoneを返す。
"""
# C++ DLL (Spout有効ビルド) がSpout送信を担当
# Python側のSpoutGLは使用しない(競合回避)
return None
def format_time(self, seconds: float) -> str:
"""秒を HH:MM:SS 形式の文字列に変換"""
if not isinstance(seconds, (int, float)) or seconds < 0:
return "--:--"
seconds = int(seconds)
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
if h > 0:
return f"{h:02d}:{m:02d}:{s:02d}"
else:
return f"{m:02d}:{s:02d}"
def on_seek_drag(self, value: float) -> None:
"""シークバードラッグ時の処理"""
if self._seeking:
self.seek_value = value
current_t = self.format_time(value)
total_t = self.format_time(self.duration_cache)
self.time_label.configure(text=f"{current_t} / {total_t}")
def on_seek_press(self, event: tk.Event) -> None:
"""シークバー押下時の処理"""
if self.streamer and self.streamer.is_vod:
self._seeking = True
# マウスのクリック位置からスライダーの値を計算して設定
slider_width = self.seek_slider.winfo_width()
if slider_width == 0: return
slider_range = self.seek_slider.cget("to") - self.seek_slider.cget("from_")
click_x = event.x
if click_x < 0:
click_x = 0
if click_x > slider_width:
click_x = slider_width
percentage = click_x / slider_width
new_value = self.seek_slider.cget("from_") + (percentage * slider_range)
self.seek_slider.set(new_value)
def on_seek_release(self, event: tk.Event) -> None:
"""シークバーリリース時の処理"""
if self.streamer and self.streamer.is_vod and self._seeking:
self._seeking = False
# マウスリリース時の最終的な値を元にシーク
final_seek_value = self.seek_slider.get()
self.streamer.seek(final_seek_value)
def update_download_progress(self, progress_data: dict[str, Any]) -> None:
"""ダウンロード進捗を更新する(ファイルサイズベース)"""
try:
# 進捗データを更新
self.download_progress.update(progress_data)
# プログレスバーを表示(まだ表示されていない場合のみ)
if not self.progress_frame.winfo_viewable():
# info_labelの上に表示するため、info_labelの前に挿入