ssboost commited on
Commit
9a3b674
·
verified ·
1 Parent(s): 64d9fca

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +989 -1
app.py CHANGED
@@ -1,2 +1,990 @@
 
1
  import os
2
- exec(os.environ.get('APP'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
  import os
3
+ import tempfile
4
+ import base64
5
+ import math
6
+ import traceback
7
+ import numpy as np
8
+ from PIL import Image
9
+
10
+ from moviepy.editor import VideoFileClip, vfx
11
+ from shutil import copyfile
12
+ from datetime import datetime, timedelta
13
+
14
+ # 별도 CSS 파일 불러오기 함수
15
+ def load_css():
16
+ """외부 CSS 파일을 불러오거나, 파일이 없을 경우 내장 스타일 반환"""
17
+ try:
18
+ with open('styles.css', 'r', encoding='utf-8') as f:
19
+ return f.read()
20
+ except:
21
+ # 파일이 없을 경우 내장 스타일 반환 (다크모드 지원)
22
+ return """
23
+ /* FontAwesome 아이콘 포함 */
24
+ @import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css");
25
+
26
+ /* ============================================
27
+ 다크모드 자동 변경 템플릿 CSS
28
+ ============================================ */
29
+
30
+ /* 1. CSS 변수 정의 (라이트모드 - 기본값) */
31
+ :root {
32
+ /* 메인 컬러 */
33
+ --primary-color: #FB7F0D;
34
+ --secondary-color: #ff9a8b;
35
+ --accent-color: #FF6B6B;
36
+
37
+ /* 배경 컬러 */
38
+ --background-color: #FFFFFF;
39
+ --card-bg: #ffffff;
40
+ --input-bg: #ffffff;
41
+
42
+ /* 텍스트 컬러 */
43
+ --text-color: #334155;
44
+ --text-secondary: #64748b;
45
+
46
+ /* 보더 및 구분선 */
47
+ --border-color: #dddddd;
48
+ --border-light: #e5e5e5;
49
+
50
+ /* 테이블 컬러 */
51
+ --table-even-bg: #f3f3f3;
52
+ --table-hover-bg: #f0f0f0;
53
+
54
+ /* 그림자 */
55
+ --shadow: 0 8px 30px rgba(251, 127, 13, 0.08);
56
+ --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1);
57
+
58
+ /* 기타 */
59
+ --border-radius: 18px;
60
+ }
61
+
62
+ /* 2. 다크모드 색상 변수 (자동 감지) */
63
+ @media (prefers-color-scheme: dark) {
64
+ :root {
65
+ /* 배경 컬러 */
66
+ --background-color: #1a1a1a;
67
+ --card-bg: #2d2d2d;
68
+ --input-bg: #2d2d2d;
69
+
70
+ /* 텍스트 컬러 */
71
+ --text-color: #e5e5e5;
72
+ --text-secondary: #a1a1aa;
73
+
74
+ /* 보더 및 구분선 */
75
+ --border-color: #404040;
76
+ --border-light: #525252;
77
+
78
+ /* 테이블 컬러 */
79
+ --table-even-bg: #333333;
80
+ --table-hover-bg: #404040;
81
+
82
+ /* 그림자 */
83
+ --shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
84
+ --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2);
85
+ }
86
+ }
87
+
88
+ /* 3. 수동 다크모드 클래스 (Gradio 토글용) */
89
+ [data-theme="dark"],
90
+ .dark,
91
+ .gr-theme-dark {
92
+ /* 배경 컬러 */
93
+ --background-color: #1a1a1a;
94
+ --card-bg: #2d2d2d;
95
+ --input-bg: #2d2d2d;
96
+
97
+ /* 텍스트 컬러 */
98
+ --text-color: #e5e5e5;
99
+ --text-secondary: #a1a1aa;
100
+
101
+ /* 보더 및 구분선 */
102
+ --border-color: #404040;
103
+ --border-light: #525252;
104
+
105
+ /* 테이블 컬러 */
106
+ --table-even-bg: #333333;
107
+ --table-hover-bg: #404040;
108
+
109
+ /* 그림자 */
110
+ --shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
111
+ --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2);
112
+ }
113
+
114
+ /* 4. 기본 요소 다크모드 적용 */
115
+ body {
116
+ font-family: 'Pretendard', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
117
+ background-color: var(--background-color) !important;
118
+ color: var(--text-color) !important;
119
+ line-height: 1.6;
120
+ transition: background-color 0.3s ease, color 0.3s ease;
121
+ }
122
+
123
+ footer {
124
+ visibility: hidden;
125
+ }
126
+
127
+ /* 5. Gradio 컨테이너 강제 적용 */
128
+ .gradio-container,
129
+ .gradio-container *,
130
+ .gr-app,
131
+ .gr-app *,
132
+ .gr-interface {
133
+ background-color: var(--background-color) !important;
134
+ color: var(--text-color) !important;
135
+ }
136
+
137
+ .container {
138
+ max-width: 1200px;
139
+ margin: 0 auto;
140
+ }
141
+
142
+ .header {
143
+ background: linear-gradient(135deg, #FB7F0D, #FF9A5B);
144
+ padding: 2rem;
145
+ border-radius: 15px;
146
+ margin-bottom: 20px;
147
+ box-shadow: var(--shadow);
148
+ text-align: center;
149
+ color: white;
150
+ }
151
+
152
+ .header h1 {
153
+ margin: 0;
154
+ font-size: 2.5rem;
155
+ font-weight: 700;
156
+ }
157
+
158
+ .header p {
159
+ margin: 10px 0 0;
160
+ font-size: 1.2rem;
161
+ opacity: 0.9;
162
+ }
163
+
164
+ .card {
165
+ background-color: var(--card-bg) !important;
166
+ border-radius: var(--border-radius);
167
+ padding: 20px;
168
+ margin: 10px 0;
169
+ box-shadow: var(--shadow);
170
+ border: 1px solid var(--border-color);
171
+ color: var(--text-color) !important;
172
+ transition: all 0.3s ease;
173
+ }
174
+
175
+ .button-primary {
176
+ border-radius: 30px !important;
177
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)) !important;
178
+ color: white !important;
179
+ font-size: 18px !important;
180
+ padding: 10px 20px !important;
181
+ border: none;
182
+ box-shadow: 0 4px 8px rgba(251, 127, 13, 0.25);
183
+ transition: transform 0.3s ease;
184
+ text-align: center;
185
+ font-weight: 600;
186
+ }
187
+
188
+ .button-primary:hover {
189
+ transform: translateY(-2px);
190
+ box-shadow: 0 6px 12px rgba(251, 127, 13, 0.3);
191
+ }
192
+
193
+ .section-title {
194
+ display: flex;
195
+ align-items: center;
196
+ font-size: 20px;
197
+ font-weight: 700;
198
+ color: var(--text-color) !important;
199
+ margin-bottom: 15px;
200
+ padding-bottom: 8px;
201
+ border-bottom: 2px solid var(--primary-color);
202
+ }
203
+
204
+ .section-title i {
205
+ margin-right: 10px;
206
+ color: var(--primary-color);
207
+ }
208
+
209
+ .guide-container {
210
+ background-color: var(--card-bg) !important;
211
+ border-radius: var(--border-radius);
212
+ padding: 1.5rem;
213
+ margin-bottom: 1.5rem;
214
+ border: 1px solid var(--border-color);
215
+ color: var(--text-color) !important;
216
+ }
217
+
218
+ .guide-title {
219
+ font-size: 1.3rem;
220
+ font-weight: 700;
221
+ color: var(--primary-color) !important;
222
+ margin-bottom: 1rem;
223
+ display: flex;
224
+ align-items: center;
225
+ }
226
+
227
+ .guide-title i {
228
+ margin-right: 0.8rem;
229
+ font-size: 1.3rem;
230
+ }
231
+
232
+ .guide-item {
233
+ display: flex;
234
+ margin-bottom: 0.8rem;
235
+ align-items: flex-start;
236
+ }
237
+
238
+ .guide-number {
239
+ background-color: var(--primary-color);
240
+ color: white;
241
+ width: 24px;
242
+ height: 24px;
243
+ border-radius: 50%;
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ font-weight: bold;
248
+ margin-right: 10px;
249
+ flex-shrink: 0;
250
+ font-size: 14px;
251
+ }
252
+
253
+ .guide-text {
254
+ flex: 1;
255
+ line-height: 1.6;
256
+ color: var(--text-color) !important;
257
+ }
258
+
259
+ .feature-tag {
260
+ display: inline-block;
261
+ background-color: rgba(251, 127, 13, 0.1);
262
+ color: var(--primary-color);
263
+ padding: 3px 10px;
264
+ border-radius: 12px;
265
+ font-size: 14px;
266
+ font-weight: 600;
267
+ margin-right: 8px;
268
+ margin-bottom: 8px;
269
+ }
270
+
271
+ .input-label {
272
+ font-weight: 600;
273
+ margin-bottom: 8px;
274
+ color: var(--text-color) !important;
275
+ }
276
+
277
+ /* 6. 카드 및 패널 스타일 */
278
+ .gr-form,
279
+ .gr-box,
280
+ .gr-panel,
281
+ .custom-frame,
282
+ [class*="frame"],
283
+ [class*="panel"] {
284
+ background-color: var(--card-bg) !important;
285
+ border-color: var(--border-color) !important;
286
+ color: var(--text-color) !important;
287
+ box-shadow: var(--shadow) !important;
288
+ }
289
+
290
+ /* 7. 입력 필드 스타일 */
291
+ input[type="text"],
292
+ input[type="number"],
293
+ input[type="email"],
294
+ input[type="password"],
295
+ textarea,
296
+ select,
297
+ .gr-input,
298
+ .gr-text-input,
299
+ .gr-textarea,
300
+ .gr-dropdown {
301
+ background-color: var(--input-bg) !important;
302
+ color: var(--text-color) !important;
303
+ border-color: var(--border-color) !important;
304
+ border-radius: var(--border-radius) !important;
305
+ border: 1px solid var(--border-color) !important;
306
+ padding: 12px !important;
307
+ transition: all 0.3s ease !important;
308
+ }
309
+
310
+ input[type="text"]:focus,
311
+ input[type="number"]:focus,
312
+ input[type="email"]:focus,
313
+ input[type="password"]:focus,
314
+ textarea:focus,
315
+ select:focus,
316
+ .gr-input:focus,
317
+ .gr-text-input:focus,
318
+ .gr-textarea:focus,
319
+ .gr-dropdown:focus {
320
+ border-color: var(--primary-color) !important;
321
+ outline: none !important;
322
+ box-shadow: 0 0 0 2px rgba(251, 127, 13, 0.2) !important;
323
+ }
324
+
325
+ /* 8. 라벨 및 텍스트 요소 */
326
+ label,
327
+ .gr-label,
328
+ .gr-checkbox label,
329
+ .gr-radio label,
330
+ p, span, div {
331
+ color: var(--text-color) !important;
332
+ }
333
+
334
+ /* 9. 테이블 스타일 */
335
+ table {
336
+ background-color: var(--card-bg) !important;
337
+ color: var(--text-color) !important;
338
+ border-color: var(--border-color) !important;
339
+ }
340
+
341
+ table th {
342
+ background-color: var(--primary-color) !important;
343
+ color: white !important;
344
+ border-color: var(--border-color) !important;
345
+ }
346
+
347
+ table td {
348
+ background-color: var(--card-bg) !important;
349
+ color: var(--text-color) !important;
350
+ border-color: var(--border-color) !important;
351
+ }
352
+
353
+ table tbody tr:nth-child(even) {
354
+ background-color: var(--table-even-bg) !important;
355
+ }
356
+
357
+ table tbody tr:hover {
358
+ background-color: var(--table-hover-bg) !important;
359
+ }
360
+
361
+ /* 10. 체크박스 및 라디오 버튼 */
362
+ input[type="checkbox"],
363
+ input[type="radio"] {
364
+ accent-color: var(--primary-color) !important;
365
+ }
366
+
367
+ /* 11. 스크롤바 스타일 */
368
+ ::-webkit-scrollbar {
369
+ width: 8px;
370
+ height: 8px;
371
+ }
372
+
373
+ ::-webkit-scrollbar-track {
374
+ background: var(--card-bg);
375
+ border-radius: 10px;
376
+ }
377
+
378
+ ::-webkit-scrollbar-thumb {
379
+ background: var(--primary-color);
380
+ border-radius: 10px;
381
+ }
382
+
383
+ ::-webkit-scrollbar-thumb:hover {
384
+ background: var(--secondary-color);
385
+ }
386
+
387
+ /* 12. 아코디언 및 드롭다운 */
388
+ details {
389
+ background-color: var(--card-bg) !important;
390
+ border-color: var(--border-color) !important;
391
+ color: var(--text-color) !important;
392
+ }
393
+
394
+ details summary {
395
+ background-color: var(--card-bg) !important;
396
+ color: var(--text-color) !important;
397
+ }
398
+
399
+ /* 13. 추가 Gradio 컴포넌트들 */
400
+ .gr-block,
401
+ .gr-group,
402
+ .gr-row,
403
+ .gr-column {
404
+ background-color: var(--background-color) !important;
405
+ color: var(--text-color) !important;
406
+ }
407
+
408
+ /* 14. 버튼은 기존 스타일 유지 (primary-color 사용) */
409
+ button:not([class*="custom"]):not([class*="primary"]):not([class*="secondary"]) {
410
+ background-color: var(--card-bg) !important;
411
+ color: var(--text-color) !important;
412
+ border-color: var(--border-color) !important;
413
+ }
414
+
415
+ /* 15. 코드 블록 및 pre 태그 */
416
+ code,
417
+ pre,
418
+ .code-block {
419
+ background-color: var(--table-even-bg) !important;
420
+ color: var(--text-color) !important;
421
+ border-color: var(--border-color) !important;
422
+ }
423
+
424
+ /* 16. 전환 애니메이션 */
425
+ * {
426
+ transition: background-color 0.3s ease,
427
+ color 0.3s ease,
428
+ border-color 0.3s ease !important;
429
+ }
430
+
431
+ /* 기존 GIF 변환기 전용 스타일 유지 */
432
+ .left-column, .right-column {
433
+ border: 2px solid var(--primary-color);
434
+ border-radius: var(--border-radius);
435
+ padding: 20px;
436
+ background-color: var(--card-bg) !important;
437
+ color: var(--text-color) !important;
438
+ }
439
+ .left-column {
440
+ margin-right: 10px;
441
+ }
442
+ .right-column {
443
+ margin-left: 10px;
444
+ }
445
+ .section-border {
446
+ border: 1px solid var(--border-color);
447
+ border-radius: 6px;
448
+ padding: 10px;
449
+ margin-bottom: 15px;
450
+ background-color: var(--card-bg) !important;
451
+ color: var(--text-color) !important;
452
+ }
453
+ """
454
+
455
+ ########################################
456
+ # 1) PIL ANTIALIAS 에러 대응 (Monkey-patch)
457
+ ########################################
458
+ try:
459
+ from PIL import Resampling
460
+ if not hasattr(Image, "ANTIALIAS"):
461
+ Image.ANTIALIAS = Resampling.LANCZOS
462
+ except ImportError:
463
+ pass
464
+
465
+ ########################################
466
+ # 2) 내부 디버그 로깅 (UI에서 미출력)
467
+ ########################################
468
+ DEBUG_LOG_LIST = []
469
+
470
+ def log_debug(msg: str):
471
+ print("[DEBUG]", msg)
472
+ DEBUG_LOG_LIST.append(msg)
473
+
474
+ ########################################
475
+ # 3) 시간 형식 변환 유틸리티 함수
476
+ ########################################
477
+ END_EPSILON = 0.01
478
+
479
+ def round_down_to_one_decimal(value: float) -> float:
480
+ return math.floor(value * 10) / 10
481
+
482
+ def safe_end_time(duration: float) -> float:
483
+ tmp = duration - END_EPSILON
484
+ if tmp < 0:
485
+ tmp = 0
486
+ return round_down_to_one_decimal(tmp)
487
+
488
+ def coalesce_to_zero(val):
489
+ """
490
+ None이나 NaN, 문자열 오류 등이 들어오면 0.0으로 변환
491
+ """
492
+ if val is None:
493
+ return 0.0
494
+ try:
495
+ return float(val)
496
+ except:
497
+ return 0.0
498
+
499
+ def seconds_to_hms(seconds: float) -> str:
500
+ """초를 HH:MM:SS 형식으로 변환"""
501
+ try:
502
+ seconds = max(0, seconds)
503
+ td = timedelta(seconds=round(seconds))
504
+ return str(td)
505
+ except Exception as e:
506
+ log_debug(f"[seconds_to_hms] 변환 오류: {e}")
507
+ return "00:00:00"
508
+
509
+ def hms_to_seconds(time_str: str) -> float:
510
+ """HH:MM:SS 형식을 초로 변환"""
511
+ try:
512
+ parts = time_str.strip().split(':')
513
+ parts = [int(p) for p in parts]
514
+ while len(parts) < 3:
515
+ parts.insert(0, 0) # 부족한 부분은 0으로 채움
516
+ hours, minutes, seconds = parts
517
+ return hours * 3600 + minutes * 60 + seconds
518
+ except Exception as e:
519
+ log_debug(f"[hms_to_seconds] 변환 오류: {e}")
520
+ return -1 # 오류 시 -1 반환
521
+
522
+ ########################################
523
+ # 4) 업로드된 영상 파일 저장
524
+ ########################################
525
+ def save_uploaded_video(video_input):
526
+ if not video_input:
527
+ log_debug("[save_uploaded_video] video_input is None.")
528
+ return None
529
+
530
+ if isinstance(video_input, str):
531
+ log_debug(f"[save_uploaded_video] video_input is str: {video_input}")
532
+ if os.path.exists(video_input):
533
+ return video_input
534
+ else:
535
+ log_debug("[save_uploaded_video] Path does not exist.")
536
+ return None
537
+
538
+ if isinstance(video_input, dict):
539
+ log_debug(f"[save_uploaded_video] video_input is dict: {list(video_input.keys())}")
540
+ if 'data' in video_input:
541
+ file_data = video_input['data']
542
+ if isinstance(file_data, str) and file_data.startswith("data:"):
543
+ base64_str = file_data.split(';base64,')[-1]
544
+ try:
545
+ video_binary = base64.b64decode(base64_str)
546
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4")
547
+ tmp.write(video_binary)
548
+ tmp.flush()
549
+ tmp.close()
550
+ log_debug(f"[save_uploaded_video] Created temp file: {tmp.name}")
551
+ return tmp.name
552
+ except Exception as e:
553
+ log_debug(f"[save_uploaded_video] base64 디코딩 오류: {e}")
554
+ return None
555
+ else:
556
+ if isinstance(file_data, str) and os.path.exists(file_data):
557
+ log_debug("[save_uploaded_video] data 필드가 실제 경로")
558
+ return file_data
559
+ else:
560
+ log_debug("[save_uploaded_video] data 필드가 예상치 못한 형태.")
561
+ return None
562
+ else:
563
+ log_debug("[save_uploaded_video] dict이지만 'data' 키가 없음.")
564
+ return None
565
+
566
+ log_debug("[save_uploaded_video] Unrecognized type.")
567
+ return None
568
+
569
+ ########################################
570
+ # 5) 영상 길이, 해상도, 스크린샷
571
+ ########################################
572
+ def get_video_duration(video_dict):
573
+ path = save_uploaded_video(video_dict)
574
+ if not path:
575
+ return "00:00:00"
576
+ try:
577
+ clip = VideoFileClip(path)
578
+ dur = clip.duration
579
+ clip.close()
580
+ log_debug(f"[get_video_duration] duration={dur}")
581
+ return seconds_to_hms(dur)
582
+ except Exception as e:
583
+ log_debug(f"[get_video_duration] 오류: {e}\n{traceback.format_exc()}")
584
+ return "00:00:00"
585
+
586
+ def get_resolution(video_dict):
587
+ path = save_uploaded_video(video_dict)
588
+ if not path:
589
+ return "0x0"
590
+ try:
591
+ clip = VideoFileClip(path)
592
+ w, h = clip.size
593
+ clip.close()
594
+ log_debug(f"[get_resolution] w={w}, h={h}")
595
+ return f"{w}x{h}"
596
+ except Exception as e:
597
+ log_debug(f"[get_resolution] 오류: {e}\n{traceback.format_exc()}")
598
+ return "0x0"
599
+
600
+ def get_screenshot_at_time(video_dict, time_in_seconds):
601
+ path = save_uploaded_video(video_dict)
602
+ if not path:
603
+ return None
604
+ try:
605
+ clip = VideoFileClip(path)
606
+ actual_duration = clip.duration
607
+
608
+ # 마지막 프레임 접근 방지
609
+ if time_in_seconds >= actual_duration - END_EPSILON:
610
+ time_in_seconds = safe_end_time(actual_duration)
611
+
612
+ t = max(0, min(time_in_seconds, clip.duration))
613
+ log_debug(f"[get_screenshot_at_time] t={t:.3f} / duration={clip.duration:.3f}")
614
+ frame = clip.get_frame(t)
615
+ clip.close()
616
+ return frame # numpy 배열로 반환
617
+ except Exception as e:
618
+ log_debug(f"[get_screenshot_at_time] 오류: {e}\n{traceback.format_exc()}")
619
+ return None
620
+
621
+ ########################################
622
+ # 6) 업로드 이벤트
623
+ ########################################
624
+ def on_video_upload(video_dict):
625
+ log_debug("[on_video_upload] Called.")
626
+ dur_hms = get_video_duration(video_dict)
627
+ w, h = map(int, get_resolution(video_dict).split('x'))
628
+ resolution_str = f"{w}x{h}"
629
+
630
+ start_t = 0.0
631
+ end_t = safe_end_time(hms_to_seconds(dur_hms))
632
+
633
+ start_img = get_screenshot_at_time(video_dict, start_t)
634
+ end_img = None
635
+ if end_t > 0:
636
+ end_img = get_screenshot_at_time(video_dict, end_t)
637
+
638
+ # 순서대로: 영상 길이, 해상도(문자열), 시작 시간, 끝 시간, 시작 스크린샷, 끝 스크린샷
639
+ return dur_hms, resolution_str, seconds_to_hms(start_t), seconds_to_hms(end_t), start_img, end_img
640
+
641
+ ########################################
642
+ # 7) 스크린샷 갱신
643
+ ########################################
644
+ def update_screenshots(video_dict, start_time_str, end_time_str):
645
+ start_time = hms_to_seconds(start_time_str)
646
+ end_time = hms_to_seconds(end_time_str)
647
+
648
+ if start_time < 0 or end_time < 0:
649
+ return (None, None)
650
+
651
+ log_debug(f"[update_screenshots] start={start_time_str}, end={end_time_str}")
652
+
653
+ end_time = round_down_to_one_decimal(end_time)
654
+ img_start = get_screenshot_at_time(video_dict, start_time)
655
+ img_end = get_screenshot_at_time(video_dict, end_time)
656
+ return (img_start, img_end)
657
+
658
+ ########################################
659
+ # 8) GIF 생성
660
+ ########################################
661
+ def generate_gif(video_dict, start_time_str, end_time_str, fps, resize_factor, speed_factor, duration, resolution_str):
662
+ # "WxH" 형태 해상도 파싱
663
+ parts = resolution_str.split("x")
664
+ if len(parts) == 2:
665
+ try:
666
+ orig_w = float(parts[0])
667
+ orig_h = float(parts[1])
668
+ except:
669
+ orig_w = 0
670
+ orig_h = 0
671
+ else:
672
+ orig_w = 0
673
+ orig_h = 0
674
+
675
+ start_time = hms_to_seconds(start_time_str)
676
+ end_time = hms_to_seconds(end_time_str)
677
+
678
+ if start_time < 0 or end_time < 0:
679
+ return "잘못된 시간 형식입니다. HH:MM:SS 형식으로 입력해주세요."
680
+
681
+ fps = coalesce_to_zero(fps)
682
+ resize_factor = coalesce_to_zero(resize_factor)
683
+ speed_factor = coalesce_to_zero(speed_factor)
684
+
685
+ log_debug("[generate_gif] Called.")
686
+ log_debug(f" start_time={start_time}, end_time={end_time}, fps={fps}, resize_factor={resize_factor}, speed_factor={speed_factor}")
687
+
688
+ path = save_uploaded_video(video_dict)
689
+ if not path:
690
+ err_msg = "[generate_gif] 영상이 업로드되지 않았습니다."
691
+ log_debug(err_msg)
692
+ return err_msg
693
+
694
+ try:
695
+ clip = VideoFileClip(path)
696
+ end_time = round_down_to_one_decimal(end_time)
697
+
698
+ st = max(0, start_time)
699
+ et = max(0, end_time)
700
+ if et > clip.duration:
701
+ et = clip.duration
702
+
703
+ # 마지막 프레임 접근 방지
704
+ if et >= clip.duration - END_EPSILON:
705
+ et = safe_end_time(clip.duration)
706
+
707
+ log_debug(f" subclip range => st={st:.2f}, et={et:.2f}, totalDur={clip.duration:.2f}")
708
+
709
+ if st >= et:
710
+ clip.close()
711
+ err_msg = "시작 시간이 끝 시간보다 같거나 큽니다."
712
+ log_debug(f"[generate_gif] {err_msg}")
713
+ return err_msg
714
+
715
+ sub_clip = clip.subclip(st, et)
716
+
717
+ # 배속 조정
718
+ if speed_factor != 1.0:
719
+ sub_clip = sub_clip.fx(vfx.speedx, speed_factor)
720
+ log_debug(f" speed_factor applied: {speed_factor}x")
721
+
722
+ # 리사이즈
723
+ if resize_factor < 1.0 and orig_w > 0 and orig_h > 0:
724
+ new_w = int(orig_w * resize_factor)
725
+ new_h = int(orig_h * resize_factor)
726
+ log_debug(f" resizing => {new_w}x{new_h}")
727
+ sub_clip = sub_clip.resize((new_w, new_h))
728
+
729
+ # 고유한 파일 이름 생성
730
+ gif_fd, gif_path = tempfile.mkstemp(suffix=".gif")
731
+ os.close(gif_fd) # 파일 디스크립터 닫기
732
+
733
+ log_debug(f" writing GIF to {gif_path}")
734
+ sub_clip.write_gif(gif_path, fps=int(fps), program='ffmpeg') # ffmpeg 사용
735
+
736
+ clip.close()
737
+ sub_clip.close()
738
+
739
+ if os.path.exists(gif_path):
740
+ log_debug(f" GIF 생성 완료! size={os.path.getsize(gif_path)} bytes.")
741
+ return gif_path
742
+ else:
743
+ err_msg = "GIF 생성에 실패했습니다."
744
+ log_debug(f"[generate_gif] {err_msg}")
745
+ return err_msg
746
+
747
+ except Exception as e:
748
+ err_msg = f"[generate_gif] 오류 발생: {e}\n{traceback.format_exc()}"
749
+ log_debug(err_msg)
750
+ return err_msg
751
+
752
+ ########################################
753
+ # 9) GIF 다운로드 파일 이름 변경 함수
754
+ ########################################
755
+ def prepare_download_gif(gif_path, input_video_dict):
756
+ """GIF 파일의 다운로드 이름을 변경하고 경로를 반환"""
757
+ if gif_path is None:
758
+ return None
759
+
760
+ # 한국 시간 타임스탬프 생성 함수
761
+ def get_korean_timestamp():
762
+ korea_time = datetime.utcnow() + timedelta(hours=9)
763
+ return korea_time.strftime('%Y%m%d_%H%M%S')
764
+
765
+ timestamp = get_korean_timestamp()
766
+
767
+ # 입력된 GIF 이름에서 기본 이름 추출
768
+ if input_video_dict and isinstance(input_video_dict, dict) and 'data' in input_video_dict:
769
+ file_data = input_video_dict['data']
770
+ if isinstance(file_data, str) and file_data.startswith("data:"):
771
+ base_name = "GIF" # base64 데이터에서는 원본 파일 이름을 알 수 없으므로 기본 이름 사용
772
+ elif isinstance(file_data, str) and os.path.exists(file_data):
773
+ base_name = os.path.splitext(os.path.basename(file_data))[0]
774
+ else:
775
+ base_name = "GIF"
776
+ else:
777
+ base_name = "GIF"
778
+
779
+ # 새로운 파일 이름 생성
780
+ file_name = f"[끝장AI]끝장GIF_{base_name}_{timestamp}.gif"
781
+
782
+ # 임시 디렉토리에 파일 저장
783
+ temp_file_path = os.path.join(tempfile.gettempdir(), file_name)
784
+
785
+ try:
786
+ # 기존 GIF 파일을 새로운 이름으로 복사
787
+ copyfile(gif_path, temp_file_path)
788
+ except Exception as e:
789
+ log_debug(f"[prepare_download_gif] 파일 복사 오류: {e}")
790
+ return gif_path # 복사에 실패하면 원본 경로 반환
791
+
792
+ return temp_file_path
793
+
794
+ ########################################
795
+ # 10) 콜백 함수
796
+ ########################################
797
+ def on_any_change(video_dict, start_time_str, end_time_str):
798
+ # 스크린샷만 업데이트
799
+ start_img, end_img = update_screenshots(video_dict, start_time_str, end_time_str)
800
+ return (start_img, end_img)
801
+
802
+ def on_generate_click(video_dict, start_time_str, end_time_str, fps, resize_factor, speed_factor, duration, resolution_str):
803
+ """GIF 생성 후:
804
+ - 성공시: (생성된 GIF 경로, 파일 용량 문자열, 파일 다운로드 경로)
805
+ - 실패시: (None, 에러 메시지, None)
806
+ """
807
+ # Convert duration from hms to seconds for internal use if needed
808
+ # duration는 현재 사용되지 않으므로 무시하거나 필요 시 처리할 수 있음
809
+
810
+ result = generate_gif(video_dict, start_time_str, end_time_str, fps, resize_factor, speed_factor, duration, resolution_str)
811
+
812
+ if isinstance(result, str) and os.path.exists(result):
813
+ # GIF 생성 성공
814
+ size_bytes = os.path.getsize(result)
815
+ size_mb = size_bytes / (1024 * 1024)
816
+ file_size_str = f"{size_mb:.2f} MB"
817
+
818
+ # 다운로드 파일 이름 변경
819
+ download_path = prepare_download_gif(result, video_dict)
820
+
821
+ # Gradio가 자동으로 파일을 처리하도록 `download_path`를 반환
822
+ return (result, file_size_str, download_path)
823
+ else:
824
+ # GIF 생성 실패, 에러 메시지 반환
825
+ err_msg = result if isinstance(result, str) else "GIF 생성에 실패했습니다."
826
+ return (None, err_msg, None)
827
+
828
+ ########################################
829
+ # 11) Gradio UI
830
+ ########################################
831
+
832
+ with gr.Blocks(
833
+ theme=gr.themes.Soft(
834
+ primary_hue=gr.themes.Color(
835
+ c50="#FFF7ED", # 가장 밝은 주황
836
+ c100="#FFEDD5",
837
+ c200="#FED7AA",
838
+ c300="#FDBA74",
839
+ c400="#FB923C",
840
+ c500="#F97316", # 기본 주황
841
+ c600="#EA580C",
842
+ c700="#C2410C",
843
+ c800="#9A3412",
844
+ c900="#7C2D12", # 가장 어두운 주황
845
+ c950="#431407",
846
+ ),
847
+ secondary_hue="zinc", # 모던한 느낌의 회색 계열
848
+ neutral_hue="zinc",
849
+ font=("Pretendard", "sans-serif")
850
+ ),
851
+ css=load_css() # 외부 CSS 파일 또는 내장 스타일 로드
852
+ ) as demo:
853
+ # FontAwesome 아이콘 헤더 추가
854
+ gr.HTML("""
855
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
856
+ """)
857
+
858
+ with gr.Row():
859
+ # 왼쪽 컬럼: 영상 업로드 및 정보
860
+ with gr.Column(elem_classes="left-column"):
861
+ # 영상 업로드 섹션
862
+ with gr.Row(elem_classes="card"):
863
+ gr.HTML('<div class="section-title"><i class="fas fa-cloud-upload-alt"></i> 영상 업로드</div>')
864
+ video_input = gr.Video(label="")
865
+
866
+ # 영상 길이 및 해상도 섹션
867
+ with gr.Row(elem_classes="card"):
868
+ gr.HTML('<div class="section-title"><i class="fas fa-info-circle"></i> 영상 정보</div>')
869
+ duration_box = gr.Textbox(label="영상 길이", interactive=False, value="00:00:00")
870
+ resolution_box = gr.Textbox(label="해상도", interactive=False, value="0x0")
871
+
872
+ # 오른쪽 컬럼: 결과 GIF 및 다운로드
873
+ with gr.Column(elem_classes="right-column"):
874
+ # 결과 GIF 섹션
875
+ with gr.Row(elem_classes="card"):
876
+ gr.HTML('<div class="section-title"><i class="fas fa-image"></i> 결과 GIF</div>')
877
+ output_gif = gr.Image(label="")
878
+
879
+ # 파일 용량 및 다운로드 섹션
880
+ with gr.Row(elem_classes="card"):
881
+ gr.HTML('<div class="section-title"><i class="fas fa-download"></i> 다운로드</div>')
882
+ file_size_text = gr.Textbox(label="파일 용량", interactive=False, value="0 MB")
883
+ download_gif_component = gr.File(label="GIF 다운로드")
884
+
885
+ # 추가 설정 섹션
886
+ with gr.Row():
887
+ with gr.Column():
888
+ # 시간 설정 섹션
889
+ with gr.Column(elem_classes="card"):
890
+ gr.HTML('<div class="section-title"><i class="fas fa-clock"></i> 시간 설정</div>')
891
+ with gr.Row():
892
+ start_time_input = gr.Textbox(label="시작 시간 (HH:MM:SS)", value="00:00:00")
893
+ end_time_input = gr.Textbox(label="끝 시간 (HH:MM:SS)", value="00:00:00")
894
+
895
+ # 스크린샷 섹션
896
+ with gr.Row(elem_classes="card"):
897
+ gr.HTML('<div class="section-title"><i class="fas fa-camera"></i> 미리보기</div>')
898
+ start_screenshot = gr.Image(label="시작 지점 캡쳐본")
899
+ end_screenshot = gr.Image(label="끝 지점 캡쳐본")
900
+
901
+ # 설정 슬라이더 섹션
902
+ with gr.Column(elem_classes="card"):
903
+ gr.HTML('<div class="section-title"><i class="fas fa-sliders-h"></i> 변환 설정</div>')
904
+
905
+ # 배속 조절 슬라이더
906
+ speed_slider = gr.Slider(
907
+ label="배속",
908
+ minimum=0.5,
909
+ maximum=2.0,
910
+ step=0.1,
911
+ value=1.0,
912
+ info="0.5x: 절반 속도, 1.0x: 원래 속도, 2.0x: 두 배 속도"
913
+ )
914
+
915
+ # FPS 슬라이더
916
+ fps_slider = gr.Slider(
917
+ label="FPS",
918
+ minimum=1,
919
+ maximum=30,
920
+ step=1,
921
+ value=10,
922
+ info="프레임 수를 조절하여 애니메이션의 부드러움을 변경합니다."
923
+ )
924
+
925
+ # 해상도 배율 슬라이더
926
+ resize_slider = gr.Slider(
927
+ label="해상도 배율",
928
+ minimum=0.1,
929
+ maximum=1.0,
930
+ step=0.05,
931
+ value=1.0,
932
+ info="GIF의 해상도를 조절합니다."
933
+ )
934
+
935
+ # GIF 생성 버튼 섹션
936
+ with gr.Row(elem_classes="card"):
937
+ generate_button = gr.Button("✨ GIF 생성하기", elem_classes="button-primary")
938
+
939
+ # 이벤트 콜백 설정
940
+ # 업로드 시 → 영상 길이, 해상도 업데이트
941
+ video_input.change(
942
+ fn=on_video_upload,
943
+ inputs=[video_input],
944
+ outputs=[
945
+ duration_box, # 영상 길이
946
+ resolution_box, # 해상도("WxH")
947
+ start_time_input,
948
+ end_time_input,
949
+ start_screenshot,
950
+ end_screenshot
951
+ ]
952
+ )
953
+
954
+ # 시작/끝 시간 변경 시 → 스크린샷 업데이트
955
+ for c in [start_time_input, end_time_input]:
956
+ c.change(
957
+ fn=on_any_change,
958
+ inputs=[video_input, start_time_input, end_time_input],
959
+ outputs=[start_screenshot, end_screenshot]
960
+ )
961
+
962
+ # 배속, FPS, 해상도 배율 변경 시 → 스크린샷 업데이트
963
+ for c in [speed_slider, fps_slider, resize_slider]:
964
+ c.change(
965
+ fn=on_any_change,
966
+ inputs=[video_input, start_time_input, end_time_input],
967
+ outputs=[start_screenshot, end_screenshot]
968
+ )
969
+
970
+ # GIF 생성 버튼 클릭 시 → GIF 생성 및 결과 업데이트
971
+ generate_button.click(
972
+ fn=on_generate_click,
973
+ inputs=[
974
+ video_input,
975
+ start_time_input,
976
+ end_time_input,
977
+ fps_slider,
978
+ resize_slider,
979
+ speed_slider, # 배속 슬라이더 추가
980
+ duration_box,
981
+ resolution_box
982
+ ],
983
+ outputs=[
984
+ output_gif, # 생성된 GIF 미리보기
985
+ file_size_text, # 파일 용량 표시
986
+ download_gif_component # 다운로드 링크
987
+ ]
988
+ )
989
+
990
+ demo.launch()