-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathVideo Encoding Optimizer.py
More file actions
8231 lines (7000 loc) · 427 KB
/
Video Encoding Optimizer.py
File metadata and controls
8231 lines (7000 loc) · 427 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
# ==============================================================================
# 1. 임포트 및 기본 설정
# ==============================================================================
# ------------------------------------------------------------------------------
# 표준 라이브러리 (Standard Library)
# ------------------------------------------------------------------------------
# 데이터 구조 및 타입
import csv # CSV (쉼표로 구분된 값) 형식의 파일을 읽고 쓰기 위한 모듈 (결과 내보내기용)
import json # JSON(JavaScript Object Notation) 데이터 구조를 파싱하고 생성하기 위한 모듈 (VMAF 로그, ffprobe 출력 처리용)
from collections import OrderedDict # 아이템이 삽입된 순서를 기억하는 딕셔너리 클래스
from dataclasses import dataclass, field # 상용구 코드 없이 클래스를 단순하게 작성하기 위한 데이터 클래스 데코레이터
from typing import Any, Dict, List, Tuple # 타입 힌트(type hint)를 지원하기 위한 모듈
# 시스템, 프로세스, 동시성 관리
import logging # 애플리케이션의 정보, 경고, 오류 등 이벤트 스트림을 로그 파일에 기록하기 위한 모듈
import multiprocessing # CPU 집약적 인코딩 작업을 별도의 프로세스에서 병렬로 실행하여 처리 속도를 높이기 위한 모듈
import os # 운영 체제 서비스와 상호작용하기 위한 모듈 (파일 경로 조작, 디렉토리 생성/삭제, 프로세스 ID 획득 등)
import shlex # 쉘(shell)과 유사한 문법으로 문자열을 파싱하는 모듈 (FFmpeg 명령어 문자열을 인자 리스트로 안전하게 분리하는 데 사용)
import shutil # 파일 및 디렉토리 관련 고수준 작업을 제공하는 모듈 (임시 디렉토리 생성 및 삭제 등)
import subprocess # 새로운 프로세스를 생성하고 입출력 파이프에 연결하며 반환 코드를 얻기 위한 모듈 (FFmpeg/FFprobe 실행용)
import threading # 스레드 기반 병렬 처리를 위한 모듈 (GUI 응답성을 유지하며 백그라운드 작업 수행 시 사용)
from concurrent.futures import ThreadPoolExecutor, as_completed # 스레드 풀을 사용하여 비동기 호출을 실행하기 위한 고수준 인터페이스
# 유틸리티 및 기타
import math # 기본적인 수학 함수를 제공하는 모듈 (벡터 계산, 정규화 등에 사용)
import re # 정규 표현식(Regular Expression) 작업을 위한 모듈 (FFmpeg 로그에서 특정 텍스트 패턴 추출용)
import statistics # 수학적 통계 함수(평균, 표준편차 등)를 계산하기 위한 모듈 (VMAF 점수 분석용)
import time # 시간 관련 기능을 제공하는 모듈 (작업 소요 시간 측정, 스레드 지연 등)
import zipfile # ZIP 아카이브를 읽고 쓰기 위한 모듈 (다운로드한 FFmpeg 빌드 압축 해제용)
from datetime import datetime, timedelta # 날짜와 시간을 조작하기 위한 클래스를 제공하는 모듈
# GUI 프레임워크
import tkinter as tk # Python 표준 GUI 툴킷 Tcl/Tk에 대한 인터페이스
from tkinter import ttk, filedialog, messagebox, scrolledtext # ttk(테마 위젯), 파일 대화상자, 메시지 박스, 스크롤 텍스트 위젯
# ------------------------------------------------------------------------------
# 서드파티 라이브러리 (Third-Party Libraries)
# ------------------------------------------------------------------------------
# 시각화
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk # Matplotlib 그래프를 Tkinter 애플리케이션에 임베드하기 위한 클래스
from matplotlib.figure import Figure # Matplotlib의 최상위 컨테이너로, 그래프의 모든 요소를 담는 그림(figure) 객체
# 네트워킹 및 시스템 정보
import psutil # 실행 중인 프로세스와 시스템 활용도(CPU, 메모리, 디스크 등)에 대한 정보를 가져오는 모듈 (물리 CPU 코어 수 확인용)
import requests # HTTP 요청을 보내기 위한 모듈 (FFmpeg, VMAF 모델 등 웹 리소스 다운로드용)
# ------------------------------------------------------------------------------
# 로깅 기본 설정
# ------------------------------------------------------------------------------
# 애플리케이션 실행 중 발생하는 로그를 'Video Encoding Optimizer.log' 파일에 추가(append) 모드로 기록.
# 로그 형식: [시간] - [로그 레벨] - [메시지]
logging.basicConfig(
level=logging.INFO,
filename="Video Encoding Optimizer.log",
filemode="a",
format="%(asctime)s - %(levelname)s - %(message)s",
)
# ==============================================================================
# 상수 정의
# ==============================================================================
# 애플리케이션 설정 상수
APP_CONFIG = {
# ==============================================================================
# 1. 핵심 동작 및 기본값
# ==============================================================================
"default_sample_duration": 10.0, # 기본 샘플 지속 시간 (초)
"default_target_vmaf": 95.0, # 기본 목표 VMAF 값
"default_vmaf_threshold": 90.0, # 기본 VMAF 임계값
"default_parallel_jobs": 4, # 기본 병렬 작업 수
# ==============================================================================
# 2. 성능 및 자원 관리
# ==============================================================================
"max_parallel_jobs": 8, # NVENC 최대 병렬 작업 수
"chunk_size": 10000, # 프레임 처리 청크 크기
"memory_check_interval": 5, # 메모리 체크 간격 (청크 단위)
"progress_update_interval": 4, # 진행률 업데이트 간격 (워커 단위)
"max_eta_display_seconds": 86400, # ETA 표시 최대 시간 (24시간)
# ==============================================================================
# 3. 장면 분석 알고리즘
# ==============================================================================
"overlap_keyframes": 2, # 병렬 분석 시 키프레임 중첩 수
"min_data_points_for_iqr": 10, # IQR 아웃라이어 제거 최소 데이터 포인트 수
"q1_percentile": 0.15, # Q1 계산용 하위 백분율
"q3_percentile": 0.85, # Q3 계산용 상위 백분율
"iqr_multiplier": 3.0, # IQR 아웃라이어 제거 승수
# ==============================================================================
# 4. 시스템 및 외부 연동
# ==============================================================================
"data_folder_name": "VEO_Resource", # 리소스 데이터 폴더명
"max_filename_length": 200, # 최대 파일명 길이 (임시 디렉토리 생성용)
"ffmpeg_download_url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip", # FFmpeg 다운로드 URL
"vmaf_repo_api_url": "https://api.github.com/repos/Netflix/vmaf/contents/model", # VMAF 모델 저장소 API URL
"max_download_workers": 16, # VMAF 모델 병렬 다운로드에 사용할 최대 스레드 수
"subprocess_timeout": 2, # 서브프로세스 종료 대기 시간 (초)
"subprocess_poll_interval": 0.1, # 서브프로세스 상태 확인 간격 (초)
# ==============================================================================
# 5. GUI 및 표시 형식
# ==============================================================================
"window_size": (800, 600), # 메인 윈도우 크기 (너비, 높이)
"time_selector_window_size": (400, 150), # 시간 선택 윈도우 크기 (너비, 높이)
"preview_window_size": (800, 600), # 미리보기 윈도우 크기 (너비, 높이)
"vmaf_model_selector_window_size": (360, 320), # VMAF 모델 선택 윈도우 크기 (너비, 높이)
"advanced_settings_window_size": (700, 300), # 고급 설정 윈도우 크기 (너비, 높이)
"graph_window_size": (900, 700), # 그래프 윈도우 크기 (너비, 높이)
"log_window_size": (700, 500), # 로그 윈도우 크기 (너비, 높이)
"command_window_size": (700, 200),# 명령어 윈도우 크기 (너비, 높이)
"tooltip_offset": 25, # 툴팁 오프셋 (픽셀)
"tooltip_padding": 40, # 툴팁 패딩 (픽셀)
"metric_formats": {
"vmaf": ".6f", # VMAF 점수 (소수점 6자리)
"vmaf_1_low": ".6f", # VMAF 하위 1% 점수 (소수점 6자리)
"psnr": ".4f", # PSNR 점수 (소수점 4자리)
"ssim": ".6f", # SSIM 점수 (소수점 6자리)
"block_score": ".7f", # 블록 점수 (소수점 7자리)
"size_mb": ".4f", # 파일 크기 (소수점 4자리)
"efficiency": ".6f", # 효율성 (소수점 6자리)
},
# ==============================================================================
# 6. 수치 정밀도 관련 상수
# ==============================================================================
"numerical_tolerance": {
"relative": 1e-6, # 상대 오차 허용치 (상대적 정밀도 기준)
"absolute": 1e-8, # 절대 오차 허용치 (절대적 정밀도 기준)
"convergence": 1e-4, # 수렴 판정 허용치 (최적화 알고리즘의 수렴 여부 판정)
},
# ==============================================================================
# 7. 메시지 박스 관련 상수
# ==============================================================================
"message_titles": {
"error": "Error", # 일반적인 오류 상황을 알리는 메시지 박스의 제목
"warning": "Warning", # 사용자에게 잠재적인 문제를 경고하는 메시지 박스의 제목
"info": "Information", # 사용자에게 단순 정보를 제공하는 메시지 박스의 제목
"input_error": "Input Error", # 사용자가 입력한 값(예: 품질 범위)에 오류가 있을 때 표시되는 메시지 박스의 제목
"selection_error": "Selection Error", # 사용자가 항목(예: 결과 테이블의 행, 코덱)을 잘못 선택했을 때 표시되는 메시지 박스의 제목
},
"message_texts": {
# 비디오 파일이 선택되지 않았거나 지정된 경로에 파일이 없을 때 사용되는 메시지
"file_not_found": "Please select a valid video file.",
# 인코딩 코덱이 선택되지 않은 상태에서 작업을 시작하려 할 때의 메시지
"codec_not_selected": "Please select a codec.",
# 수동 샘플 시간 범위가 설정되지 않았거나, 종료 시간이 시작 시간보다 빠를 때의 메시지
"invalid_time_range": "Manual sample time has not been set or is invalid. Please use the 'Set Range...' button.",
# 품질(CRF/CQ 등) 설정 범위가 유효하지 않을 때(예: 시작 값이 종료 값보다 클 때)의 메시지
"invalid_quality_range": "Quality range is invalid.",
# 'Target VMAF' 모드에서 목표 VMAF 값이 0-100 범위를 벗어났을 때의 메시지
"invalid_target_vmaf": "Target VMAF must be between 0 and 100.",
# 프리셋 범위 설정 시, 시작 프리셋이 종료 프리셋보다 느린(순서상 뒤에 있는) 값일 경우의 메시지
"invalid_preset_order": "Preset start value cannot be slower than the end value.",
# .format()을 사용하여 동적인 값을 메시지에 포함시켜야 할 때 사용되는 범용 오류 메시지 템플릿
"invalid_setting_value": "Invalid setting value: {}",
},
# ==============================================================================
# 8. 프로그램 정보 상수
# ==============================================================================
"about_info": {
"program": "Video Encoding Optimizer", # 프로그램 이름
"version": "1.1.0", # 버전
"updated": "2025-09-07", # 업데이트 날짜
"license": "GNU General Public License v3.0", # 라이선스
"developer": "(Github) IZH318", # 개발자 정보
"website": "https://github.com/IZH318", # 웹사이트
},
# ==============================================================================
# 9. 파일 확장자 및 형식 정의
# ==============================================================================
"supported_video_extensions": (
"*.mp4 *.mkv *.avi *.mov *.webm *.flv *.mpg *.mpeg *.wmv *.vob *.mts *.m2ts *.ts"
),
"file_type_filters": [
("Supported Media Files", "*.mp4 *.mkv *.avi *.mov *.webm *.flv *.mpg *.mpeg *.wmv *.vob *.mts *.m2ts *.ts"),
("All files", "*.*"),
],
# ==============================================================================
# 10. 디버깅
# ==============================================================================
"enable_debug_logging": False, # True로 설정 시 장면 분석 결과가 JSON 파일로 저장됨
}
# 로그 메시지 템플릿
LOG_MESSAGES = {
# FFmpeg 실행 파일이 없어 인코더 감지를 시작할 수 없을 때 UI 상태 표시줄이나 로그에 사용
"ffmpeg_not_found": "FFmpeg not found. Cannot detect encoders.",
# FFmpeg 인코더 감지 중 오류 발생 시, 기본 목록을 사용함을 알리는 로그
"encoder_detection_error": "Error detecting encoders. Using default list.",
# 사용자가 'Sample Preview' 분석 작업을 취소했을 때의 상태 메시지
"preview_analysis_cancelled": "Preview analysis cancelled.",
# 병렬 장면 분석이 유의미한 데이터를 반환하지 못해, 더 안정적인 순차 분석으로 전환될 때의 로그
"parallel_analysis_fallback": "Parallel analysis inconclusive. Switching to sequential mode...",
# 장면 분석 시작 시 초기 메모리 사용량을 기록하기 위한 로그. (디버깅용)
"memory_usage_info": "Initial memory usage: {:.2f} MB",
# 장면 분석 완료 후 최종 메모리 사용량과 변화량을 기록하기 위한 로그. (디버깅용)
"memory_usage_final": "Final memory usage: {:.2f} MB (Change: {+.2f} MB)",
# IQR(사분위수 범위)을 이용한 아웃라이어 제거가 적용되었을 때, 제거된 데이터 포인트 수를 기록하는 로그
"iqr_outlier_removed": "IQR outlier removal applied. {} data points were trimmed.",
# IQR 아웃라이어 제거 후 남은 데이터가 없을 경우, 원본 데이터를 사용함을 알리는 로그
"iqr_empty_data": "IQR outlier trimming resulted in empty data set. Using original data.",
# IQR 처리 시작 시 통계 정보를 기록하는 로그
"iqr_processing_start": "IQR processing started - Original data points: {}, Q1: {:.2f}, Q3: {:.2f}, IQR: {:.2f}, Bounds: [{:.2f}, {:.2f}]",
# IQR 처리 완료 시 결과를 기록하는 로그
"iqr_processing_complete": "IQR processing completed - Final data points: {}, Removed: {}, Reason: {}",
# IQR 처리가 적용되지 않은 경우의 로그
"iqr_processing_skipped": "IQR processing skipped - Reason: {}",
# IQR이 0인 경우의 로그
"iqr_zero_detected": "IQR is 0 (Q1: {:.2f}, Q3: {:.2f}) - All values are similar, no outlier removal applied",
# IQR 아웃라이어 제거 상세 정보
"iqr_outlier_details": "IQR outliers removed - Seconds: {}, Values: {} (outside bounds [{:.0f}, {:.0f}])",
# 프로그램 시작/종료 관련 로그
"program_start": "Video Encoding Optimizer started - Version: {}, Date: {}",
"program_shutdown": "Video Encoding Optimizer shutting down - Session duration: {:.2f} seconds",
"program_exit": "Video Encoding Optimizer exited normally",
# 사용자 액션 관련 로그
"file_selected": "Video file selected: {} (Size: {:.2f} MB)",
"settings_changed": "Settings changed - Codec: {}, Mode: {}, Quality: {}",
"optimization_started": "Optimization started - Target: {}, Jobs: {}, Duration: {}s",
"optimization_completed": "Optimization completed - Total tests: {}, Duration: {:.2f}s, Best result: {}",
"optimization_cancelled": "Optimization cancelled by user - Completed tests: {}",
# 파일 처리 관련 로그
"temp_dir_created": "Temporary directory created: {}",
"temp_dir_cleaned": "Temporary directory cleaned: {}",
"export_started": "Export started - Format: {}, File: {}",
"export_completed": "Export completed - File: {} (Size: {:.2f} MB)",
# 성능 메트릭 관련 로그
"performance_summary": "Performance summary - Avg encoding time: {:.2f}s, Avg VMAF: {:.2f}, Best efficiency: {:.2f}",
"system_info": "System info - CPU cores: {}, Memory: {:.2f} GB, OS: {}",
# 비디오의 시작 타임스탬프가 0이 아닐 때(예: M2TS/TS 파일), 감지된 오프셋을 기록하는 로그
"time_offset_detected": "Detected time offset: {}s. Normalizing timestamps to start from 0.",
# ffprobe를 통해 비디오의 색상 정보를 성공적으로 읽어왔을 때의 로그
"color_info_probed": "Probed color info for {}: {}",
# 멀티프로세싱 워커에서 예외가 발생하여 'error' 상태를 반환했을 때의 로그
"worker_error": "Worker Error: {}",
# FFmpeg/FFprobe 서브프로세스가 0이 아닌 종료 코드를 반환하며 실패했을 때의 상세 로그
"ffmpeg_process_failed": "FFmpeg process failed with code {}.\nCMD: {}\nStderr: {}",
# 취소 가능한 서브프로세스 실행 래퍼(`_run_cancellable_subprocess`) 내에서 예외가 발생했을 때의 로그
"subprocess_exception": "Exception in _run_cancellable_subprocess: {}",
# 타임스탬프 정규화(`_normalize_seconds_map`) 과정에서 오류가 발생했을 때의 로그
"seconds_map_normalization_error": "Error during seconds_map normalization: {}",
# FFmpeg 인코더 감지 프로세스가 실패했을 때의 로그
"ffmpeg_encoder_detection_failed": "FFmpeg process failed detecting encoders: {}",
# FFmpeg 인코더 감지 중 시스템 수준(OS)의 오류가 발생했을 때의 로그
"system_error_encoder_detection": "System error detecting FFmpeg encoders: {}",
# FFmpeg 인코더 감지 중 예상치 못한 기타 오류가 발생했을 때의 로그
"unexpected_error_encoder_detection": "Unexpected error detecting FFmpeg encoders: {}",
# 비디오 색상 정보 조회(ffprobe) 프로세스가 실패했을 때의 로그
"color_info_ffmpeg_failed": "FFmpeg process failed probing color info for {}. Defaults will be used. Error: {}",
# 비디오 색상 정보 조회 중 시스템 수준(OS)의 오류가 발생했을 때의 로그
"color_info_system_error": "System error probing color info for {}. Defaults will be used. Error: {}",
# 'Sample Preview'를 위한 자동 장면 분석 중 오류가 발생했을 때의 로그
"preview_analysis_failed": "Failed to analyze for preview: {}",
# ffplay를 이용한 샘플 미리보기 실행에 실패했을 때의 로그
"ffplay_launch_failed": "Failed to start ffplay preview: {}",
# FFmpeg 자동 다운로드 및 설치 과정에서 오류가 발생했을 때의 로그
"ffmpeg_download_failed": "FFmpeg download failed: {}",
# 병렬 장면 분석 시, 개별 ffprobe 워커가 실패했을 때의 로그
"indexed_ffprobe_worker_failed": "Indexed ffprobe worker failed for interval {}: {}",
# 병렬 장면 분석에서 유효한 프레임을 전혀 찾지 못했을 때의 로그
"no_frames_found_parallel": "No frames found during indexed parallel analysis for {}. Falling back to sequential.",
# 병렬 장면 분석 결과, 유효한 데이터(초당 프레임 크기)가 없을 때의 로그
"parallel_frame_analysis_no_data": "Parallel frame size analysis for {} yielded no valid data. Falling back to reliable sequential analysis.",
}
# ==============================================================================
# 2. 데이터 클래스 및 헬퍼
# ==============================================================================
# 인코딩 작업을 위한 데이터 클래스
@dataclass
class EncodingTask:
"""
단일 인코딩 및 분석 작업에 필요한 모든 매개변수를 구조화하여 저장하는 데이터 클래스.
FFmpeg 명령어 생성, 결과 분석, 파일 경로 관리 등에 필요한 모든 정보를
하나의 객체에 통합하여 관리함으로써 코드의 가독성과 유지보수성을 향상시킴.
"""
ffmpeg_path: str # ffmpeg.exe 실행 파일의 전체 절대 경로
sample_path: str # 인코딩 및 분석의 기준이 되는 원본 샘플 영상 파일의 경로
temp_dir: str # 인코딩 결과물, 로그 파일 등 임시 파일들을 저장할 디렉토리 경로
codec: str # 사용할 비디오 코덱 이름 (예: 'libx265', 'h264_nvenc')
preset: str # 인코딩 속도/압축률 트레이드오프를 결정하는 프리셋 (예: 'slow', 'medium')
crf: int # CRF(Constant Rate Factor) 또는 그에 상응하는 품질 제어 값 (CQ, QP 등)
audio_option: str # 오디오 스트림 처리 방식 지정 ('Copy Audio', 'Remove Audio')
adv_opts: Dict[str, Any] # 사용자 정의 고급 인코딩 옵션을 담고 있는 딕셔너리
metrics: Dict[str, bool] = field(default_factory=dict) # PSNR, SSIM 등 추가적인 품질 메트릭의 계산 여부를 지정하는 딕셔너리
vmaf_model_path: str = "" # 사용할 특정 VMAF 모델 파일의 경로 (지정하지 않으면 FFmpeg 내장 모델 사용)
color_info: Dict[str, str] = field(default_factory=dict) # 비디오의 색상 정보(색공간, 색상 프라이머리, 전송 특성 등)를 담고 있는 딕셔너리
@property
def encoded_filename(self) -> str:
"""
이 작업으로 생성될 인코딩된 결과물의 파일명을 생성.
파일명 형식: encoded_{preset}_{crf}.mkv
예시: encoded_slow_23.mkv, encoded_medium_28.mkv
"""
return f"encoded_{self.preset}_{self.crf}.mkv"
@property
def encoded_path(self) -> str:
"""
인코딩된 결과물의 전체 절대 경로를 반환.
임시 디렉토리와 인코딩된 파일명을 결합하여 완전한 파일 경로를 생성함.
"""
return os.path.join(self.temp_dir, self.encoded_filename)
@property
def vmaf_log_filename(self) -> str:
"""
VMAF 분석 결과(JSON)를 저장할 로그 파일의 이름을 생성.
파일명 형식: vmaf_{preset}_{crf}.json
예시: vmaf_slow_23.json, vmaf_medium_28.json
"""
return f"vmaf_{self.preset}_{self.crf}.json"
@property
def vmaf_log_path(self) -> str:
"""
VMAF 분석 결과 로그 파일의 전체 절대 경로를 반환.
임시 디렉토리와 VMAF 로그 파일명을 결합하여 완전한 파일 경로를 생성함.
"""
return os.path.join(self.temp_dir, self.vmaf_log_filename)
# 파일 경로에 사용하기 안전한 문자열로 변환하는 헬퍼 함수
def sanitize_for_path(text):
"""
문자열을 파일 시스템에서 안전하게 사용할 수 있도록 정규화.
공백을 밑줄로 변환하고, 파일명으로 부적합한 특수문자들을 제거하여 Windows 환경에서 파일 시스템 오류를 방지함.
Args:
text (str): 정규화할 원본 문자열
Returns:
str: 경로에 사용 가능한 안전한 문자열 (최대 길이 제한 적용)
"""
# 문자열 정규화 (공백 및 특수문자 제거)
text = re.sub(r'\s+', '_', text) # 하나 이상의 연속된 공백 문자를 '_'로 대체
text = re.sub(r'[^\w\-_.]', '', text) # 단어 문자(\w), 밑줄, 하이픈, 점을 제외한 모든 문자를 제거
# 최종 문자열을 최대 길이에 맞게 잘라 반환
return text[:APP_CONFIG['max_filename_length']] # 보안 및 경로 길이 제한을 위해 최대 길이로 자름
def _get_subprocess_startupinfo():
"""
Windows 환경에서 subprocess 실행 시 콘솔 창을 숨기는 STARTUPINFO 객체를 설정.
FFmpeg 등의 명령줄 도구 실행 시 불필요한 콘솔 창이 나타나지 않도록 하여 사용자 경험을 개선함.
Returns:
subprocess.STARTUPINFO or None: Windows 환경에서는 STARTUPINFO 객체, 다른 환경에서는 None
"""
# Windows 환경일 경우에만 콘솔 창을 숨기는 설정 적용
if os.name == 'nt': # 운영 체제가 Windows인 경우에만 적용
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # 프로세스 생성 시 창을 숨기는 플래그 설정
return startupinfo
# 그 외 운영체제에서는 기본값(None)을 반환
return None # Windows가 아닌 경우 None을 반환하여 기본 동작을 따르도록 함
# Tkinter 위젯에 마우스를 올렸을 때 툴팁 표시하는 헬퍼 클래스
class ToolTip:
"""
Tkinter 위젯에 마우스 호버 시 툴팁을 표시하는 기능을 제공하는 클래스.
마우스가 위젯 위에 올라가면 설명 텍스트가 포함된 팝업 창을 표시하고, 마우스가 벗어나면 자동으로 숨김.
콤보박스 클릭 등 다양한 GUI 상호작용 상황에서 안정적으로 동작하도록 여러 이벤트를 처리함.
"""
def __init__(self, widget, text):
"""
ToolTip 객체를 초기화하고 위젯에 마우스 이벤트를 바인딩.
Args:
widget: 툴팁을 적용할 대상 Tkinter 위젯
text: 툴팁에 표시할 설명 문자열
"""
self.widget = widget # 툴팁을 적용할 대상 Tkinter 위젯
self.text = text # 툴팁에 표시할 문자열
self.tooltip_window = None # 툴팁을 표시하는 Toplevel 창 객체에 대한 참조
self.widget.bind("<Enter>", self.show_tooltip) # 마우스 커서가 위젯 영역에 진입할 때 툴팁 표시
self.widget.bind("<Leave>", self.hide_tooltip) # 마우스 커서가 위젯 영역에서 이탈할 때 툴팁 숨김
# 콤보박스와 같이 복잡한 위젯에서 <Leave> 이벤트가 누락될 수 있는 경우에 대비하여 추가 이벤트를 바인딩함.
self.widget.bind("<ButtonPress>", self.hide_tooltip) # 위젯 클릭 시 툴팁 숨김
self.widget.bind("<FocusOut>", self.hide_tooltip) # 위젯이 포커스를 잃을 때 툴팁 숨김
def show_tooltip(self, event=None):
"""
마우스 커서 근처에 제목 표시줄이 없는 Toplevel 창을 생성하고 툴팁 텍스트를 표시.
마우스가 위젯 위에 올라갔을 때 호출되며, 사용자에게 도움말 정보를 제공하는 작은 팝업 창을 생성함.
호출 시점에 기존 툴팁이 남아있으면 먼저 제거하여 중복 생성을 방지함.
Args:
event: 마우스 이벤트 객체 (사용되지 않음)
"""
# 이벤트 누락으로 인해 기존 툴팁 윈도우가 남아있는 경우를 대비하여 먼저 제거
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
# 툴팁이 표시될 화면상의 좌표를 계산
x, y, _, _ = self.widget.bbox("insert")
x += self.widget.winfo_rootx() + APP_CONFIG['tooltip_offset'] # 위젯의 전역 좌표를 기준으로 툴팁 위치 계산
y += self.widget.winfo_rooty() + APP_CONFIG['tooltip_offset']
# 툴팁을 담을 Toplevel 창을 생성하고 설정
self.tooltip_window = tk.Toplevel(self.widget)
self.tooltip_window.wm_overrideredirect(True) # 창 관리자 장식(제목 표시줄, 테두리 등) 제거
self.tooltip_window.wm_geometry(f"+{x}+{y}") # 계산된 위치에 창 배치
# 툴팁 텍스트를 표시할 라벨을 생성하고 창에 배치
label = tk.Label(self.tooltip_window, text=self.text, justify='left',
background="#ffffe0", relief='solid', borderwidth=1,
font=("tahoma", "8", "normal"))
label.pack(ipadx=1)
def hide_tooltip(self, event=None):
"""
표시된 툴팁 창을 제거.
마우스가 위젯에서 벗어났을 때 호출되며, 툴팁 창을 안전하게 제거함.
창이 존재하는 경우에만 제거 작업을 수행하여 오류를 방지함.
Args:
event: 마우스 이벤트 객체 (사용되지 않음)
"""
# 툴팁 창이 존재하는 경우에만 창을 제거
tw = self.tooltip_window
self.tooltip_window = None # 참조를 먼저 None으로 설정하여 중복 호출을 방지
if tw:
tw.destroy()
# ==============================================================================
# 3. GUI 팝업 윈도우 클래스
# ==============================================================================
# 애플리케이션의 모든 팝업 창 위한 기본 클래스
class BaseToplevel(tk.Toplevel):
"""
애플리케이션 내 Toplevel 창들의 공통 속성(제목, 모달 동작, 중앙 정렬 등)을 정의하는 기본 클래스.
모든 팝업 창이 공통적으로 가져야 할 기능들을 제공하며, 창의 중앙 정렬, 모달 동작, 제목 설정 등을 표준화함으로써 일관된 사용자 경험을 제공함.
"""
def __init__(self, parent, title, width, height):
"""
BaseToplevel 객체를 초기화하고 기본 속성을 설정.
Args:
parent: 부모 Tkinter 위젯
title: 창 제목
width: 창 너비
height: 창 높이
"""
super().__init__(parent)
# 창을 만들자마자 숨김 (설정 중 깜빡임을 방지하고 최종 상태만 보여주기 위함)
self.withdraw()
# 창의 기본 속성(제목, 부모 종속성, 모달 동작)을 설정
self.title(title)
self.transient(parent) # 부모 창에 종속되도록 설정
self.grab_set() # 모달 창으로 설정 (부모 창과 상호작용 차단)
# 창을 부모 창의 중앙에 위치시키는 메서드를 호출
self.center_window(parent, width, height)
# 모든 설정이 완료된 후 창을 다시 화면에 표시
self.deiconify()
def center_window(self, parent, w, h):
"""
창을 부모 창의 정중앙에 위치시키는 메서드.
팝업 창이 부모 창의 정확한 중앙에 표시되도록 위치를 계산하고 설정함.
부모 창의 크기와 위치를 기준으로 하여 사용자가 팝업 창을 쉽게 찾을 수 있도록 함.
Args:
parent: 부모 Tkinter 위젯 (위치 계산의 기준)
w: 팝업 창의 너비
h: 팝업 창의 높이
"""
# 창 상태 업데이트 및 최소 크기 설정
self.update_idletasks() # 위젯 업데이트 완료 대기
self.minsize(w, h) # 최소 크기 설정
# 부모 창의 위치와 크기 정보 가져오기
px, py = parent.winfo_x(), parent.winfo_y() # 부모 창의 위치
pw, ph = parent.winfo_width(), parent.winfo_height() # 부모 창의 크기
# 부모 창을 기준으로 중앙 좌표 계산
x = px + (pw // 2) - (w // 2) # 중앙 X 좌표 계산
y = py + (ph // 2) - (h // 2) # 중앙 Y 좌표 계산
# 계산된 위치와 크기를 현재 창에 적용
self.geometry(f'{w}x{h}+{x}+{y}') # 창 크기와 위치 설정
# FFmpeg 실행 로그를 보여주는 창 클래스
class LogViewerWindow(BaseToplevel):
"""
FFmpeg 명령어 실행 시 생성된 로그를 표시하는 전용 팝업 창 클래스.
인코딩 작업 중 발생한 FFmpeg 로그를 사용자가 읽기 쉽게 스크롤 가능한 텍스트 영역에 표시하며, 로그 내용의 수정을 방지하여 데이터 무결성을 보장함.
"""
def __init__(self, parent, log_content):
"""
LogViewerWindow 객체를 초기화하고 로그 내용을 표시.
Args:
parent: 부모 Tkinter 위젯
log_content: 표시할 FFmpeg 로그 내용
"""
# 부모 클래스(BaseToplevel)를 초기화하여 창의 기본 속성을 설정
super().__init__(
parent,
"FFmpeg Command Log",
APP_CONFIG["log_window_size"][0],
APP_CONFIG["log_window_size"][1],
)
# 로그 내용을 표시할 스크롤 가능한 텍스트 영역 위젯을 생성하고 배치
text_area = scrolledtext.ScrolledText(
self, wrap=tk.WORD, width=100, height=30, font=("Consolas", 9)
)
text_area.pack(expand=True, fill=tk.BOTH, padx=10, pady=10)
# 텍스트 영역에 전달받은 로그 내용을 삽입하고, 수정 불가능하도록 읽기 전용으로 설정
text_area.insert(tk.INSERT, log_content)
text_area.config(state=tk.DISABLED)
# 창을 닫는 'Close' 버튼을 생성하고 배치
close_button = ttk.Button(self, text="Close", command=self.destroy)
close_button.pack(pady=(0, 10))
# 인코딩 결과를 그래프로 시각화하는 창 클래스
class GraphWindow(BaseToplevel):
"""
Matplotlib을 사용하여 인코딩 결과 데이터를 2D 산점도(scatter plot)로 시각화하는 창 클래스.
다양한 인코딩 설정(프리셋, CRF 등)에 따른 품질 메트릭(VMAF, PSNR, SSIM 등)과
파일 크기 간의 관계를 직관적인 그래프로 표시함.
사용자는 X축과 Y축을 자유롭게 선택하여 원하는 관점에서 데이터를 분석할 수 있으며,
파레토 프론트와 같은 최적화 지점도 시각적으로 확인 가능함.
"""
def __init__(self, parent, results, formats):
"""
GraphWindow 객체를 초기화하고 그래프 데이터를 설정.
Args:
parent: 부모 Tkinter 위젯
results: 그래프로 시각화할 인코딩 결과 데이터 리스트
formats: 각 메트릭의 표시 형식을 정의하는 딕셔너리
"""
super().__init__(parent, "Results Graph", APP_CONFIG['graph_window_size'][0], APP_CONFIG['graph_window_size'][1])
self.results = results # 그래프로 시각화할 원본 데이터 (결과 딕셔너리의 리스트)
self.formats = formats # 툴팁에 숫자 서식을 지정하기 위한 딕셔너리
# 그래프의 X, Y축으로 선택 가능한 메트릭 목록. (표시 이름: (내부 데이터 키, 값이 높을수록 좋은지 여부))
# 각 메트릭은 (데이터_키, 높을수록_좋은지_여부) 형태의 튜플로 정의됨
self.plot_options = {
"CRF": ('crf', False), # CRF 값 (낮을수록 좋음)
"VMAF Score": ('vmaf', True), # VMAF 점수 (높을수록 좋음)
"VMAF 1% Low": ('vmaf_1_low', True), # VMAF 하위 1% 점수 (높을수록 좋음)
"Block Score": ('block_score', False), # 블록킹 점수 (낮을수록 좋음)
"File Size (MB)": ('size_mb', False), # 파일 크기 (낮을수록 좋음)
"Efficiency (VMAF/MB)": ('efficiency', True), # 효율성 (높을수록 좋음)
"PSNR": ('psnr', True), # PSNR 점수 (높을수록 좋음)
"SSIM": ('ssim', True) # SSIM 점수 (높을수록 좋음)
}
# X축과 Y축 선택, 파레토 프론트 표시 여부를 위한 Tkinter 제어 변수
self.x_axis_var = tk.StringVar(value="File Size (MB)") # X축 선택 변수 (기본값: 파일 크기)
self.y_axis_var = tk.StringVar(value="VMAF Score") # Y축 선택 변수 (기본값: VMAF 점수)
self.show_pareto_var = tk.BooleanVar(value=True) # 파레토 프론트 표시 여부 (기본값: 표시)
# 마우스 호버 시 툴팁 표시와 관련된 Matplotlib 객체 참조 변수
self.tooltip_annotation = None # 툴팁 텍스트를 담는 주석 객체
self.scatter = None # 산점도 플롯 객체
self.fig = None # Matplotlib Figure 객체
self.create_widgets()
self.protocol("WM_DELETE_WINDOW", self.on_close) # 창 닫기 버튼 클릭 시 on_close 메서드 호출
def _is_figure_valid(self):
"""
그래프 figure 객체가 유효한지 확인하는 헬퍼 메서드.
Returns:
bool: figure 객체가 유효하면 True, 그렇지 않으면 False
"""
return (hasattr(self, 'fig') and
self.fig and
self.fig.canvas and
self.fig.canvas.manager)
def on_close(self):
"""
창이 닫힐 때 Matplotlib 관련 이벤트 핸들러를 정리하여 메모리 누수 방지.
이 메서드는 창이 닫힐 때 호출되며, Matplotlib의 이벤트 핸들러와 관련된 메모리 리소스를 안전하게 해제함.
특히 키보드 이벤트 핸들러의 연결을 해제하여 메모리 누수와 예상치 못한 동작을 방지함.
"""
# 그래프 창의 키보드 이벤트 핸들러를 안전하게 해제
if self._is_figure_valid():
handler_id = getattr(self.fig.canvas.manager, 'key_press_handler_id', None)
if handler_id is not None:
self.fig.canvas.mpl_disconnect(handler_id)
self.destroy()
def create_widgets(self):
"""
그래프 창의 UI 위젯들(축 선택 콤보박스, 그래프 캔버스, 툴바 등)을 생성.
이 메서드는 그래프 창의 모든 사용자 인터페이스 요소를 구성하며,
축 선택, 파레토 프론트 표시 옵션, 그래프 캔버스, 네비게이션 툴바 등을 포함함.
"""
# 상단 컨트롤 프레임 (X/Y축 선택)
controls_frame = ttk.Frame(self, padding=(10, 10, 10, 0))
controls_frame.pack(fill=tk.X)
ttk.Label(controls_frame, text="X-Axis:").pack(side=tk.LEFT, padx=(0, 5))
x_combo = ttk.Combobox(controls_frame, textvariable=self.x_axis_var, values=list(self.plot_options.keys()), state="readonly", width=20)
x_combo.pack(side=tk.LEFT, padx=(0, 20))
x_combo.bind("<<ComboboxSelected>>", self._redraw_plot) # 선택 변경 시 그래프 다시 그리기
ttk.Label(controls_frame, text="Y-Axis:").pack(side=tk.LEFT, padx=(0, 5))
y_combo = ttk.Combobox(controls_frame, textvariable=self.y_axis_var, values=list(self.plot_options.keys()), state="readonly", width=20)
y_combo.pack(side=tk.LEFT, padx=(0, 20))
y_combo.bind("<<ComboboxSelected>>", self._redraw_plot)
pareto_check = ttk.Checkbutton(controls_frame, text="Show Pareto Front", variable=self.show_pareto_var, command=self._redraw_plot)
pareto_check.pack(side=tk.LEFT, padx=(10, 0))
ToolTip(pareto_check, "Highlights the most efficient encodes (no other point is better on both axes).")
# Matplotlib Figure와 Tkinter용 Canvas 생성
self.fig = Figure(figsize=(8, 6), dpi=100)
self.ax = self.fig.add_subplot(111)
self.canvas = FigureCanvasTkAgg(self.fig, master=self)
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=10, pady=5)
self.toolbar = NavigationToolbar2Tk(self.canvas, self) # Matplotlib 네비게이션 툴바 추가
self.toolbar.update()
# 마우스 호버 시 툴팁으로 사용될 주석(annotation) 객체를 생성하고 숨김 상태로 초기화
self.tooltip_annotation = self.ax.annotate("", xy=(0,0), xytext=(0,0),
textcoords="offset points",
bbox=dict(boxstyle="round,pad=0.5", fc="yellow", alpha=0.9),
# 툴팁 화살표를 약간 구부려 커서와의 겹침을 최소화
arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=0.2"))
self.tooltip_annotation.set_visible(False)
self.fig.canvas.mpl_connect("motion_notify_event", self._on_hover) # 마우스 움직임 이벤트를 _on_hover 메서드에 연결
self._redraw_plot() # 초기 그래프 그리기
def _redraw_plot(self, event=None):
"""
사용자 선택에 따라 그래프를 완전히 다시 그림.
이 메서드는 사용자가 X축, Y축, 또는 파레토 프론트 표시 옵션을 변경할 때 호출됨.
선택된 축에 맞게 데이터를 재구성하고, 파레토 프론트를 계산하여 새로운 그래프를 생성함.
Args:
event: Tkinter 이벤트 객체 (사용되지 않음)
"""
if self.x_axis_var.get() == self.y_axis_var.get():
messagebox.showwarning("Selection Error", "X and Y axes cannot be the same.", parent=self)
return
# 선택된 축에 대한 정보와 데이터 추출
x_display = self.x_axis_var.get() # 사용자가 선택한 X축 메트릭 이름
y_display = self.y_axis_var.get() # 사용자가 선택한 Y축 메트릭 이름
x_key, _ = self.plot_options[x_display] # X축 메트릭의 내부 데이터 키
y_key, _ = self.plot_options[y_display] # Y축 메트릭의 내부 데이터 키
x_vals = [r.get(x_key, 0) for r in self.results] # 모든 결과에서 X축 값 추출
y_vals = [r.get(y_key, 0) for r in self.results] # 모든 결과에서 Y축 값 추출
color_data = [r.get('vmaf', r.get('efficiency', 0)) for r in self.results] # 점의 색상을 결정할 데이터 (VMAF 우선, 없으면 효율성)
# 그래프 초기화 및 산점도 생성
self.ax.clear() # 이전 그래프 내용을 모두 지움
self.scatter = self.ax.scatter(x_vals, y_vals, c=color_data, cmap='viridis_r', alpha=0.7) # 산점도 생성 (viridis_r 컬러맵 사용)
# 그래프 제목, 라벨, 그리드 설정
self.ax.set_title(f'{y_display} vs {x_display}') # 그래프 제목 설정
self.ax.set_xlabel(x_display) # X축 라벨 설정
self.ax.set_ylabel(y_display) # Y축 라벨 설정
self.ax.grid(True) # 격자 표시 활성화
# 파레토 프론트 계산 및 표시
if self.show_pareto_var.get(): # 파레토 프론트 표시 옵션이 활성화된 경우
pareto_x, pareto_y = self._calculate_pareto_front(x_vals, y_vals) # 파레토 프론트 계산
if pareto_x: # 파레토 프론트가 존재하는 경우
self.ax.plot(pareto_x, pareto_y, 'r-o', label='Pareto Front', markersize=4, linewidth=1.5, alpha=0.8) # 빨간색 선과 원으로 파레토 프론트 표시
self.ax.legend() # 범례 표시
# 툴팁 주석 객체 재생성
self.tooltip_annotation = self.ax.annotate("", xy=(0,0), xytext=(0,0), # 빈 툴팁 주석 객체 생성
textcoords="offset points", # 텍스트 위치를 오프셋 포인트로 설정
bbox=dict(boxstyle="round,pad=0.5", fc="yellow", alpha=0.9), # 노란색 둥근 배경 박스
arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=0.2")) # 화살표 스타일 설정
self.tooltip_annotation.set_visible(False) # 초기에는 툴팁 숨김
self.canvas.draw() # 캔버스에 변경 사항을 반영하여 다시 그림
def _calculate_pareto_front(self, x_vals, y_vals):
"""
주어진 2D 데이터 포인트 집합으로부터 파레토 프론트(Pareto front)를 계산.
파레토 프론트는 어떤 다른 점에 의해 모든 축에서 동시에 열등하지 않은 점들의 집합임.
(예를 들어, VMAF는 더 높으면서 파일 크기는 더 작은 점이 존재하지 않는 점들이 파레토 프론트를 구성함.
이는 최적화 문제에서 가장 효율적인 해들의 집합을 나타냄.)
Args:
x_vals: X축 값들의 리스트
y_vals: Y축 값들의 리스트
Returns:
tuple: (파레토_프론트_X값들, 파레토_프론트_Y값들) 또는 빈 리스트들
"""
# 현재 선택된 축의 특성(값이 높을수록 좋은지 여부)을 가져옴
x_display = self.x_axis_var.get() # 현재 선택된 X축 메트릭 이름
y_display = self.y_axis_var.get() # 현재 선택된 Y축 메트릭 이름
_, x_higher_is_better = self.plot_options[x_display] # X축에서 값이 높을수록 좋은지 여부
_, y_higher_is_better = self.plot_options[y_display] # Y축에서 값이 높을수록 좋은지 여부
# X축을 기준으로 점들을 정렬
points = sorted(zip(x_vals, y_vals), key=lambda p: p[0], reverse=x_higher_is_better)
# 파레토 프론트 계산
pareto_front = [] # 파레토 프론트에 속하는 점들을 저장할 리스트
best_y_so_far = -float('inf') if y_higher_is_better else float('inf') # Y축 최적값 초기화
for x, y in points: # 정렬된 점들을 순회
is_better = (y > best_y_so_far) if y_higher_is_better else (y < best_y_so_far) # Y축 기준으로 더 좋은지 판단
if is_better: # 더 우월한 경우
pareto_front.append((x, y)) # 파레토 프론트에 점 추가
best_y_so_far = y # 최적 Y값 업데이트
if not pareto_front: # 파레토 프론트가 비어있는 경우
return [], [] # 빈 리스트 반환
return zip(*pareto_front) # x, y 좌표 리스트로 분리하여 반환.
def _on_hover(self, event):
"""
그래프의 데이터 포인트 위에 마우스를 올렸을 때 해당 점의 상세 정보를 툴팁으로 표시.
이 메서드는 마우스 움직임 이벤트에 의해 호출되며, 마우스가 데이터 포인트 위에 있을 때
해당 포인트의 모든 메트릭 정보를 상세한 툴팁으로 표시함. 마우스가 포인트에서 벗어나면
툴팁을 자동으로 숨김.
Args:
event: Matplotlib 마우스 이벤트 객체
"""
# 마우스가 그래프 영역 밖에 있으면 즉시 종료
if event.inaxes != self.ax: # 마우스가 그래프 영역 밖에 있으면 툴팁 숨김
if self.tooltip_annotation.get_visible(): # 툴팁이 현재 보이는 상태라면
self.tooltip_annotation.set_visible(False) # 툴팁 숨김
self.canvas.draw_idle() # 캔버스 다시 그리기
return # 함수 종료
is_visible = self.tooltip_annotation.get_visible() # 현재 툴팁 표시 상태 확인
contains, ind_info = self.scatter.contains(event) # 마우스 위치에 데이터 포인트가 있는지 확인
if contains: # 마우스가 데이터 포인트 위에 있으면
# 포인트 정보 추출
point_index = ind_info['ind'][0] # 마우스가 가리키는 포인트의 인덱스
pos = self.scatter.get_offsets()[point_index] # 해당 포인트의 좌표 위치
point_data = self.results[point_index] # 해당 포인트의 데이터
# 툴팁 텍스트 생성
tooltip_text = ( # 툴팁에 표시할 텍스트 구성
f"Preset: {point_data.get('preset', '')}\n"
f"CRF: {point_data.get('crf', 0)}\n"
f"VMAF: {point_data.get('vmaf', 0):{self.formats['vmaf']}} (1% Low: {point_data.get('vmaf_1_low', 0):{self.formats['vmaf_1_low']}})\n"
f"PSNR: {point_data.get('psnr', 0):{self.formats['psnr']}}\n"
f"SSIM: {point_data.get('ssim', 0):{self.formats['ssim']}}\n"
f"Block Score: {point_data.get('block_score', 0):{self.formats['block_score']}}\n"
f"Size: {point_data.get('size_mb', 0):{self.formats['size_mb']}} MB\n"
f"Efficiency: {point_data.get('efficiency', 0):{self.formats['efficiency']}}"
)
# 툴팁 위치 및 내용 설정
self.tooltip_annotation.xy = pos # 툴팁 위치를 포인트 위치로 설정
self.tooltip_annotation.set_text(tooltip_text) # 툴팁 텍스트 설정
# 툴팁이 커서를 가리지 않도록 위치 동적 조정
padding = APP_CONFIG['tooltip_padding'] # 툴팁과 마우스 커서 사이의 여백
bbox = self.ax.get_window_extent() # 그래프 영역의 경계 상자
if event.x > bbox.x0 + bbox.width / 2: # 마우스가 그래프 오른쪽 절반에 있으면
x_offset = -padding; self.tooltip_annotation.set_horizontalalignment('right') # 툴팁을 왼쪽으로 배치
else: # 마우스가 그래프 왼쪽 절반에 있으면
x_offset = padding; self.tooltip_annotation.set_horizontalalignment('left') # 툴팁을 오른쪽으로 배치
if event.y > bbox.y0 + bbox.height / 2: # 마우스가 그래프 아래쪽 절반에 있으면
y_offset = -padding; self.tooltip_annotation.set_verticalalignment('top') # 툴팁을 위쪽으로 배치
else: # 마우스가 그래프 위쪽 절반에 있으면
y_offset = padding; self.tooltip_annotation.set_verticalalignment('bottom') # 툴팁을 아래쪽으로 배치
self.tooltip_annotation.set_position((x_offset, y_offset)) # 계산된 오프셋으로 툴팁 위치 설정
# 툴팁 표시
self.tooltip_annotation.set_visible(True) # 툴팁 표시
self.canvas.draw_idle() # 캔버스 다시 그리기
elif is_visible: # 마우스가 포인트에서 벗어났고, 툴팁이 보이는 상태라면 툴팁 숨김
self.tooltip_annotation.set_visible(False) # 툴팁 숨김
self.canvas.draw_idle() # 캔버스 다시 그리기
# 전체 영상에 적용할 FFmpeg 명령어를 생성하고 보여주는 창 클래스
class CommandGeneratorWindow(BaseToplevel):
"""
사용자가 선택한 인코딩 설정을 전체 비디오에 적용할 FFmpeg 명령어를 생성하여 보여주는 창 클래스.
최적화 결과에서 선택된 설정을 기반으로 전체 비디오에 적용할 수 있는 완전한 FFmpeg 명령어를 생성하고 표시함.
사용자는 생성된 명령어를 복사하여 터미널이나 배치 파일에서 직접 실행할 수 있음.
"""
def __init__(self, parent, command):
"""
CommandGeneratorWindow 객체를 초기화하고 UI를 구성.
Args:
parent: 부모 Tkinter 위젯
command: 표시할 FFmpeg 명령어 문자열
"""
# 부모 클래스(BaseToplevel)를 초기화하여 창의 기본 속성을 설정
super().__init__(parent, "Generated FFmpeg Command for Full Video", APP_CONFIG['command_window_size'][0], APP_CONFIG['command_window_size'][1])
# 상단에 안내 문구를 표시하는 라벨을 생성하고 배치
ttk.Label(self, text="Copy the command below to run the final encode on the entire video file:").pack(padx=10, pady=(10,5))
# FFmpeg 명령어를 표시할 스크롤 가능한 텍스트 영역을 생성하고 설정
text_area = scrolledtext.ScrolledText(self, wrap=tk.WORD, height=5, font=("Consolas", 10))
text_area.pack(expand=True, fill=tk.BOTH, padx=10, pady=5)
text_area.insert(tk.INSERT, command) # 생성된 명령어를 텍스트 영역에 삽입
text_area.config(state=tk.DISABLED) # 읽기 전용으로 설정
# 하단 버튼들을 담을 프레임을 생성하고 배치
button_frame = ttk.Frame(self)
button_frame.pack(fill=tk.X, padx=10, pady=(0, 10))
# 'Copy'와 'Close' 버튼을 프레임 내에 생성하고 배치
ttk.Button(button_frame, text="Copy to Clipboard", command=lambda: self.copy_to_clipboard(command)).pack(side=tk.LEFT)
ttk.Button(button_frame, text="Close", command=self.destroy).pack(side=tk.RIGHT)
def copy_to_clipboard(self, command):
"""
명령어를 시스템 클립보드에 복사.
사용자가 'Copy to Clipboard' 버튼을 클릭했을 때 호출되며, 생성된 FFmpeg 명령어를 시스템 클립보드에 복사함.
복사 완료 후에는 사용자에게 성공 메시지를 표시하여 피드백을 제공함.
Args:
command: 클립보드에 복사할 FFmpeg 명령어 문자열
"""
# 클립보드의 내용을 지우고 새로운 명령어로 채움
self.clipboard_clear() # 클립보드 내용 지우기
self.clipboard_append(command) # 명령어를 클립보드에 추가
# 사용자에게 복사가 완료되었음을 알림
messagebox.showinfo("Copied", "Command copied to clipboard.", parent=self)
# 사용자가 수동으로 샘플 구간 설정하는 창 클래스
class ManualTimeSelectorWindow(BaseToplevel):
"""
사용자가 시:분:초.밀리초 형식으로 샘플의 시작 및 종료 시간을 수동으로 설정하는 창 클래스.
사용자가 원하는 구간을 정확하게 선택할 수 있도록 직관적인 시간 입력 인터페이스를 제공함.
시, 분, 초, 밀리초를 각각 별도의 스핀박스로 입력받아 정확한 시간 범위를 설정할 수 있으며,
비디오 길이를 벗어나는 값은 자동으로 조정하여 유효성을 보장함.
"""
def __init__(self, parent, callback, video_duration, current_start_s, current_end_s):
"""
ManualTimeSelectorWindow 객체를 초기화하고 시간 입력 UI를 구성.
Args:
parent: 부모 Tkinter 위젯
callback: 시간 설정 완료 시 호출될 콜백 함수
video_duration: 전체 비디오 길이 (초)
current_start_s: 현재 설정된 시작 시간 (초)
current_end_s: 현재 설정된 종료 시간 (초)
"""
super().__init__(parent, "Set Manual Sample Time", APP_CONFIG['time_selector_window_size'][0], APP_CONFIG['time_selector_window_size'][1])
self.callback = callback # 시간 설정이 완료되면 호출될 함수
self.video_duration = video_duration # 전체 비디오 길이 (초), 입력값 검증에 사용
# 현재 시작/종료 시간(초)을 시:분:초.밀리초 형식으로 변환하여 각 Tkinter 변수에 저장.
s_h, s_m, s_s, s_ms = self._seconds_to_hmsms(current_start_s) # 시작 시간을 시:분:초:밀리초로 분해
e_h, e_m, e_s, e_ms = self._seconds_to_hmsms(current_end_s) # 종료 시간을 시:분:초:밀리초로 분해
self.start_h_var = tk.IntVar(value=s_h) # 시작 시간 - 시
self.start_m_var = tk.IntVar(value=s_m) # 시작 시간 - 분
self.start_s_var = tk.IntVar(value=s_s) # 시작 시간 - 초
self.start_ms_var = tk.IntVar(value=s_ms) # 시작 시간 - 밀리초
self.end_h_var = tk.IntVar(value=e_h) # 종료 시간 - 시
self.end_m_var = tk.IntVar(value=e_m) # 종료 시간 - 분
self.end_s_var = tk.IntVar(value=e_s) # 종료 시간 - 초
self.end_ms_var = tk.IntVar(value=e_ms) # 종료 시간 - 밀리초
self.create_widgets() # UI 위젯들 생성
def _seconds_to_hmsms(self, seconds: float) -> Tuple[int, int, int, int]:
"""
초 단위 시간을 (시, 분, 초, 밀리초) 튜플로 변환.
소수점이 포함된 초 단위 시간을 사용자가 이해하기 쉬운
시:분:초.밀리초 형식으로 변환함. 예를 들어, 3661.5초는 (1, 1, 1, 500)으로 변환됨.
Args:
seconds: 변환할 초 단위 시간 (소수점 포함 가능)
Returns:
Tuple[int, int, int, int]: (시, 분, 초, 밀리초) 형태의 튜플
"""
h = int(seconds // 3600) # 시간 계산 (3600초 = 1시간)
m = int((seconds % 3600) // 60) # 분 계산 (60초 = 1분)
s = int(seconds % 60) # 초 계산 (60초로 나눈 나머지)
ms = int((seconds - int(seconds)) * 1000) # 밀리초 계산 (소수점 부분을 1000배)
return h, m, s, ms # (시, 분, 초, 밀리초) 튜플 반환
def create_widgets(self):
"""
시간 입력을 위한 스핀박스, 라벨, 버튼 등의 위젯들을 생성.