seawolf2357 commited on
Commit
89f0e3f
ยท
verified ยท
1 Parent(s): 4da9bba

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1551 -0
app.py ADDED
@@ -0,0 +1,1551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Video Editor - ์„œ๋ฒ„ ์‚ฌ์ด๋“œ FFmpeg MP4 ๋‚ด๋ณด๋‚ด๊ธฐ
3
+ ๋ชจ๋“  ๊ธฐ๋Šฅ ์™„์ „ ์œ ์ง€
4
+ """
5
+
6
+ import gradio as gr
7
+ import base64
8
+ import os
9
+ import json
10
+ import subprocess
11
+ import tempfile
12
+ import shutil
13
+ import time
14
+
15
+ # ์ž„์‹œ ํŒŒ์ผ ์ €์žฅ์†Œ
16
+ TEMP_DIR = tempfile.mkdtemp()
17
+ uploaded_files = {}
18
+
19
+
20
+ def get_editor_html(media_data="[]"):
21
+ return f'''<!DOCTYPE html>
22
+ <html lang="ko">
23
+ <head>
24
+ <meta charset="UTF-8">
25
+ <style>
26
+ * {{
27
+ margin: 0;
28
+ padding: 0;
29
+ box-sizing: border-box;
30
+ }}
31
+ body {{
32
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
33
+ background: #f5f5f7;
34
+ font-size: 13px;
35
+ }}
36
+ .editor {{
37
+ display: flex;
38
+ flex-direction: column;
39
+ height: 100vh;
40
+ }}
41
+ .toolbar {{
42
+ height: 44px;
43
+ background: #fff;
44
+ border-bottom: 1px solid #ddd;
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: space-between;
48
+ padding: 0 12px;
49
+ }}
50
+ .toolbar-title {{
51
+ font-size: 15px;
52
+ font-weight: 600;
53
+ }}
54
+ .toolbar-actions {{
55
+ display: flex;
56
+ gap: 6px;
57
+ }}
58
+ .btn {{
59
+ padding: 6px 12px;
60
+ border: none;
61
+ border-radius: 6px;
62
+ cursor: pointer;
63
+ font-size: 11px;
64
+ font-weight: 500;
65
+ transition: all 0.2s;
66
+ }}
67
+ .btn-secondary {{
68
+ background: #f0f0f0;
69
+ color: #333;
70
+ }}
71
+ .btn-secondary:hover {{
72
+ background: #e0e0e0;
73
+ }}
74
+ .btn-primary {{
75
+ background: #6366f1;
76
+ color: #fff;
77
+ }}
78
+ .btn-primary:hover {{
79
+ background: #4f46e5;
80
+ }}
81
+ .btn-success {{
82
+ background: #10b981;
83
+ color: #fff;
84
+ }}
85
+ .btn-success:hover {{
86
+ background: #059669;
87
+ }}
88
+ .btn-danger {{
89
+ background: #ef4444;
90
+ color: #fff;
91
+ }}
92
+ .btn-danger:hover {{
93
+ background: #dc2626;
94
+ }}
95
+ .main {{
96
+ display: flex;
97
+ flex: 1;
98
+ overflow: hidden;
99
+ }}
100
+ .library {{
101
+ width: 180px;
102
+ background: #fff;
103
+ border-right: 1px solid #ddd;
104
+ display: flex;
105
+ flex-direction: column;
106
+ }}
107
+ .lib-header {{
108
+ padding: 10px 12px;
109
+ border-bottom: 1px solid #eee;
110
+ font-size: 11px;
111
+ font-weight: 600;
112
+ color: #666;
113
+ text-transform: uppercase;
114
+ letter-spacing: 0.5px;
115
+ }}
116
+ .lib-content {{
117
+ flex: 1;
118
+ overflow-y: auto;
119
+ padding: 8px;
120
+ }}
121
+ .lib-hint {{
122
+ text-align: center;
123
+ padding: 20px;
124
+ color: #999;
125
+ font-size: 11px;
126
+ }}
127
+ .media-grid {{
128
+ display: grid;
129
+ grid-template-columns: repeat(2, 1fr);
130
+ gap: 6px;
131
+ }}
132
+ .media-item {{
133
+ aspect-ratio: 16/9;
134
+ background: #f0f0f0;
135
+ border-radius: 6px;
136
+ overflow: hidden;
137
+ cursor: grab;
138
+ position: relative;
139
+ border: 2px solid transparent;
140
+ transition: all 0.2s;
141
+ }}
142
+ .media-item:hover {{
143
+ border-color: #6366f1;
144
+ transform: scale(1.02);
145
+ }}
146
+ .media-item img {{
147
+ width: 100%;
148
+ height: 100%;
149
+ object-fit: cover;
150
+ }}
151
+ .media-item-icon {{
152
+ width: 100%;
153
+ height: 100%;
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: center;
157
+ font-size: 20px;
158
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
159
+ }}
160
+ .media-item-dur {{
161
+ position: absolute;
162
+ bottom: 3px;
163
+ right: 3px;
164
+ background: rgba(0,0,0,0.75);
165
+ padding: 2px 5px;
166
+ border-radius: 3px;
167
+ font-size: 9px;
168
+ color: #fff;
169
+ font-weight: 500;
170
+ }}
171
+ .media-item-name {{
172
+ position: absolute;
173
+ bottom: 3px;
174
+ left: 3px;
175
+ right: 35px;
176
+ background: rgba(0,0,0,0.75);
177
+ padding: 2px 5px;
178
+ border-radius: 3px;
179
+ font-size: 8px;
180
+ color: #fff;
181
+ white-space: nowrap;
182
+ overflow: hidden;
183
+ text-overflow: ellipsis;
184
+ }}
185
+ .preview-area {{
186
+ flex: 1;
187
+ display: flex;
188
+ flex-direction: column;
189
+ background: #1a1a1a;
190
+ margin: 8px;
191
+ border-radius: 12px;
192
+ overflow: hidden;
193
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3);
194
+ }}
195
+ .preview-box {{
196
+ flex: 1;
197
+ display: flex;
198
+ align-items: center;
199
+ justify-content: center;
200
+ background: #000;
201
+ position: relative;
202
+ }}
203
+ #previewCanvas {{
204
+ max-width: 100%;
205
+ max-height: 100%;
206
+ background: #000;
207
+ }}
208
+ .controls {{
209
+ height: 50px;
210
+ background: linear-gradient(to top, #1a1a1a, #222);
211
+ display: flex;
212
+ align-items: center;
213
+ justify-content: center;
214
+ gap: 8px;
215
+ padding: 0 15px;
216
+ }}
217
+ .ctrl-btn {{
218
+ width: 32px;
219
+ height: 32px;
220
+ border: none;
221
+ border-radius: 50%;
222
+ background: rgba(255,255,255,0.1);
223
+ color: #fff;
224
+ cursor: pointer;
225
+ font-size: 12px;
226
+ transition: all 0.2s;
227
+ }}
228
+ .ctrl-btn:hover {{
229
+ background: rgba(255,255,255,0.2);
230
+ transform: scale(1.1);
231
+ }}
232
+ .ctrl-btn.play {{
233
+ width: 40px;
234
+ height: 40px;
235
+ background: #6366f1;
236
+ font-size: 14px;
237
+ }}
238
+ .ctrl-btn.play:hover {{
239
+ background: #4f46e5;
240
+ }}
241
+ .time-display {{
242
+ font-family: 'SF Mono', Monaco, monospace;
243
+ font-size: 11px;
244
+ color: #aaa;
245
+ min-width: 100px;
246
+ text-align: center;
247
+ background: rgba(0,0,0,0.3);
248
+ padding: 4px 10px;
249
+ border-radius: 4px;
250
+ }}
251
+ .props {{
252
+ width: 160px;
253
+ background: #fff;
254
+ border-left: 1px solid #ddd;
255
+ display: flex;
256
+ flex-direction: column;
257
+ }}
258
+ .props-header {{
259
+ padding: 10px 12px;
260
+ border-bottom: 1px solid #eee;
261
+ font-size: 11px;
262
+ font-weight: 600;
263
+ color: #666;
264
+ text-transform: uppercase;
265
+ letter-spacing: 0.5px;
266
+ }}
267
+ .props-content {{
268
+ flex: 1;
269
+ padding: 12px;
270
+ overflow-y: auto;
271
+ }}
272
+ .no-sel {{
273
+ color: #999;
274
+ text-align: center;
275
+ padding: 20px 10px;
276
+ font-size: 11px;
277
+ }}
278
+ .prop-group {{
279
+ margin-bottom: 14px;
280
+ }}
281
+ .prop-label {{
282
+ font-size: 10px;
283
+ color: #666;
284
+ margin-bottom: 4px;
285
+ font-weight: 500;
286
+ }}
287
+ .prop-input {{
288
+ width: 100%;
289
+ padding: 6px 8px;
290
+ border: 1px solid #ddd;
291
+ border-radius: 5px;
292
+ font-size: 11px;
293
+ transition: border-color 0.2s;
294
+ }}
295
+ .prop-input:focus {{
296
+ outline: none;
297
+ border-color: #6366f1;
298
+ }}
299
+ .timeline {{
300
+ height: 160px;
301
+ background: #fff;
302
+ border-top: 1px solid #ddd;
303
+ display: flex;
304
+ flex-direction: column;
305
+ }}
306
+ .tl-toolbar {{
307
+ height: 32px;
308
+ background: #fafafa;
309
+ border-bottom: 1px solid #eee;
310
+ display: flex;
311
+ align-items: center;
312
+ padding: 0 10px;
313
+ gap: 6px;
314
+ }}
315
+ .tl-toolbar .btn {{
316
+ padding: 4px 8px;
317
+ font-size: 10px;
318
+ }}
319
+ .tl-zoom {{
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 5px;
323
+ margin-left: auto;
324
+ font-size: 10px;
325
+ color: #666;
326
+ }}
327
+ .tl-zoom input {{
328
+ width: 60px;
329
+ cursor: pointer;
330
+ }}
331
+ .tl-container {{
332
+ flex: 1;
333
+ overflow-x: auto;
334
+ overflow-y: hidden;
335
+ position: relative;
336
+ }}
337
+ .tl-ruler {{
338
+ height: 20px;
339
+ background: #fafafa;
340
+ border-bottom: 1px solid #eee;
341
+ position: sticky;
342
+ top: 0;
343
+ z-index: 5;
344
+ }}
345
+ .tl-tracks {{
346
+ position: relative;
347
+ }}
348
+ .tl-track {{
349
+ height: 50px;
350
+ border-bottom: 1px solid #eee;
351
+ display: flex;
352
+ }}
353
+ .tl-track:nth-child(2) {{
354
+ background: linear-gradient(to right, #fffbeb, #fef3c7);
355
+ }}
356
+ .track-label {{
357
+ width: 60px;
358
+ padding: 0 8px;
359
+ font-size: 9px;
360
+ color: #666;
361
+ background: #fafafa;
362
+ display: flex;
363
+ align-items: center;
364
+ border-right: 1px solid #eee;
365
+ font-weight: 500;
366
+ }}
367
+ .track-content {{
368
+ flex: 1;
369
+ position: relative;
370
+ min-width: 800px;
371
+ }}
372
+ .clip {{
373
+ position: absolute;
374
+ height: 40px;
375
+ top: 5px;
376
+ border-radius: 6px;
377
+ cursor: grab;
378
+ display: flex;
379
+ align-items: center;
380
+ overflow: hidden;
381
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
382
+ transition: box-shadow 0.2s;
383
+ }}
384
+ .clip:hover {{
385
+ box-shadow: 0 0 0 2px #6366f1, 0 4px 8px rgba(0,0,0,0.2);
386
+ }}
387
+ .clip.selected {{
388
+ box-shadow: 0 0 0 2px #6366f1, 0 4px 8px rgba(0,0,0,0.2);
389
+ }}
390
+ .clip.video {{
391
+ background: linear-gradient(135deg, #818cf8 0%, #6366f1 100%);
392
+ }}
393
+ .clip.image {{
394
+ background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
395
+ }}
396
+ .clip.audio {{
397
+ background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
398
+ }}
399
+ .clip-thumb {{
400
+ width: 40px;
401
+ height: 100%;
402
+ object-fit: cover;
403
+ border-right: 1px solid rgba(255,255,255,0.2);
404
+ }}
405
+ .clip-info {{
406
+ padding: 0 8px;
407
+ flex: 1;
408
+ overflow: hidden;
409
+ }}
410
+ .clip-name {{
411
+ font-size: 9px;
412
+ color: #fff;
413
+ font-weight: 500;
414
+ overflow: hidden;
415
+ text-overflow: ellipsis;
416
+ white-space: nowrap;
417
+ text-shadow: 0 1px 2px rgba(0,0,0,0.3);
418
+ }}
419
+ .clip-dur {{
420
+ font-size: 8px;
421
+ color: rgba(255,255,255,0.8);
422
+ margin-top: 2px;
423
+ }}
424
+ .clip-handle {{
425
+ position: absolute;
426
+ top: 0;
427
+ bottom: 0;
428
+ width: 10px;
429
+ background: rgba(255,255,255,0.5);
430
+ cursor: ew-resize;
431
+ opacity: 0;
432
+ transition: opacity 0.2s;
433
+ }}
434
+ .clip:hover .clip-handle {{
435
+ opacity: 1;
436
+ }}
437
+ .clip-handle-l {{
438
+ left: 0;
439
+ border-radius: 6px 0 0 6px;
440
+ }}
441
+ .clip-handle-r {{
442
+ right: 0;
443
+ border-radius: 0 6px 6px 0;
444
+ }}
445
+ .playhead {{
446
+ position: absolute;
447
+ top: 0;
448
+ bottom: 0;
449
+ width: 2px;
450
+ background: #ef4444;
451
+ z-index: 10;
452
+ pointer-events: none;
453
+ }}
454
+ .playhead::before {{
455
+ content: "";
456
+ position: absolute;
457
+ top: 0;
458
+ left: -5px;
459
+ border-left: 6px solid transparent;
460
+ border-right: 6px solid transparent;
461
+ border-top: 8px solid #ef4444;
462
+ }}
463
+ .drop-zone {{
464
+ background: rgba(99, 102, 241, 0.1) !important;
465
+ outline: 2px dashed #6366f1 !important;
466
+ }}
467
+ .status {{
468
+ height: 24px;
469
+ background: #f5f5f5;
470
+ border-top: 1px solid #ddd;
471
+ display: flex;
472
+ align-items: center;
473
+ padding: 0 12px;
474
+ font-size: 10px;
475
+ color: #666;
476
+ }}
477
+ .ctx-menu {{
478
+ position: fixed;
479
+ background: #fff;
480
+ border: 1px solid #ddd;
481
+ border-radius: 8px;
482
+ padding: 4px 0;
483
+ min-width: 120px;
484
+ z-index: 1000;
485
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
486
+ display: none;
487
+ }}
488
+ .ctx-item {{
489
+ padding: 8px 14px;
490
+ cursor: pointer;
491
+ font-size: 11px;
492
+ transition: background 0.1s;
493
+ }}
494
+ .ctx-item:hover {{
495
+ background: #f5f5f5;
496
+ }}
497
+ .ctx-item.danger {{
498
+ color: #ef4444;
499
+ }}
500
+ .ctx-item.danger:hover {{
501
+ background: #fef2f2;
502
+ }}
503
+ .hidden-media {{
504
+ position: absolute;
505
+ left: -9999px;
506
+ top: -9999px;
507
+ width: 1px;
508
+ height: 1px;
509
+ opacity: 0;
510
+ pointer-events: none;
511
+ }}
512
+ </style>
513
+ </head>
514
+ <body>
515
+ <div class="editor">
516
+ <div class="toolbar">
517
+ <div class="toolbar-title">๐ŸŽฌ Video Editor</div>
518
+ <div class="toolbar-actions">
519
+ <button class="btn btn-secondary" onclick="undo()">โ†ฉ ์‹คํ–‰์ทจ์†Œ</button>
520
+ </div>
521
+ </div>
522
+
523
+ <div class="main">
524
+ <div class="library">
525
+ <div class="lib-header">๐Ÿ“ ๋ฏธ๋””์–ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ</div>
526
+ <div class="lib-content">
527
+ <div class="lib-hint" id="hint">ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜์„ธ์š”<br><small>์˜์ƒ, ์ด๋ฏธ์ง€, ์˜ค๋””์˜ค</small></div>
528
+ <div class="media-grid" id="mediaGrid"></div>
529
+ </div>
530
+ </div>
531
+
532
+ <div class="preview-area">
533
+ <div class="preview-box">
534
+ <canvas id="previewCanvas" width="640" height="360"></canvas>
535
+ </div>
536
+ <div class="controls">
537
+ <button class="ctrl-btn" onclick="seek(0)" title="์ฒ˜์Œ์œผ๋กœ">โฎ</button>
538
+ <button class="ctrl-btn" onclick="seek(S.time-5)" title="-5์ดˆ">โช</button>
539
+ <button class="ctrl-btn play" onclick="togglePlay()" id="playBtn" title="์žฌ์ƒ/์ผ์‹œ์ •์ง€">โ–ถ</button>
540
+ <button class="ctrl-btn" onclick="seek(S.time+5)" title="+5์ดˆ">โฉ</button>
541
+ <button class="ctrl-btn" onclick="seek(S.dur)" title="๋์œผ๋กœ">โญ</button>
542
+ <div class="time-display">
543
+ <span id="curT">00:00.00</span> / <span id="totT">00:00.00</span>
544
+ </div>
545
+ <button class="ctrl-btn" onclick="toggleMute()" id="muteBtn" title="์Œ์†Œ๊ฑฐ">๐Ÿ”Š</button>
546
+ </div>
547
+ </div>
548
+
549
+ <div class="props">
550
+ <div class="props-header">โš™๏ธ ์†์„ฑ</div>
551
+ <div class="props-content" id="propsBox">
552
+ <div class="no-sel">ํด๋ฆฝ์„ ์„ ํƒํ•˜์„ธ์š”</div>
553
+ </div>
554
+ </div>
555
+ </div>
556
+
557
+ <div class="timeline">
558
+ <div class="tl-toolbar">
559
+ <button class="btn btn-secondary" onclick="splitClip()">โœ‚๏ธ ์ž๋ฅด๊ธฐ</button>
560
+ <button class="btn btn-secondary" onclick="dupeClip()">๐Ÿ“‹ ๋ณต์ œ</button>
561
+ <button class="btn btn-danger" onclick="delClip()">๐Ÿ—‘๏ธ ์‚ญ์ œ</button>
562
+ <div class="tl-zoom">
563
+ ๐Ÿ” <input type="range" min="0.5" max="3" step="0.1" value="1" oninput="setZoom(this.value)">
564
+ </div>
565
+ </div>
566
+ <div class="tl-container" id="tlBox" onclick="tlClick(event)">
567
+ <div class="tl-ruler" id="ruler"></div>
568
+ <div class="tl-tracks">
569
+ <div class="tl-track">
570
+ <div class="track-label">๐ŸŽฌ ์˜์ƒ</div>
571
+ <div class="track-content" id="t0"></div>
572
+ </div>
573
+ <div class="tl-track">
574
+ <div class="track-label">๐ŸŽต ์˜ค๋””์˜ค</div>
575
+ <div class="track-content" id="t1"></div>
576
+ </div>
577
+ </div>
578
+ <div class="playhead" id="playhead" style="left:60px"></div>
579
+ </div>
580
+ </div>
581
+
582
+ <div class="status" id="status">์ค€๋น„๋จ | ๋‹จ์ถ•ํ‚ค: Space(์žฌ์ƒ), Delete(์‚ญ์ œ), โ†โ†’(์ด๋™)</div>
583
+ </div>
584
+
585
+ <div class="ctx-menu" id="ctx">
586
+ <div class="ctx-item" onclick="splitClip()">โœ‚๏ธ ์ž๋ฅด๊ธฐ</div>
587
+ <div class="ctx-item" onclick="dupeClip()">๐Ÿ“‹ ๋ณต์ œ</div>
588
+ <div class="ctx-item danger" onclick="delClip()">๐Ÿ—‘๏ธ ์‚ญ์ œ</div>
589
+ </div>
590
+
591
+ <div id="hiddenMedia" class="hidden-media"></div>
592
+ <textarea id="exportData" style="display:none"></textarea>
593
+
594
+ <script>
595
+ var S = {{
596
+ media: [],
597
+ clips: [],
598
+ sel: null,
599
+ playing: false,
600
+ muted: false,
601
+ time: 0,
602
+ dur: 0,
603
+ zoom: 1,
604
+ pps: 80,
605
+ history: [],
606
+ animId: null,
607
+ els: {{}},
608
+ canvas: null,
609
+ ctx: null
610
+ }};
611
+
612
+ function init() {{
613
+ S.canvas = document.getElementById('previewCanvas');
614
+ S.ctx = S.canvas.getContext('2d');
615
+ drawPlaceholder();
616
+ }}
617
+
618
+ function id() {{
619
+ return Math.random().toString(36).substr(2, 9);
620
+ }}
621
+
622
+ function fmt(t) {{
623
+ if (!t || isNaN(t)) t = 0;
624
+ var m = Math.floor(t / 60);
625
+ var s = Math.floor(t % 60);
626
+ var ms = Math.floor((t % 1) * 100);
627
+ return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0') + '.' + String(ms).padStart(2, '0');
628
+ }}
629
+
630
+ function r(n) {{
631
+ return Math.round(n * 1000) / 1000;
632
+ }}
633
+
634
+ function stat(m) {{
635
+ document.getElementById('status').textContent = m;
636
+ }}
637
+
638
+ function save() {{
639
+ S.history.push(JSON.stringify(S.clips));
640
+ if (S.history.length > 30) S.history.shift();
641
+ }}
642
+
643
+ function drawPlaceholder() {{
644
+ S.ctx.fillStyle = '#000';
645
+ S.ctx.fillRect(0, 0, 640, 360);
646
+ S.ctx.fillStyle = '#444';
647
+ S.ctx.font = '14px sans-serif';
648
+ S.ctx.textAlign = 'center';
649
+ S.ctx.fillText('ํƒ€์ž„๋ผ์ธ์— ๋ฏธ๋””์–ด๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”', 320, 180);
650
+ }}
651
+
652
+ function addMedia(name, type, url, filePath) {{
653
+ var m = {{
654
+ id: id(),
655
+ name: name,
656
+ type: type,
657
+ url: url,
658
+ filePath: filePath || '',
659
+ dur: type === 'image' ? 5 : 0,
660
+ thumb: type === 'image' ? url : null
661
+ }};
662
+ S.media.push(m);
663
+
664
+ var container = document.getElementById('hiddenMedia');
665
+
666
+ if (type === 'video') {{
667
+ var v = document.createElement('video');
668
+ v.src = url;
669
+ v.muted = true;
670
+ v.playsInline = true;
671
+ v.preload = 'auto';
672
+ v.crossOrigin = 'anonymous';
673
+ container.appendChild(v);
674
+ S.els[m.id] = v;
675
+
676
+ v.onloadedmetadata = function() {{
677
+ m.dur = r(v.duration);
678
+ renderLib();
679
+ v.currentTime = 0.5;
680
+ }};
681
+
682
+ v.onseeked = function() {{
683
+ if (!m.thumb) {{
684
+ try {{
685
+ var c = document.createElement('canvas');
686
+ c.width = 160;
687
+ c.height = 90;
688
+ c.getContext('2d').drawImage(v, 0, 0, 160, 90);
689
+ m.thumb = c.toDataURL();
690
+ renderLib();
691
+ }} catch(e) {{}}
692
+ }}
693
+ }};
694
+ }} else if (type === 'audio') {{
695
+ var a = document.createElement('audio');
696
+ a.src = url;
697
+ a.preload = 'auto';
698
+ container.appendChild(a);
699
+ S.els[m.id] = a;
700
+ a.onloadedmetadata = function() {{
701
+ m.dur = r(a.duration);
702
+ renderLib();
703
+ }};
704
+ }} else if (type === 'image') {{
705
+ var img = new Image();
706
+ img.src = url;
707
+ img.crossOrigin = 'anonymous';
708
+ S.els[m.id] = img;
709
+ }}
710
+
711
+ renderLib();
712
+ stat('๋ฏธ๋””์–ด ์ถ”๊ฐ€: ' + name);
713
+ setTimeout(function() {{ addClip(m) }}, 400);
714
+ }}
715
+
716
+ function renderLib() {{
717
+ var g = document.getElementById('mediaGrid');
718
+ var h = document.getElementById('hint');
719
+ h.style.display = S.media.length ? 'none' : 'block';
720
+ g.innerHTML = '';
721
+
722
+ S.media.forEach(function(m) {{
723
+ var d = document.createElement('div');
724
+ d.className = 'media-item';
725
+ d.draggable = true;
726
+ d.ondblclick = function() {{ addClip(m) }};
727
+ d.ondragstart = function(e) {{ e.dataTransfer.setData('mid', m.id) }};
728
+
729
+ var th = m.thumb
730
+ ? '<img src="' + m.thumb + '">'
731
+ : '<div class="media-item-icon">' + (m.type === 'video' ? '๐ŸŽฌ' : m.type === 'audio' ? '๐ŸŽต' : '๐Ÿ–ผ') + '</div>';
732
+
733
+ d.innerHTML = th +
734
+ '<div class="media-item-name">' + m.name + '</div>' +
735
+ (m.dur ? '<div class="media-item-dur">' + fmt(m.dur) + '</div>' : '');
736
+ g.appendChild(d);
737
+ }});
738
+ }}
739
+
740
+ function trackEnd(tr) {{
741
+ var end = 0;
742
+ for (var i = 0; i < S.clips.length; i++) {{
743
+ var c = S.clips[i];
744
+ if (c.track === tr) {{
745
+ var e = r(c.start + (c.te - c.ts));
746
+ if (e > end) end = e;
747
+ }}
748
+ }}
749
+ return end;
750
+ }}
751
+
752
+ function addClip(m, at) {{
753
+ save();
754
+ var tr = m.type === 'audio' ? 1 : 0;
755
+ var st = at !== undefined ? r(at) : trackEnd(tr);
756
+
757
+ S.clips.push({{
758
+ id: id(),
759
+ mid: m.id,
760
+ name: m.name,
761
+ type: m.type,
762
+ track: tr,
763
+ start: st,
764
+ dur: m.dur,
765
+ ts: 0,
766
+ te: m.dur,
767
+ vol: 1
768
+ }});
769
+
770
+ renderTL();
771
+ updateDur();
772
+ stat('ํด๋ฆฝ ์ถ”๊ฐ€: ' + m.name);
773
+ drawFrame();
774
+ updateExportData();
775
+ }}
776
+
777
+ function renderTL() {{
778
+ ['t0', 't1'].forEach(function(tid) {{
779
+ document.getElementById(tid).innerHTML = '';
780
+ }});
781
+
782
+ S.clips.forEach(function(c) {{
783
+ var tr = document.getElementById('t' + c.track);
784
+ var el = document.createElement('div');
785
+ el.className = 'clip ' + c.type + (S.sel === c.id ? ' selected' : '');
786
+
787
+ var len = r(c.te - c.ts);
788
+ el.style.left = r(c.start * S.pps * S.zoom) + 'px';
789
+ el.style.width = Math.max(30, r(len * S.pps * S.zoom)) + 'px';
790
+ el.draggable = true;
791
+
792
+ el.onclick = function(e) {{
793
+ e.stopPropagation();
794
+ selClip(c.id);
795
+ }};
796
+
797
+ el.oncontextmenu = function(e) {{
798
+ e.preventDefault();
799
+ selClip(c.id);
800
+ showCtx(e.clientX, e.clientY);
801
+ }};
802
+
803
+ el.ondragstart = function(e) {{
804
+ e.dataTransfer.setData('cid', c.id);
805
+ e.dataTransfer.setData('ox', e.offsetX);
806
+ }};
807
+
808
+ var m = S.media.find(function(x) {{ return x.id === c.mid }});
809
+ var th = m && m.thumb ? '<img class="clip-thumb" src="' + m.thumb + '">' : '';
810
+
811
+ el.innerHTML = th +
812
+ '<div class="clip-info">' +
813
+ '<div class="clip-name">' + c.name + '</div>' +
814
+ '<div class="clip-dur">' + fmt(len) + '</div>' +
815
+ '</div>' +
816
+ '<div class="clip-handle clip-handle-l"></div>' +
817
+ '<div class="clip-handle clip-handle-r"></div>';
818
+
819
+ el.querySelector('.clip-handle-l').onmousedown = function(e) {{
820
+ e.stopPropagation();
821
+ startTrim(c.id, 'l', e);
822
+ }};
823
+ el.querySelector('.clip-handle-r').onmousedown = function(e) {{
824
+ e.stopPropagation();
825
+ startTrim(c.id, 'r', e);
826
+ }};
827
+
828
+ tr.appendChild(el);
829
+ }});
830
+
831
+ renderRuler();
832
+ setupDrop();
833
+ }}
834
+
835
+ function renderRuler() {{
836
+ var ru = document.getElementById('ruler');
837
+ var w = Math.max(r(S.dur * S.pps * S.zoom) + 200, 800);
838
+ ru.style.width = w + 'px';
839
+
840
+ var h = '<svg width="100%" height="20" style="position:absolute;left:60px">';
841
+ var step = S.zoom < 0.7 ? 5 : S.zoom < 1.5 ? 2 : 1;
842
+
843
+ for (var i = 0; i <= S.dur + 10; i += step) {{
844
+ var x = r(i * S.pps * S.zoom);
845
+ h += '<line x1="' + x + '" y1="14" x2="' + x + '" y2="20" stroke="#ccc"/>';
846
+ h += '<text x="' + x + '" y="11" fill="#999" font-size="9" text-anchor="middle">' + i + 's</text>';
847
+ }}
848
+
849
+ ru.innerHTML = h + '</svg>';
850
+ }}
851
+
852
+ function setupDrop() {{
853
+ ['t0', 't1'].forEach(function(tid, idx) {{
854
+ var tr = document.getElementById(tid);
855
+
856
+ tr.ondragover = function(e) {{
857
+ e.preventDefault();
858
+ tr.classList.add('drop-zone');
859
+ }};
860
+
861
+ tr.ondragleave = function() {{
862
+ tr.classList.remove('drop-zone');
863
+ }};
864
+
865
+ tr.ondrop = function(e) {{
866
+ e.preventDefault();
867
+ tr.classList.remove('drop-zone');
868
+
869
+ var rect = tr.getBoundingClientRect();
870
+ var t = r(Math.max(0, (e.clientX - rect.left) / (S.pps * S.zoom)));
871
+ var mid = e.dataTransfer.getData('mid');
872
+ var cid = e.dataTransfer.getData('cid');
873
+ var ox = parseFloat(e.dataTransfer.getData('ox') || 0);
874
+
875
+ if (mid) {{
876
+ var m = S.media.find(function(x) {{ return x.id === mid }});
877
+ if (m) addClip(m, t);
878
+ }} else if (cid) {{
879
+ save();
880
+ var c = S.clips.find(function(x) {{ return x.id === cid }});
881
+ if (c) {{
882
+ c.start = r(Math.max(0, t - ox / (S.pps * S.zoom)));
883
+ c.track = c.type === 'audio' ? 1 : idx;
884
+ renderTL();
885
+ updateDur();
886
+ drawFrame();
887
+ updateExportData();
888
+ }}
889
+ }}
890
+ }};
891
+ }});
892
+ }}
893
+
894
+ var trimData = null;
895
+
896
+ function startTrim(cid, side, e) {{
897
+ e.preventDefault();
898
+ var c = S.clips.find(function(x) {{ return x.id === cid }});
899
+ if (!c) return;
900
+
901
+ save();
902
+ trimData = {{
903
+ cid: cid,
904
+ side: side,
905
+ sx: e.clientX,
906
+ ots: c.ts,
907
+ ote: c.te,
908
+ ost: c.start
909
+ }};
910
+
911
+ document.addEventListener('mousemove', doTrim);
912
+ document.addEventListener('mouseup', endTrim);
913
+ }}
914
+
915
+ function doTrim(e) {{
916
+ if (!trimData) return;
917
+
918
+ var c = S.clips.find(function(x) {{ return x.id === trimData.cid }});
919
+ if (!c) return;
920
+
921
+ var dx = e.clientX - trimData.sx;
922
+ var dt = r(dx / (S.pps * S.zoom));
923
+
924
+ if (trimData.side === 'l') {{
925
+ var newTs = Math.max(0, Math.min(c.te - 0.1, trimData.ots + dt));
926
+ c.ts = r(newTs);
927
+ c.start = r(trimData.ost + (newTs - trimData.ots));
928
+ }} else {{
929
+ c.te = r(Math.max(c.ts + 0.1, Math.min(c.dur, trimData.ote + dt)));
930
+ }}
931
+
932
+ renderTL();
933
+ updateDur();
934
+ }}
935
+
936
+ function endTrim() {{
937
+ trimData = null;
938
+ document.removeEventListener('mousemove', doTrim);
939
+ document.removeEventListener('mouseup', endTrim);
940
+ updateExportData();
941
+ }}
942
+
943
+ function selClip(cid) {{
944
+ S.sel = cid;
945
+ renderTL();
946
+ renderProps();
947
+ }}
948
+
949
+ function renderProps() {{
950
+ var box = document.getElementById('propsBox');
951
+ var c = S.clips.find(function(x) {{ return x.id === S.sel }});
952
+
953
+ if (!c) {{
954
+ box.innerHTML = '<div class="no-sel">ํด๋ฆฝ์„ ์„ ํƒํ•˜์„ธ์š”</div>';
955
+ return;
956
+ }}
957
+
958
+ var len = r(c.te - c.ts);
959
+ box.innerHTML =
960
+ '<div class="prop-group">' +
961
+ '<div class="prop-label">์ด๋ฆ„</div>' +
962
+ '<input class="prop-input" value="' + c.name + '" onchange="setProp(\'name\', this.value)">' +
963
+ '</div>' +
964
+ '<div class="prop-group">' +
965
+ '<div class="prop-label">์‹œ์ž‘ ์œ„์น˜ (์ดˆ)</div>' +
966
+ '<input class="prop-input" type="number" step="0.1" value="' + c.start + '" onchange="setProp(\'start\', parseFloat(this.value))">' +
967
+ '</div>' +
968
+ '<div class="prop-group">' +
969
+ '<div class="prop-label">๊ธธ์ด</div>' +
970
+ '<div style="font-size:12px;font-weight:500;color:#333">' + fmt(len) + '</div>' +
971
+ '</div>' +
972
+ (c.type !== 'image' ?
973
+ '<div class="prop-group">' +
974
+ '<div class="prop-label">๋ณผ๋ฅจ ' + Math.round(c.vol * 100) + '%</div>' +
975
+ '<input class="prop-input" type="range" min="0" max="1" step="0.05" value="' + c.vol + '" oninput="setProp(\'vol\', parseFloat(this.value))">' +
976
+ '</div>' : '');
977
+ }}
978
+
979
+ function setProp(p, v) {{
980
+ save();
981
+ var c = S.clips.find(function(x) {{ return x.id === S.sel }});
982
+ if (c) {{
983
+ c[p] = p === 'start' ? r(v) : v;
984
+ renderTL();
985
+ updateDur();
986
+ renderProps();
987
+ drawFrame();
988
+ updateExportData();
989
+ }}
990
+ }}
991
+
992
+ function splitClip() {{
993
+ if (!S.sel) {{
994
+ alert('ํด๋ฆฝ์„ ์„ ํƒํ•˜์„ธ์š”');
995
+ return;
996
+ }}
997
+
998
+ var c = S.clips.find(function(x) {{ return x.id === S.sel }});
999
+ if (!c) return;
1000
+
1001
+ var cEnd = r(c.start + c.te - c.ts);
1002
+ if (S.time <= c.start || S.time >= cEnd) {{
1003
+ alert('ํ”Œ๋ ˆ์ดํ—ค๋“œ๊ฐ€ ํด๋ฆฝ ์•ˆ์— ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค');
1004
+ return;
1005
+ }}
1006
+
1007
+ save();
1008
+ var splitAt = r(S.time - c.start);
1009
+ var c2 = JSON.parse(JSON.stringify(c));
1010
+ c2.id = id();
1011
+ c2.start = r(S.time);
1012
+ c2.ts = r(c.ts + splitAt);
1013
+ c.te = r(c.ts + splitAt);
1014
+
1015
+ S.clips.push(c2);
1016
+ renderTL();
1017
+ updateDur();
1018
+ hideCtx();
1019
+ stat('ํด๋ฆฝ ๋ถ„ํ• ๋จ');
1020
+ updateExportData();
1021
+ }}
1022
+
1023
+ function dupeClip() {{
1024
+ if (!S.sel) return;
1025
+
1026
+ var c = S.clips.find(function(x) {{ return x.id === S.sel }});
1027
+ if (!c) return;
1028
+
1029
+ save();
1030
+ var len = r(c.te - c.ts);
1031
+ var nc = JSON.parse(JSON.stringify(c));
1032
+ nc.id = id();
1033
+ nc.start = r(c.start + len);
1034
+
1035
+ S.clips.push(nc);
1036
+ renderTL();
1037
+ updateDur();
1038
+ hideCtx();
1039
+ stat('ํด๋ฆฝ ๋ณต์ œ๋จ');
1040
+ updateExportData();
1041
+ }}
1042
+
1043
+ function delClip() {{
1044
+ if (!S.sel) return;
1045
+
1046
+ save();
1047
+ S.clips = S.clips.filter(function(x) {{ return x.id !== S.sel }});
1048
+ S.sel = null;
1049
+ renderTL();
1050
+ renderProps();
1051
+ updateDur();
1052
+ hideCtx();
1053
+ stat('ํด๋ฆฝ ์‚ญ์ œ๋จ');
1054
+ drawFrame();
1055
+ updateExportData();
1056
+ }}
1057
+
1058
+ function undo() {{
1059
+ if (S.history.length) {{
1060
+ S.clips = JSON.parse(S.history.pop());
1061
+ renderTL();
1062
+ updateDur();
1063
+ stat('์‹คํ–‰์ทจ์†Œ');
1064
+ drawFrame();
1065
+ updateExportData();
1066
+ }}
1067
+ }}
1068
+
1069
+ function updateDur() {{
1070
+ var mx = 0;
1071
+ for (var i = 0; i < S.clips.length; i++) {{
1072
+ var c = S.clips[i];
1073
+ var e = r(c.start + c.te - c.ts);
1074
+ if (e > mx) mx = e;
1075
+ }}
1076
+ S.dur = mx;
1077
+ document.getElementById('totT').textContent = fmt(mx);
1078
+ }}
1079
+
1080
+ function togglePlay() {{
1081
+ S.playing = !S.playing;
1082
+ document.getElementById('playBtn').textContent = S.playing ? 'โธ' : 'โ–ถ';
1083
+ if (S.playing) play(); else stop();
1084
+ }}
1085
+
1086
+ function play() {{
1087
+ var last = performance.now();
1088
+
1089
+ function loop(now) {{
1090
+ if (!S.playing) return;
1091
+
1092
+ var dt = (now - last) / 1000;
1093
+ last = now;
1094
+ S.time = S.time + dt;
1095
+
1096
+ if (S.time >= S.dur) {{
1097
+ S.time = 0;
1098
+ if (S.dur === 0) {{
1099
+ S.playing = false;
1100
+ document.getElementById('playBtn').textContent = 'โ–ถ';
1101
+ return;
1102
+ }}
1103
+ }}
1104
+
1105
+ updateHead();
1106
+ drawFrame();
1107
+ S.animId = requestAnimationFrame(loop);
1108
+ }}
1109
+
1110
+ S.animId = requestAnimationFrame(loop);
1111
+ }}
1112
+
1113
+ function stop() {{
1114
+ if (S.animId) {{
1115
+ cancelAnimationFrame(S.animId);
1116
+ S.animId = null;
1117
+ }}
1118
+
1119
+ Object.keys(S.els).forEach(function(k) {{
1120
+ var el = S.els[k];
1121
+ if (el && el.pause) el.pause();
1122
+ }});
1123
+ }}
1124
+
1125
+ function seek(t) {{
1126
+ S.time = Math.max(0, Math.min(S.dur || 0, t));
1127
+ updateHead();
1128
+ drawFrame();
1129
+ }}
1130
+
1131
+ function updateHead() {{
1132
+ document.getElementById('playhead').style.left = (60 + S.time * S.pps * S.zoom) + 'px';
1133
+ document.getElementById('curT').textContent = fmt(S.time);
1134
+ }}
1135
+
1136
+ function getClipAt(t, type) {{
1137
+ var sorted = S.clips.filter(function(c) {{
1138
+ if (type === 'visual') return c.type === 'video' || c.type === 'image';
1139
+ if (type === 'audio') return c.type === 'audio';
1140
+ return true;
1141
+ }}).sort(function(a, b) {{ return a.start - b.start }});
1142
+
1143
+ for (var i = 0; i < sorted.length; i++) {{
1144
+ var c = sorted[i];
1145
+ var cEnd = c.start + (c.te - c.ts);
1146
+ if (t >= c.start && t < cEnd) return c;
1147
+ }}
1148
+ return null;
1149
+ }}
1150
+
1151
+ function drawFrame() {{
1152
+ var t = S.time;
1153
+ var vc = getClipAt(t, 'visual');
1154
+
1155
+ S.ctx.fillStyle = '#000';
1156
+ S.ctx.fillRect(0, 0, 640, 360);
1157
+
1158
+ if (vc) {{
1159
+ var el = S.els[vc.mid];
1160
+ if (el) {{
1161
+ if (vc.type === 'video') {{
1162
+ var clipT = t - vc.start + vc.ts;
1163
+ if (Math.abs(el.currentTime - clipT) > 0.05) {{
1164
+ el.currentTime = clipT;
1165
+ }}
1166
+ if (S.playing && el.paused) el.play().catch(function(){{}});
1167
+ if (!S.playing && !el.paused) el.pause();
1168
+ el.volume = S.muted ? 0 : vc.vol;
1169
+ }}
1170
+
1171
+ try {{
1172
+ var sw = el.videoWidth || el.naturalWidth || el.width || 640;
1173
+ var sh = el.videoHeight || el.naturalHeight || el.height || 360;
1174
+ var scale = Math.min(640 / sw, 360 / sh);
1175
+ var dw = sw * scale, dh = sh * scale;
1176
+ var dx = (640 - dw) / 2, dy = (360 - dh) / 2;
1177
+ S.ctx.drawImage(el, dx, dy, dw, dh);
1178
+ }} catch(e) {{}}
1179
+ }}
1180
+ }} else if (S.clips.length === 0) {{
1181
+ S.ctx.fillStyle = '#444';
1182
+ S.ctx.font = '14px sans-serif';
1183
+ S.ctx.textAlign = 'center';
1184
+ S.ctx.fillText('ํƒ€์ž„๋ผ์ธ์— ๋ฏธ๋””์–ด๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”', 320, 180);
1185
+ }}
1186
+
1187
+ // ์˜ค๋””์˜ค ์ฒ˜๋ฆฌ
1188
+ var audioClips = S.clips.filter(function(c) {{
1189
+ if (c.type !== 'audio') return false;
1190
+ var cEnd = c.start + (c.te - c.ts);
1191
+ return t >= c.start && t < cEnd;
1192
+ }});
1193
+
1194
+ audioClips.forEach(function(ac) {{
1195
+ var el = S.els[ac.mid];
1196
+ if (el) {{
1197
+ var clipT = t - ac.start + ac.ts;
1198
+ if (Math.abs(el.currentTime - clipT) > 0.1) el.currentTime = clipT;
1199
+ el.volume = S.muted ? 0 : ac.vol;
1200
+ if (S.playing && el.paused) el.play().catch(function(){{}});
1201
+ if (!S.playing && !el.paused) el.pause();
1202
+ }}
1203
+ }});
1204
+
1205
+ // ๋ฒ”์œ„ ๋ฐ– ์˜ค๋””์˜ค ์ •์ง€
1206
+ S.clips.forEach(function(c) {{
1207
+ if (c.type !== 'audio') return;
1208
+ var cEnd = c.start + (c.te - c.ts);
1209
+ if (t < c.start || t >= cEnd) {{
1210
+ var el = S.els[c.mid];
1211
+ if (el && !el.paused) el.pause();
1212
+ }}
1213
+ }});
1214
+ }}
1215
+
1216
+ function toggleMute() {{
1217
+ S.muted = !S.muted;
1218
+ document.getElementById('muteBtn').textContent = S.muted ? '๐Ÿ”‡' : '๐Ÿ”Š';
1219
+ }}
1220
+
1221
+ function setZoom(v) {{
1222
+ S.zoom = parseFloat(v);
1223
+ renderTL();
1224
+ updateHead();
1225
+ }}
1226
+
1227
+ function tlClick(e) {{
1228
+ if (e.target.closest('.clip')) return;
1229
+ var rect = document.getElementById('tlBox').getBoundingClientRect();
1230
+ var scrollL = document.getElementById('tlBox').scrollLeft;
1231
+ S.time = Math.max(0, Math.min(S.dur || 0, (e.clientX - rect.left - 60 + scrollL) / (S.pps * S.zoom)));
1232
+ updateHead();
1233
+ drawFrame();
1234
+ }}
1235
+
1236
+ function showCtx(x, y) {{
1237
+ var m = document.getElementById('ctx');
1238
+ m.style.display = 'block';
1239
+ m.style.left = x + 'px';
1240
+ m.style.top = y + 'px';
1241
+ }}
1242
+
1243
+ function hideCtx() {{
1244
+ document.getElementById('ctx').style.display = 'none';
1245
+ }}
1246
+
1247
+ document.addEventListener('click', function(e) {{
1248
+ if (!e.target.closest('.ctx-menu')) hideCtx();
1249
+ }});
1250
+
1251
+ function updateExportData() {{
1252
+ var data = {{
1253
+ clips: S.clips.map(function(c) {{
1254
+ var m = S.media.find(function(x) {{ return x.id === c.mid }});
1255
+ return {{
1256
+ filePath: m ? m.filePath : '',
1257
+ type: c.type,
1258
+ start: c.start,
1259
+ ts: c.ts,
1260
+ te: c.te,
1261
+ vol: c.vol
1262
+ }};
1263
+ }}),
1264
+ duration: S.dur
1265
+ }};
1266
+ document.getElementById('exportData').value = JSON.stringify(data);
1267
+ }}
1268
+
1269
+ // ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค
1270
+ document.addEventListener('keydown', function(e) {{
1271
+ if (e.target.tagName === 'INPUT') return;
1272
+
1273
+ if (e.code === 'Space') {{
1274
+ e.preventDefault();
1275
+ togglePlay();
1276
+ }} else if (e.code === 'Delete' || e.code === 'Backspace') {{
1277
+ e.preventDefault();
1278
+ delClip();
1279
+ }} else if (e.code === 'ArrowLeft') {{
1280
+ seek(S.time - 0.1);
1281
+ }} else if (e.code === 'ArrowRight') {{
1282
+ seek(S.time + 0.1);
1283
+ }}
1284
+ }});
1285
+
1286
+ // ์ดˆ๊ธฐํ™”
1287
+ init();
1288
+ renderTL();
1289
+ stat('์ค€๋น„๋จ | ๋‹จ์ถ•ํ‚ค: Space(์žฌ์ƒ), Delete(์‚ญ์ œ), โ†โ†’(์ด๋™)');
1290
+
1291
+ var initData = {media_data};
1292
+ if (initData && initData.length) {{
1293
+ initData.forEach(function(m) {{
1294
+ addMedia(m.name, m.type, m.dataUrl, m.filePath);
1295
+ }});
1296
+ }}
1297
+ </script>
1298
+ </body>
1299
+ </html>'''
1300
+
1301
+
1302
+ def save_uploaded_file(file_obj):
1303
+ """์—…๋กœ๋“œ๋œ ํŒŒ์ผ์„ ์ž„์‹œ ๋””๋ ‰ํ† ๋ฆฌ์— ์ €์žฅ"""
1304
+ if not file_obj:
1305
+ return None
1306
+
1307
+ src_path = file_obj.name if hasattr(file_obj, 'name') else file_obj
1308
+ name = os.path.basename(src_path)
1309
+ dst_path = os.path.join(TEMP_DIR, f"{int(time.time()*1000)}_{name}")
1310
+ shutil.copy(src_path, dst_path)
1311
+ uploaded_files[name] = dst_path
1312
+ return dst_path
1313
+
1314
+
1315
+ def process_files(files):
1316
+ """ํŒŒ์ผ ์ฒ˜๋ฆฌ ๋ฐ ๋ฏธ๋””์–ด ๋ฆฌ์ŠคํŠธ ์ƒ์„ฑ"""
1317
+ if not files:
1318
+ return []
1319
+
1320
+ results = []
1321
+ file_list = files if isinstance(files, list) else [files]
1322
+
1323
+ for f in file_list:
1324
+ if not f:
1325
+ continue
1326
+
1327
+ saved_path = save_uploaded_file(f)
1328
+ if not saved_path:
1329
+ continue
1330
+
1331
+ name = os.path.basename(saved_path)
1332
+ ext = name.lower().split('.')[-1]
1333
+
1334
+ if ext in ['mp4', 'webm', 'mov', 'avi', 'mkv']:
1335
+ t, m = 'video', f'video/{ext}'
1336
+ elif ext in ['jpg', 'jpeg', 'png', 'gif', 'webp']:
1337
+ t, m = 'image', f'image/{ext}'
1338
+ elif ext in ['mp3', 'wav', 'ogg', 'm4a', 'aac']:
1339
+ t, m = 'audio', f'audio/{ext}'
1340
+ else:
1341
+ continue
1342
+
1343
+ with open(saved_path, 'rb') as fp:
1344
+ data = base64.b64encode(fp.read()).decode()
1345
+
1346
+ results.append({
1347
+ 'name': name,
1348
+ 'type': t,
1349
+ 'dataUrl': f'data:{m};base64,{data}',
1350
+ 'filePath': saved_path
1351
+ })
1352
+
1353
+ return results
1354
+
1355
+
1356
+ def make_editor(media_list):
1357
+ """์—๋””ํ„ฐ HTML ์ƒ์„ฑ"""
1358
+ j = json.dumps(media_list, ensure_ascii=False)
1359
+ h = get_editor_html(j).replace("'", "&#39;")
1360
+ return f"<iframe srcdoc='{h}' style='width:100%;height:700px;border:none;border-radius:10px'></iframe>"
1361
+
1362
+
1363
+ def export_mp4(export_json):
1364
+ """FFmpeg๋กœ MP4 ๋‚ด๋ณด๋‚ด๊ธฐ"""
1365
+ if not export_json or export_json.strip() == "" or not export_json.startswith('{'):
1366
+ return None
1367
+
1368
+ try:
1369
+ data = json.loads(export_json)
1370
+ clips = data.get('clips', [])
1371
+ duration = data.get('duration', 0)
1372
+
1373
+ if not clips or duration <= 0:
1374
+ return None
1375
+
1376
+ video_clips = [c for c in clips if c['type'] in ['video', 'image'] and c.get('filePath')]
1377
+
1378
+ if not video_clips:
1379
+ return None
1380
+
1381
+ output_path = os.path.join(TEMP_DIR, f'output_{int(time.time())}.mp4')
1382
+
1383
+ # ๋‹จ์ผ ํด๋ฆฝ
1384
+ if len(video_clips) == 1:
1385
+ clip = video_clips[0]
1386
+ input_file = clip['filePath']
1387
+ clip_duration = clip['te'] - clip['ts']
1388
+
1389
+ if clip['type'] == 'image':
1390
+ cmd = [
1391
+ 'ffmpeg', '-y',
1392
+ '-loop', '1',
1393
+ '-i', input_file,
1394
+ '-c:v', 'libx264',
1395
+ '-t', str(clip_duration),
1396
+ '-pix_fmt', 'yuv420p',
1397
+ '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2',
1398
+ output_path
1399
+ ]
1400
+ else:
1401
+ cmd = [
1402
+ 'ffmpeg', '-y',
1403
+ '-i', input_file,
1404
+ '-ss', str(clip['ts']),
1405
+ '-t', str(clip_duration),
1406
+ '-c:v', 'libx264',
1407
+ '-preset', 'fast',
1408
+ '-crf', '23',
1409
+ '-c:a', 'aac',
1410
+ '-b:a', '128k',
1411
+ '-movflags', '+faststart',
1412
+ output_path
1413
+ ]
1414
+
1415
+ subprocess.run(cmd, capture_output=True)
1416
+
1417
+ else:
1418
+ # ์—ฌ๋Ÿฌ ํด๋ฆฝ - concat
1419
+ temp_files = []
1420
+ concat_file = os.path.join(TEMP_DIR, 'concat.txt')
1421
+
1422
+ for i, clip in enumerate(sorted(video_clips, key=lambda x: x['start'])):
1423
+ temp_out = os.path.join(TEMP_DIR, f'temp_{i}.mp4')
1424
+ input_file = clip['filePath']
1425
+ clip_duration = clip['te'] - clip['ts']
1426
+
1427
+ if clip['type'] == 'image':
1428
+ cmd = [
1429
+ 'ffmpeg', '-y',
1430
+ '-loop', '1',
1431
+ '-i', input_file,
1432
+ '-c:v', 'libx264',
1433
+ '-t', str(clip_duration),
1434
+ '-pix_fmt', 'yuv420p',
1435
+ '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2',
1436
+ '-r', '30',
1437
+ temp_out
1438
+ ]
1439
+ else:
1440
+ cmd = [
1441
+ 'ffmpeg', '-y',
1442
+ '-i', input_file,
1443
+ '-ss', str(clip['ts']),
1444
+ '-t', str(clip_duration),
1445
+ '-c:v', 'libx264',
1446
+ '-preset', 'fast',
1447
+ '-crf', '23',
1448
+ '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2',
1449
+ '-r', '30',
1450
+ '-c:a', 'aac',
1451
+ '-b:a', '128k',
1452
+ '-ar', '44100',
1453
+ temp_out
1454
+ ]
1455
+
1456
+ subprocess.run(cmd, capture_output=True)
1457
+ if os.path.exists(temp_out):
1458
+ temp_files.append(temp_out)
1459
+
1460
+ with open(concat_file, 'w') as f:
1461
+ for tf in temp_files:
1462
+ f.write(f"file '{tf}'\n")
1463
+
1464
+ cmd = [
1465
+ 'ffmpeg', '-y',
1466
+ '-f', 'concat',
1467
+ '-safe', '0',
1468
+ '-i', concat_file,
1469
+ '-c:v', 'libx264',
1470
+ '-preset', 'fast',
1471
+ '-crf', '23',
1472
+ '-c:a', 'aac',
1473
+ '-movflags', '+faststart',
1474
+ output_path
1475
+ ]
1476
+ subprocess.run(cmd, capture_output=True)
1477
+
1478
+ for tf in temp_files:
1479
+ try:
1480
+ os.remove(tf)
1481
+ except:
1482
+ pass
1483
+ try:
1484
+ os.remove(concat_file)
1485
+ except:
1486
+ pass
1487
+
1488
+ if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
1489
+ return output_path
1490
+
1491
+ return None
1492
+
1493
+ except Exception as e:
1494
+ print(f"Export error: {e}")
1495
+ return None
1496
+
1497
+
1498
+ # Gradio UI
1499
+ with gr.Blocks(title="Video Editor") as demo:
1500
+ gr.Markdown("## ๐ŸŽฌ Video Editor")
1501
+
1502
+ with gr.Row():
1503
+ file_input = gr.File(
1504
+ label="๐Ÿ“ ํŒŒ์ผ ์—…๋กœ๋“œ (์˜์ƒ/์ด๋ฏธ์ง€/์˜ค๋””์˜ค)",
1505
+ file_count="multiple",
1506
+ file_types=["video", "image", "audio"]
1507
+ )
1508
+ export_btn = gr.Button("๐Ÿ“ฅ MP4 ๋‚ด๋ณด๋‚ด๊ธฐ", variant="primary", scale=0)
1509
+
1510
+ editor = gr.HTML(value=make_editor([]))
1511
+
1512
+ export_data = gr.Textbox(visible=False, elem_id="export_data_input")
1513
+ mp4_output = gr.File(label="๐Ÿ“ฅ MP4 ๋‹ค์šด๋กœ๋“œ")
1514
+
1515
+ file_input.change(
1516
+ fn=lambda x: make_editor(process_files(x)),
1517
+ inputs=[file_input],
1518
+ outputs=[editor]
1519
+ )
1520
+
1521
+ export_btn.click(
1522
+ fn=None,
1523
+ inputs=None,
1524
+ outputs=None,
1525
+ js="""
1526
+ () => {
1527
+ const iframe = document.querySelector('iframe');
1528
+ if (iframe && iframe.contentDocument) {
1529
+ const data = iframe.contentDocument.getElementById('exportData');
1530
+ if (data) {
1531
+ const input = document.querySelector('#export_data_input textarea');
1532
+ if (input) {
1533
+ input.value = data.value;
1534
+ input.dispatchEvent(new Event('input', {bubbles: true}));
1535
+ }
1536
+ }
1537
+ }
1538
+ return [];
1539
+ }
1540
+ """
1541
+ )
1542
+
1543
+ export_data.change(
1544
+ fn=export_mp4,
1545
+ inputs=[export_data],
1546
+ outputs=[mp4_output]
1547
+ )
1548
+
1549
+
1550
+ if __name__ == "__main__":
1551
+ demo.launch()