seawolf2357 commited on
Commit
8b90cd2
ยท
verified ยท
1 Parent(s): 0626714

Update app.py

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