seawolf2357 commited on
Commit
59f5f95
Β·
verified Β·
1 Parent(s): 5a1dde5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +489 -474
app.py CHANGED
@@ -1,14 +1,17 @@
1
  """
2
  Simple Video Editor - ν—ˆκΉ…νŽ˜μ΄μŠ€ 슀페이슀용
3
  CapCut/VEED μŠ€νƒ€μΌ 간단 μ˜μƒ νŽΈμ§‘κΈ° (흰색 ν…Œλ§ˆ)
4
- Gradio λ„€μ΄ν‹°λΈŒ 파일 μ—…λ‘œλ“œ μ‚¬μš©
5
  """
6
 
7
  import gradio as gr
8
  import base64
9
  import os
 
10
 
11
- EDITOR_HTML = """
 
 
12
  <!DOCTYPE html>
13
  <html lang="ko">
14
  <head>
@@ -17,21 +20,21 @@ EDITOR_HTML = """
17
  <title>Simple Video Editor</title>
18
  <style>
19
  /* ========== κΈ°λ³Έ 리셋 ========== */
20
- * {
21
  margin: 0;
22
  padding: 0;
23
  box-sizing: border-box;
24
- }
25
 
26
- #editor-root {
27
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
28
  background: #f5f5f7;
29
  color: #1d1d1f;
30
  font-size: 13px;
31
- }
32
 
33
  /* ========== 에디터 μ»¨ν…Œμ΄λ„ˆ ========== */
34
- .editor-container {
35
  display: flex;
36
  flex-direction: column;
37
  height: 750px;
@@ -39,10 +42,10 @@ EDITOR_HTML = """
39
  border-radius: 12px;
40
  overflow: hidden;
41
  border: 1px solid #e0e0e0;
42
- }
43
 
44
  /* ========== 상단 νˆ΄λ°” ========== */
45
- .toolbar {
46
  height: 48px;
47
  background: #ffffff;
48
  border-bottom: 1px solid #e0e0e0;
@@ -51,30 +54,30 @@ EDITOR_HTML = """
51
  justify-content: space-between;
52
  padding: 0 12px;
53
  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
54
- }
55
 
56
- .toolbar-title {
57
  font-size: 15px;
58
  font-weight: 600;
59
  color: #1d1d1f;
60
  display: flex;
61
  align-items: center;
62
  gap: 6px;
63
- }
64
 
65
- .toolbar-title svg {
66
  width: 20px;
67
  height: 20px;
68
  color: #6366f1;
69
- }
70
 
71
- .toolbar-actions {
72
  display: flex;
73
  gap: 8px;
74
- }
75
 
76
  /* ========== λ²„νŠΌ μŠ€νƒ€μΌ ========== */
77
- .btn {
78
  padding: 6px 12px;
79
  border: none;
80
  border-radius: 6px;
@@ -85,73 +88,73 @@ EDITOR_HTML = """
85
  align-items: center;
86
  gap: 4px;
87
  transition: all 0.15s;
88
- }
89
 
90
- .btn svg {
91
  width: 14px;
92
  height: 14px;
93
- }
94
 
95
- .btn-primary {
96
  background: #6366f1;
97
  color: white;
98
- }
99
 
100
- .btn-primary:hover {
101
  background: #4f46e5;
102
- }
103
 
104
- .btn-success {
105
  background: #10b981;
106
  color: white;
107
- }
108
 
109
- .btn-success:hover {
110
  background: #059669;
111
- }
112
 
113
- .btn-danger {
114
  background: #ef4444;
115
  color: white;
116
- }
117
 
118
- .btn-danger:hover {
119
  background: #dc2626;
120
- }
121
 
122
- .btn-secondary {
123
  background: #f3f4f6;
124
  color: #374151;
125
  border: 1px solid #e5e7eb;
126
- }
127
 
128
- .btn-secondary:hover {
129
  background: #e5e7eb;
130
- }
131
 
132
- .btn:disabled {
133
  opacity: 0.5;
134
  cursor: not-allowed;
135
- }
136
 
137
  /* ========== 메인 μ˜μ—­ ========== */
138
- .main-area {
139
  display: flex;
140
  flex: 1;
141
  overflow: hidden;
142
  background: #f5f5f7;
143
- }
144
 
145
  /* ========== λ―Έλ””μ–΄ 라이브러리 ========== */
146
- .media-library {
147
  width: 200px;
148
  background: #ffffff;
149
  border-right: 1px solid #e0e0e0;
150
  display: flex;
151
  flex-direction: column;
152
- }
153
 
154
- .library-header {
155
  padding: 10px 12px;
156
  border-bottom: 1px solid #e0e0e0;
157
  font-size: 11px;
@@ -159,30 +162,30 @@ EDITOR_HTML = """
159
  color: #6b7280;
160
  text-transform: uppercase;
161
  letter-spacing: 0.5px;
162
- }
163
 
164
- .library-content {
165
  flex: 1;
166
  overflow-y: auto;
167
  padding: 8px;
168
- }
169
 
170
- .library-hint {
171
  text-align: center;
172
  padding: 20px 10px;
173
  color: #9ca3af;
174
  font-size: 11px;
175
  line-height: 1.5;
176
- }
177
 
178
  /* ========== λ―Έλ””μ–΄ κ·Έλ¦¬λ“œ ========== */
179
- .media-grid {
180
  display: grid;
181
  grid-template-columns: repeat(2, 1fr);
182
  gap: 6px;
183
- }
184
 
185
- .media-item {
186
  aspect-ratio: 16/9;
187
  background: #f3f4f6;
188
  border-radius: 6px;
@@ -191,26 +194,26 @@ EDITOR_HTML = """
191
  position: relative;
192
  transition: all 0.15s;
193
  border: 1px solid #e5e7eb;
194
- }
195
 
196
- .media-item:hover {
197
  transform: scale(1.02);
198
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
199
  border-color: #6366f1;
200
- }
201
 
202
- .media-item:active {
203
  cursor: grabbing;
204
- }
205
 
206
  .media-item img,
207
- .media-item video {
208
  width: 100%;
209
  height: 100%;
210
  object-fit: cover;
211
- }
212
 
213
- .media-item-overlay {
214
  position: absolute;
215
  inset: 0;
216
  background: linear-gradient(transparent 40%, rgba(0,0,0,0.7));
@@ -219,21 +222,21 @@ EDITOR_HTML = """
219
  display: flex;
220
  align-items: flex-end;
221
  padding: 4px;
222
- }
223
 
224
- .media-item:hover .media-item-overlay {
225
  opacity: 1;
226
- }
227
 
228
- .media-item-name {
229
  font-size: 9px;
230
  color: white;
231
  white-space: nowrap;
232
  overflow: hidden;
233
  text-overflow: ellipsis;
234
- }
235
 
236
- .media-item-duration {
237
  position: absolute;
238
  top: 3px;
239
  right: 3px;
@@ -242,9 +245,9 @@ EDITOR_HTML = """
242
  border-radius: 3px;
243
  font-size: 9px;
244
  color: white;
245
- }
246
 
247
- .media-item-type {
248
  position: absolute;
249
  top: 3px;
250
  left: 3px;
@@ -255,15 +258,15 @@ EDITOR_HTML = """
255
  display: flex;
256
  align-items: center;
257
  justify-content: center;
258
- }
259
 
260
- .media-item-type svg {
261
  width: 10px;
262
  height: 10px;
263
  color: white;
264
- }
265
 
266
- .media-item-icon {
267
  width: 100%;
268
  height: 100%;
269
  display: flex;
@@ -272,10 +275,10 @@ EDITOR_HTML = """
272
  font-size: 24px;
273
  color: #9ca3af;
274
  background: #e5e7eb;
275
- }
276
 
277
  /* ========== 프리뷰 μ˜μ—­ ========== */
278
- .preview-area {
279
  flex: 1;
280
  display: flex;
281
  flex-direction: column;
@@ -284,9 +287,9 @@ EDITOR_HTML = """
284
  border-radius: 12px;
285
  overflow: hidden;
286
  box-shadow: 0 4px 20px rgba(0,0,0,0.15);
287
- }
288
 
289
- .preview-container {
290
  flex: 1;
291
  display: flex;
292
  align-items: center;
@@ -294,33 +297,33 @@ EDITOR_HTML = """
294
  position: relative;
295
  overflow: hidden;
296
  background: #000;
297
- }
298
 
299
  .preview-container video,
300
- .preview-container img {
301
  max-width: 100%;
302
  max-height: 100%;
303
  object-fit: contain;
304
- }
305
 
306
- .preview-placeholder {
307
  text-align: center;
308
  color: #666;
309
- }
310
 
311
- .preview-placeholder svg {
312
  width: 48px;
313
  height: 48px;
314
  margin-bottom: 12px;
315
  opacity: 0.5;
316
- }
317
 
318
- .preview-placeholder p {
319
  font-size: 12px;
320
- }
321
 
322
  /* ========== μž¬μƒ 컨트둀 ========== */
323
- .playback-controls {
324
  height: 50px;
325
  background: linear-gradient(180deg, #2a2a2a, #1a1a1a);
326
  display: flex;
@@ -328,9 +331,9 @@ EDITOR_HTML = """
328
  justify-content: center;
329
  gap: 8px;
330
  padding: 0 16px;
331
- }
332
 
333
- .control-btn {
334
  width: 32px;
335
  height: 32px;
336
  border: none;
@@ -342,51 +345,51 @@ EDITOR_HTML = """
342
  align-items: center;
343
  justify-content: center;
344
  transition: all 0.15s;
345
- }
346
 
347
- .control-btn:hover {
348
  background: rgba(255,255,255,0.2);
349
- }
350
 
351
- .control-btn svg {
352
  width: 14px;
353
  height: 14px;
354
- }
355
 
356
- .control-btn.play-btn {
357
  width: 40px;
358
  height: 40px;
359
  background: #6366f1;
360
- }
361
 
362
- .control-btn.play-btn:hover {
363
  background: #4f46e5;
364
  transform: scale(1.05);
365
- }
366
 
367
- .control-btn.play-btn svg {
368
  width: 18px;
369
  height: 18px;
370
- }
371
 
372
- .time-display {
373
  font-family: 'SF Mono', Monaco, monospace;
374
  font-size: 11px;
375
  color: #aaa;
376
  min-width: 110px;
377
  text-align: center;
378
- }
379
 
380
  /* ========== 속성 νŒ¨λ„ ========== */
381
- .properties-panel {
382
  width: 180px;
383
  background: #ffffff;
384
  border-left: 1px solid #e0e0e0;
385
  display: flex;
386
  flex-direction: column;
387
- }
388
 
389
- .properties-header {
390
  padding: 10px 12px;
391
  border-bottom: 1px solid #e0e0e0;
392
  font-size: 11px;
@@ -394,27 +397,27 @@ EDITOR_HTML = """
394
  color: #6b7280;
395
  text-transform: uppercase;
396
  letter-spacing: 0.5px;
397
- }
398
 
399
- .properties-content {
400
  flex: 1;
401
  padding: 12px;
402
  overflow-y: auto;
403
- }
404
 
405
- .property-group {
406
  margin-bottom: 14px;
407
- }
408
 
409
- .property-label {
410
  font-size: 10px;
411
  color: #6b7280;
412
  margin-bottom: 4px;
413
  text-transform: uppercase;
414
  letter-spacing: 0.3px;
415
- }
416
 
417
- .property-input {
418
  width: 100%;
419
  padding: 6px 8px;
420
  background: #f9fafb;
@@ -422,41 +425,41 @@ EDITOR_HTML = """
422
  border-radius: 5px;
423
  color: #1d1d1f;
424
  font-size: 12px;
425
- }
426
 
427
- .property-input:focus {
428
  outline: none;
429
  border-color: #6366f1;
430
  box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
431
- }
432
 
433
- .property-row {
434
  display: flex;
435
  gap: 8px;
436
- }
437
 
438
- .property-row > div {
439
  flex: 1;
440
- }
441
 
442
- .no-selection {
443
  color: #9ca3af;
444
  text-align: center;
445
  padding: 30px 15px;
446
  font-size: 12px;
447
  line-height: 1.5;
448
- }
449
 
450
  /* ========== νƒ€μž„λΌμΈ μ˜μ—­ ========== */
451
- .timeline-area {
452
  height: 180px;
453
  background: #ffffff;
454
  border-top: 1px solid #e0e0e0;
455
  display: flex;
456
  flex-direction: column;
457
- }
458
 
459
- .timeline-toolbar {
460
  height: 32px;
461
  background: #fafafa;
462
  border-bottom: 1px solid #e0e0e0;
@@ -464,75 +467,75 @@ EDITOR_HTML = """
464
  align-items: center;
465
  padding: 0 8px;
466
  gap: 6px;
467
- }
468
 
469
- .timeline-toolbar .btn {
470
  padding: 4px 8px;
471
  font-size: 11px;
472
- }
473
 
474
- .timeline-toolbar .btn svg {
475
  width: 12px;
476
  height: 12px;
477
- }
478
 
479
- .timeline-zoom {
480
  display: flex;
481
  align-items: center;
482
  gap: 4px;
483
  margin-left: auto;
484
  font-size: 11px;
485
  color: #6b7280;
486
- }
487
 
488
- .timeline-zoom input {
489
  width: 60px;
490
  height: 4px;
491
- }
492
 
493
- .timeline-zoom svg {
494
  width: 12px;
495
  height: 12px;
496
- }
497
 
498
- .timeline-container {
499
  flex: 1;
500
  overflow-x: auto;
501
  overflow-y: hidden;
502
  position: relative;
503
  background: #f9fafb;
504
- }
505
 
506
  /* ========== νƒ€μž„λΌμΈ 눈금자 ========== */
507
- .timeline-ruler {
508
  height: 22px;
509
  background: #fff;
510
  position: sticky;
511
  top: 0;
512
  z-index: 10;
513
  border-bottom: 1px solid #e5e7eb;
514
- }
515
 
516
  /* ========== νƒ€μž„λΌμΈ νŠΈλž™ ========== */
517
- .timeline-tracks {
518
  position: relative;
519
  min-height: 120px;
520
- }
521
 
522
- .timeline-track {
523
  height: 55px;
524
  border-bottom: 1px solid #e5e7eb;
525
  position: relative;
526
  display: flex;
527
  align-items: center;
528
  background: #fff;
529
- }
530
 
531
- .timeline-track:nth-child(2) {
532
  background: #fffbeb;
533
- }
534
 
535
- .track-label {
536
  width: 70px;
537
  padding: 0 8px;
538
  font-size: 10px;
@@ -546,22 +549,22 @@ EDITOR_HTML = """
546
  position: sticky;
547
  left: 0;
548
  z-index: 5;
549
- }
550
 
551
- .track-label svg {
552
  width: 12px;
553
  height: 12px;
554
- }
555
 
556
- .track-content {
557
  flex: 1;
558
  height: 100%;
559
  position: relative;
560
  min-width: 800px;
561
- }
562
 
563
  /* ========== νƒ€μž„λΌμΈ 클립 ========== */
564
- .timeline-clip {
565
  position: absolute;
566
  height: 45px;
567
  top: 5px;
@@ -573,61 +576,61 @@ EDITOR_HTML = """
573
  transition: box-shadow 0.15s;
574
  min-width: 40px;
575
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
576
- }
577
 
578
- .timeline-clip:active {
579
  cursor: grabbing;
580
- }
581
 
582
- .timeline-clip:hover {
583
  box-shadow: 0 0 0 2px #6366f1;
584
- }
585
 
586
- .timeline-clip.selected {
587
  box-shadow: 0 0 0 2px #6366f1, 0 4px 12px rgba(99, 102, 241, 0.3);
588
- }
589
 
590
- .timeline-clip.video {
591
  background: linear-gradient(135deg, #818cf8, #6366f1);
592
- }
593
 
594
- .timeline-clip.image {
595
  background: linear-gradient(135deg, #34d399, #10b981);
596
- }
597
 
598
- .timeline-clip.audio {
599
  background: linear-gradient(135deg, #fbbf24, #f59e0b);
600
- }
601
 
602
- .clip-thumbnail {
603
  width: 45px;
604
  height: 100%;
605
  object-fit: cover;
606
  flex-shrink: 0;
607
- }
608
 
609
- .clip-info {
610
  padding: 0 6px;
611
  flex: 1;
612
  overflow: hidden;
613
- }
614
 
615
- .clip-name {
616
  font-size: 10px;
617
  font-weight: 500;
618
  color: white;
619
  white-space: nowrap;
620
  overflow: hidden;
621
  text-overflow: ellipsis;
622
- }
623
 
624
- .clip-duration {
625
  font-size: 9px;
626
  color: rgba(255,255,255,0.7);
627
- }
628
 
629
  /* ========== 클립 트림 ν•Έλ“€ ========== */
630
- .clip-handles {
631
  position: absolute;
632
  top: 0;
633
  bottom: 0;
@@ -636,24 +639,24 @@ EDITOR_HTML = """
636
  cursor: ew-resize;
637
  opacity: 0;
638
  transition: opacity 0.15s;
639
- }
640
 
641
- .timeline-clip:hover .clip-handles {
642
  opacity: 1;
643
- }
644
 
645
- .clip-handle-left {
646
  left: 0;
647
  border-radius: 6px 0 0 6px;
648
- }
649
 
650
- .clip-handle-right {
651
  right: 0;
652
  border-radius: 0 6px 6px 0;
653
- }
654
 
655
  /* ========== ν”Œλ ˆμ΄ν—€λ“œ ========== */
656
- .playhead {
657
  position: absolute;
658
  top: 0;
659
  bottom: 0;
@@ -661,9 +664,9 @@ EDITOR_HTML = """
661
  background: #ef4444;
662
  z-index: 20;
663
  pointer-events: none;
664
- }
665
 
666
- .playhead::before {
667
  content: '';
668
  position: absolute;
669
  top: 0;
@@ -672,36 +675,36 @@ EDITOR_HTML = """
672
  height: 12px;
673
  background: #ef4444;
674
  clip-path: polygon(50% 100%, 0 0, 100% 0);
675
- }
676
 
677
  /* ========== μŠ€ν¬λ‘€λ°” μŠ€νƒ€μΌ ========== */
678
- ::-webkit-scrollbar {
679
  width: 6px;
680
  height: 6px;
681
- }
682
 
683
- ::-webkit-scrollbar-track {
684
  background: #f1f1f1;
685
- }
686
 
687
- ::-webkit-scrollbar-thumb {
688
  background: #c1c1c1;
689
  border-radius: 3px;
690
- }
691
 
692
- ::-webkit-scrollbar-thumb:hover {
693
  background: #a1a1a1;
694
- }
695
 
696
  /* ========== λ“œλ‘­μ‘΄ ν•˜μ΄λΌμ΄νŠΈ ========== */
697
- .drop-highlight {
698
  background: rgba(99, 102, 241, 0.1) !important;
699
  outline: 2px dashed #6366f1 !important;
700
  outline-offset: -2px;
701
- }
702
 
703
  /* ========== λͺ¨λ‹¬ ========== */
704
- .modal-overlay {
705
  position: fixed;
706
  inset: 0;
707
  background: rgba(0,0,0,0.5);
@@ -709,43 +712,43 @@ EDITOR_HTML = """
709
  align-items: center;
710
  justify-content: center;
711
  z-index: 1000;
712
- }
713
 
714
- .modal {
715
  background: #fff;
716
  border-radius: 12px;
717
  padding: 24px;
718
  min-width: 280px;
719
  text-align: center;
720
  box-shadow: 0 20px 60px rgba(0,0,0,0.3);
721
- }
722
 
723
- .modal h3 {
724
  margin-bottom: 12px;
725
  font-size: 16px;
726
- }
727
 
728
- .modal p {
729
  font-size: 13px;
730
  color: #6b7280;
731
- }
732
 
733
- .progress-bar {
734
  height: 6px;
735
  background: #e5e7eb;
736
  border-radius: 3px;
737
  overflow: hidden;
738
  margin: 16px 0;
739
- }
740
 
741
- .progress-fill {
742
  height: 100%;
743
  background: linear-gradient(90deg, #6366f1, #8b5cf6);
744
  transition: width 0.3s;
745
- }
746
 
747
  /* ========== μ»¨ν…μŠ€νŠΈ 메뉴 ========== */
748
- .context-menu {
749
  position: fixed;
750
  background: #fff;
751
  border: 1px solid #e5e7eb;
@@ -754,38 +757,38 @@ EDITOR_HTML = """
754
  min-width: 140px;
755
  z-index: 1000;
756
  box-shadow: 0 10px 40px rgba(0,0,0,0.15);
757
- }
758
 
759
- .context-menu-item {
760
  padding: 6px 12px;
761
  cursor: pointer;
762
  display: flex;
763
  align-items: center;
764
  gap: 8px;
765
  font-size: 12px;
766
- }
767
 
768
- .context-menu-item:hover {
769
  background: #f3f4f6;
770
- }
771
 
772
- .context-menu-item svg {
773
  width: 12px;
774
  height: 12px;
775
- }
776
 
777
- .context-menu-item.danger {
778
  color: #ef4444;
779
- }
780
 
781
- .context-menu-divider {
782
  height: 1px;
783
  background: #e5e7eb;
784
  margin: 4px 0;
785
- }
786
 
787
  /* ========== μƒνƒœλ°” ========== */
788
- .status-bar {
789
  height: 24px;
790
  background: #f0f0f0;
791
  border-top: 1px solid #e0e0e0;
@@ -794,7 +797,7 @@ EDITOR_HTML = """
794
  padding: 0 12px;
795
  font-size: 11px;
796
  color: #666;
797
- }
798
  </style>
799
  </head>
800
  <body>
@@ -1019,7 +1022,7 @@ EDITOR_HTML = """
1019
  // ========================================
1020
  // μ „μ—­ μƒνƒœ λ³€μˆ˜
1021
  // ========================================
1022
- window.editorState = {
1023
  mediaLibrary: [],
1024
  timelineClips: [],
1025
  selectedClipId: null,
@@ -1032,137 +1035,142 @@ window.editorState = {
1032
  undoStack: [],
1033
  animationId: null,
1034
  trimData: null
1035
- };
1036
 
1037
  // ========================================
1038
  // μœ ν‹Έλ¦¬ν‹° ν•¨μˆ˜
1039
  // ========================================
1040
- function generateId() {
1041
  return Date.now().toString(36) + Math.random().toString(36).substr(2);
1042
- }
1043
 
1044
- function formatTime(seconds) {
1045
  if (!seconds || isNaN(seconds)) seconds = 0;
1046
  var mins = Math.floor(seconds / 60);
1047
  var secs = Math.floor(seconds % 60);
1048
  var ms = Math.floor((seconds % 1) * 100);
1049
  return (mins < 10 ? '0' : '') + mins + ':' + (secs < 10 ? '0' : '') + secs + '.' + (ms < 10 ? '0' : '') + ms;
1050
- }
1051
 
1052
- function updateStatus(msg) {
1053
  var el = document.getElementById('statusBar');
1054
- if (el) {
1055
  var state = window.editorState;
1056
  el.textContent = msg + ' | λ―Έλ””μ–΄: ' + state.mediaLibrary.length + '개 | 클립: ' + state.timelineClips.length + '개';
1057
- }
1058
- }
1059
 
1060
- function saveState() {
1061
  var state = window.editorState;
1062
  state.undoStack.push(JSON.stringify(state.timelineClips));
1063
  if (state.undoStack.length > 50) state.undoStack.shift();
1064
- }
1065
 
1066
  // ========================================
1067
- // μ™ΈλΆ€μ—μ„œ ν˜ΈμΆœλ˜λŠ” λ―Έλ””μ–΄ μΆ”κ°€ ν•¨μˆ˜
1068
  // ========================================
1069
- window.addMediaToEditor = function(name, type, dataUrl) {
1070
  console.log('Adding media:', name, type);
1071
 
1072
  var state = window.editorState;
1073
- var media = {
1074
  id: generateId(),
1075
  name: name,
1076
  type: type,
1077
  url: dataUrl,
1078
  duration: (type === 'image') ? 5 : 0,
1079
  thumbnail: (type === 'image') ? dataUrl : null
1080
- };
1081
 
1082
  state.mediaLibrary.push(media);
1083
 
1084
  // λΉ„λ””μ˜€/μ˜€λ””μ˜€ 메타데이터 λ‘œλ“œ
1085
- if (type === 'video' || type === 'audio') {
1086
  var el = document.createElement(type);
1087
  el.src = dataUrl;
1088
  el.preload = 'metadata';
1089
 
1090
- el.onloadedmetadata = function() {
1091
  media.duration = el.duration;
1092
  renderMediaLibrary();
1093
 
1094
- if (type === 'video') {
1095
  el.currentTime = Math.min(1, el.duration / 2);
1096
- }
1097
- };
1098
 
1099
- el.onseeked = function() {
1100
- if (type === 'video') {
1101
- try {
1102
  var canvas = document.createElement('canvas');
1103
  canvas.width = 160;
1104
  canvas.height = 90;
1105
  canvas.getContext('2d').drawImage(el, 0, 0, 160, 90);
1106
  media.thumbnail = canvas.toDataURL();
1107
  renderMediaLibrary();
1108
- } catch(e) {
1109
  console.log('Thumbnail failed');
1110
- }
1111
- }
1112
- };
1113
- }
1114
 
1115
  renderMediaLibrary();
1116
  updateStatus('λ―Έλ””μ–΄ 좔가됨: ' + name);
1117
- };
 
 
 
 
 
1118
 
1119
  // ========================================
1120
  // λ―Έλ””μ–΄ 라이브러리 λ Œλ”λ§
1121
  // ========================================
1122
- function renderMediaLibrary() {
1123
  var state = window.editorState;
1124
  var grid = document.getElementById('mediaGrid');
1125
  var hint = document.getElementById('libraryHint');
1126
 
1127
  if (!grid) return;
1128
 
1129
- if (hint) {
1130
  hint.style.display = state.mediaLibrary.length === 0 ? 'block' : 'none';
1131
- }
1132
 
1133
  grid.innerHTML = '';
1134
 
1135
- var typeIcons = {
1136
  video: '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21"/></svg>',
1137
  image: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
1138
  audio: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>'
1139
- };
1140
 
1141
- state.mediaLibrary.forEach(function(media) {
1142
  var item = document.createElement('div');
1143
  item.className = 'media-item';
1144
  item.draggable = true;
1145
  item.setAttribute('data-id', media.id);
1146
 
1147
- item.ondblclick = function() {
1148
  addToTimeline(media);
1149
- };
1150
 
1151
- item.ondragstart = function(e) {
1152
  e.dataTransfer.setData('mediaId', media.id);
1153
  this.style.opacity = '0.5';
1154
- };
1155
 
1156
- item.ondragend = function() {
1157
  this.style.opacity = '1';
1158
- };
1159
 
1160
  var thumbHtml = '';
1161
- if (media.thumbnail) {
1162
  thumbHtml = '<img src="' + media.thumbnail + '" alt="' + media.name + '">';
1163
- } else {
1164
  thumbHtml = '<div class="media-item-icon">' + (media.type === 'video' ? '🎬' : media.type === 'audio' ? '🎡' : 'πŸ–ΌοΈ') + '</div>';
1165
- }
1166
 
1167
  item.innerHTML = thumbHtml +
1168
  '<div class="media-item-type">' + typeIcons[media.type] + '</div>' +
@@ -1170,20 +1178,20 @@ function renderMediaLibrary() {
1170
  '<div class="media-item-overlay"><span class="media-item-name">' + media.name + '</span></div>';
1171
 
1172
  grid.appendChild(item);
1173
- });
1174
- }
1175
 
1176
  // ========================================
1177
  // νƒ€μž„λΌμΈμ— μΆ”κ°€
1178
  // ========================================
1179
- function addToTimeline(media, startTime) {
1180
  var state = window.editorState;
1181
  saveState();
1182
 
1183
  var track = (media.type === 'audio') ? 1 : 0;
1184
  var start = (startTime !== undefined) ? startTime : getTrackEndTime(track);
1185
 
1186
- var clip = {
1187
  id: generateId(),
1188
  mediaId: media.id,
1189
  name: media.name,
@@ -1196,30 +1204,30 @@ function addToTimeline(media, startTime) {
1196
  trimStart: 0,
1197
  trimEnd: media.duration,
1198
  volume: 1
1199
- };
1200
 
1201
  state.timelineClips.push(clip);
1202
  renderTimeline();
1203
  updateDuration();
1204
  updateStatus('클립 좔가됨: ' + clip.name);
1205
- }
1206
 
1207
- function getTrackEndTime(track) {
1208
  var state = window.editorState;
1209
  var maxEnd = 0;
1210
- state.timelineClips.forEach(function(c) {
1211
- if (c.track === track) {
1212
  var end = c.startTime + (c.trimEnd - c.trimStart);
1213
  if (end > maxEnd) maxEnd = end;
1214
- }
1215
- });
1216
  return maxEnd;
1217
- }
1218
 
1219
  // ========================================
1220
  // νƒ€μž„λΌμΈ λ Œλ”λ§
1221
  // ========================================
1222
- function renderTimeline() {
1223
  var state = window.editorState;
1224
 
1225
  var track0 = document.getElementById('track0');
@@ -1227,7 +1235,7 @@ function renderTimeline() {
1227
  if (track0) track0.innerHTML = '';
1228
  if (track1) track1.innerHTML = '';
1229
 
1230
- state.timelineClips.forEach(function(clip) {
1231
  var trackEl = document.getElementById('track' + clip.track);
1232
  if (!trackEl) return;
1233
 
@@ -1243,37 +1251,37 @@ function renderTimeline() {
1243
  clipEl.style.width = width + 'px';
1244
  clipEl.draggable = true;
1245
 
1246
- clipEl.onclick = function(e) {
1247
  e.stopPropagation();
1248
  selectClip(clip.id);
1249
- };
1250
 
1251
- clipEl.oncontextmenu = function(e) {
1252
  e.preventDefault();
1253
  selectClip(clip.id);
1254
  showContextMenu(e.clientX, e.clientY);
1255
- };
1256
 
1257
- clipEl.ondragstart = function(e) {
1258
  e.dataTransfer.setData('clipId', clip.id);
1259
  e.dataTransfer.setData('offsetX', e.offsetX.toString());
1260
- };
1261
 
1262
  var thumbHtml = clip.thumbnail ? '<img class="clip-thumbnail" src="' + clip.thumbnail + '">' : '';
1263
 
1264
  clipEl.innerHTML = thumbHtml +
1265
  '<div class="clip-info"><div class="clip-name">' + clip.name + '</div><div class="clip-duration">' + formatTime(clipDuration) + '</div></div>' +
1266
- '<div class="clip-handles clip-handle-left" onmousedown="startTrim(event, \'' + clip.id + '\', \'left\')"></div>' +
1267
- '<div class="clip-handles clip-handle-right" onmousedown="startTrim(event, \'' + clip.id + '\', \'right\')"></div>';
1268
 
1269
  trackEl.appendChild(clipEl);
1270
- });
1271
 
1272
  renderRuler();
1273
  setupTrackDropZones();
1274
- }
1275
 
1276
- function renderRuler() {
1277
  var state = window.editorState;
1278
  var ruler = document.getElementById('timelineRuler');
1279
  if (!ruler) return;
@@ -1284,33 +1292,33 @@ function renderRuler() {
1284
  var html = '<svg width="100%" height="22" style="position:absolute;left:70px">';
1285
  var step = state.zoom < 0.7 ? 5 : state.zoom < 1.5 ? 2 : 1;
1286
 
1287
- for (var i = 0; i <= Math.ceil(state.totalDuration) + 10; i += step) {
1288
  var x = i * state.pixelsPerSecond * state.zoom;
1289
  html += '<line x1="' + x + '" y1="17" x2="' + x + '" y2="22" stroke="#d1d5db" stroke-width="1"/>';
1290
  html += '<text x="' + x + '" y="12" fill="#9ca3af" font-size="9" text-anchor="middle">' + formatTime(i) + '</text>';
1291
- }
1292
 
1293
  html += '</svg>';
1294
  ruler.innerHTML = html;
1295
- }
1296
 
1297
- function setupTrackDropZones() {
1298
  var state = window.editorState;
1299
 
1300
- ['track0', 'track1'].forEach(function(trackId, trackIdx) {
1301
  var track = document.getElementById(trackId);
1302
  if (!track) return;
1303
 
1304
- track.ondragover = function(e) {
1305
  e.preventDefault();
1306
  track.classList.add('drop-highlight');
1307
- };
1308
 
1309
- track.ondragleave = function() {
1310
  track.classList.remove('drop-highlight');
1311
- };
1312
 
1313
- track.ondrop = function(e) {
1314
  e.preventDefault();
1315
  track.classList.remove('drop-highlight');
1316
 
@@ -1322,163 +1330,163 @@ function setupTrackDropZones() {
1322
  var clipId = e.dataTransfer.getData('clipId');
1323
  var offsetX = parseFloat(e.dataTransfer.getData('offsetX') || 0);
1324
 
1325
- if (mediaId) {
1326
- var media = state.mediaLibrary.find(function(m) { return m.id === mediaId; });
1327
- if (media) {
1328
  var targetTrack = (media.type === 'audio') ? 1 : trackIdx;
1329
  addToTimeline(media, time);
1330
- if (targetTrack !== trackIdx) {
1331
  state.timelineClips[state.timelineClips.length - 1].track = targetTrack;
1332
  renderTimeline();
1333
- }
1334
- }
1335
- } else if (clipId) {
1336
  saveState();
1337
- var clip = state.timelineClips.find(function(c) { return c.id === clipId; });
1338
- if (clip) {
1339
  clip.startTime = Math.max(0, time - offsetX / (state.pixelsPerSecond * state.zoom));
1340
  clip.track = (clip.type === 'audio') ? 1 : trackIdx;
1341
  renderTimeline();
1342
  updateDuration();
1343
- }
1344
- }
1345
- };
1346
- });
1347
- }
1348
 
1349
  // ========================================
1350
  // 클립 선택 및 속성
1351
  // ========================================
1352
- function selectClip(clipId) {
1353
  var state = window.editorState;
1354
  state.selectedClipId = clipId;
1355
  renderTimeline();
1356
  renderProperties();
1357
- }
1358
 
1359
- function renderProperties() {
1360
  var state = window.editorState;
1361
  var container = document.getElementById('propertiesContent');
1362
  if (!container) return;
1363
 
1364
- var clip = state.timelineClips.find(function(c) { return c.id === state.selectedClipId; });
1365
 
1366
- if (!clip) {
1367
  container.innerHTML = '<div class="no-selection">클립을 μ„ νƒν•˜λ©΄<br>속성을 νŽΈμ§‘ν•  수 μžˆμŠ΅λ‹ˆλ‹€</div>';
1368
  return;
1369
- }
1370
 
1371
  var clipDuration = clip.trimEnd - clip.trimStart;
1372
 
1373
  var html = '<div class="property-group">' +
1374
  '<div class="property-label">이름</div>' +
1375
- '<input type="text" class="property-input" value="' + clip.name + '" onchange="updateClipProp(\'name\', this.value)">' +
1376
  '</div>' +
1377
  '<div class="property-group">' +
1378
  '<div class="property-label">μ‹œμž‘ μ‹œκ°„</div>' +
1379
- '<input type="number" class="property-input" value="' + clip.startTime.toFixed(2) + '" step="0.1" min="0" onchange="updateClipProp(\'startTime\', parseFloat(this.value))">' +
1380
  '</div>' +
1381
  '<div class="property-group">' +
1382
  '<div class="property-row">' +
1383
- '<div><div class="property-label">트림 μ‹œμž‘</div><input type="number" class="property-input" value="' + clip.trimStart.toFixed(2) + '" step="0.1" min="0" max="' + clip.duration + '" onchange="updateClipProp(\'trimStart\', parseFloat(this.value))"></div>' +
1384
- '<div><div class="property-label">트림 끝</div><input type="number" class="property-input" value="' + clip.trimEnd.toFixed(2) + '" step="0.1" min="0" max="' + clip.duration + '" onchange="updateClipProp(\'trimEnd\', parseFloat(this.value))"></div>' +
1385
  '</div></div>' +
1386
  '<div class="property-group"><div class="property-label">길이</div><div style="padding:6px 0;font-size:12px;color:#374151">' + formatTime(clipDuration) + '</div></div>';
1387
 
1388
- if (clip.type !== 'image') {
1389
  html += '<div class="property-group">' +
1390
  '<div class="property-label">λ³Όλ₯¨ (' + Math.round(clip.volume * 100) + '%)</div>' +
1391
- '<input type="range" class="property-input" style="padding:0" min="0" max="1" step="0.05" value="' + clip.volume + '" oninput="updateClipProp(\'volume\', parseFloat(this.value))">' +
1392
  '</div>';
1393
- }
1394
 
1395
  container.innerHTML = html;
1396
- }
1397
 
1398
- function updateClipProp(prop, value) {
1399
  var state = window.editorState;
1400
  saveState();
1401
- var clip = state.timelineClips.find(function(c) { return c.id === state.selectedClipId; });
1402
- if (clip) {
1403
  clip[prop] = value;
1404
  renderTimeline();
1405
  updateDuration();
1406
  renderProperties();
1407
- }
1408
- }
1409
 
1410
  // ========================================
1411
  // 트림 ν•Έλ“€
1412
  // ========================================
1413
- window.startTrim = function(event, clipId, side) {
1414
  event.stopPropagation();
1415
  event.preventDefault();
1416
 
1417
  var state = window.editorState;
1418
- var clip = state.timelineClips.find(function(c) { return c.id === clipId; });
1419
  if (!clip) return;
1420
 
1421
  saveState();
1422
 
1423
- state.trimData = {
1424
  clipId: clipId,
1425
  side: side,
1426
  startX: event.clientX,
1427
  originalTrimStart: clip.trimStart,
1428
  originalTrimEnd: clip.trimEnd,
1429
  originalStartTime: clip.startTime
1430
- };
1431
 
1432
  document.addEventListener('mousemove', handleTrim);
1433
  document.addEventListener('mouseup', endTrim);
1434
- };
1435
 
1436
- function handleTrim(event) {
1437
  var state = window.editorState;
1438
  if (!state.trimData) return;
1439
 
1440
- var clip = state.timelineClips.find(function(c) { return c.id === state.trimData.clipId; });
1441
  if (!clip) return;
1442
 
1443
  var deltaX = event.clientX - state.trimData.startX;
1444
  var deltaTime = deltaX / (state.pixelsPerSecond * state.zoom);
1445
 
1446
- if (state.trimData.side === 'left') {
1447
  var newTrimStart = Math.max(0, Math.min(clip.trimEnd - 0.1, state.trimData.originalTrimStart + deltaTime));
1448
  var trimDelta = newTrimStart - state.trimData.originalTrimStart;
1449
  clip.trimStart = newTrimStart;
1450
  clip.startTime = state.trimData.originalStartTime + trimDelta;
1451
- } else {
1452
  clip.trimEnd = Math.max(clip.trimStart + 0.1, Math.min(clip.duration, state.trimData.originalTrimEnd + deltaTime));
1453
- }
1454
 
1455
  renderTimeline();
1456
  updateDuration();
1457
- }
1458
 
1459
- function endTrim() {
1460
  var state = window.editorState;
1461
  state.trimData = null;
1462
  document.removeEventListener('mousemove', handleTrim);
1463
  document.removeEventListener('mouseup', endTrim);
1464
- }
1465
 
1466
  // ========================================
1467
  // νŽΈμ§‘ κΈ°λŠ₯
1468
  // ========================================
1469
- window.editorSplit = function() {
1470
  var state = window.editorState;
1471
  if (!state.selectedClipId) return;
1472
 
1473
  var idx = -1;
1474
  var clip = null;
1475
- for (var i = 0; i < state.timelineClips.length; i++) {
1476
- if (state.timelineClips[i].id === state.selectedClipId) {
1477
  idx = i;
1478
  clip = state.timelineClips[i];
1479
  break;
1480
- }
1481
- }
1482
  if (!clip) return;
1483
 
1484
  saveState();
@@ -1497,13 +1505,13 @@ window.editorSplit = function() {
1497
  renderTimeline();
1498
  hideContextMenu();
1499
  updateStatus('클립 뢄할됨');
1500
- };
1501
 
1502
- window.editorDuplicate = function() {
1503
  var state = window.editorState;
1504
  if (!state.selectedClipId) return;
1505
 
1506
- var clip = state.timelineClips.find(function(c) { return c.id === state.selectedClipId; });
1507
  if (!clip) return;
1508
 
1509
  saveState();
@@ -1518,14 +1526,14 @@ window.editorDuplicate = function() {
1518
  updateDuration();
1519
  hideContextMenu();
1520
  updateStatus('클립 볡제됨');
1521
- };
1522
 
1523
- window.editorDelete = function() {
1524
  var state = window.editorState;
1525
  if (!state.selectedClipId) return;
1526
 
1527
  saveState();
1528
- state.timelineClips = state.timelineClips.filter(function(c) { return c.id !== state.selectedClipId; });
1529
  state.selectedClipId = null;
1530
 
1531
  renderTimeline();
@@ -1533,169 +1541,169 @@ window.editorDelete = function() {
1533
  updateDuration();
1534
  hideContextMenu();
1535
  updateStatus('클립 μ‚­μ œλ¨');
1536
- };
1537
 
1538
- window.editorUndo = function() {
1539
  var state = window.editorState;
1540
- if (state.undoStack.length > 0) {
1541
  state.timelineClips = JSON.parse(state.undoStack.pop());
1542
  renderTimeline();
1543
  updateDuration();
1544
  updateStatus('μ‹€ν–‰μ·¨μ†Œ');
1545
- }
1546
- };
1547
 
1548
  // ========================================
1549
  // μž¬μƒ 컨트둀
1550
  // ========================================
1551
- function updateDuration() {
1552
  var state = window.editorState;
1553
  var maxEnd = 0;
1554
- state.timelineClips.forEach(function(c) {
1555
  var end = c.startTime + (c.trimEnd - c.trimStart);
1556
  if (end > maxEnd) maxEnd = end;
1557
- });
1558
  state.totalDuration = maxEnd;
1559
  document.getElementById('durationDisplay').textContent = formatTime(maxEnd);
1560
- }
1561
 
1562
- window.editorTogglePlay = function() {
1563
  var state = window.editorState;
1564
  state.isPlaying = !state.isPlaying;
1565
  var icon = document.getElementById('playIcon');
1566
 
1567
- if (state.isPlaying) {
1568
  icon.innerHTML = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';
1569
  startPlayback();
1570
- } else {
1571
  icon.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
1572
  stopPlayback();
1573
- }
1574
- };
1575
 
1576
- function startPlayback() {
1577
  var state = window.editorState;
1578
  var lastTime = performance.now();
1579
 
1580
- function animate(now) {
1581
  if (!state.isPlaying) return;
1582
 
1583
  var delta = (now - lastTime) / 1000;
1584
  lastTime = now;
1585
  state.currentTime += delta;
1586
 
1587
- if (state.currentTime >= state.totalDuration) {
1588
  state.currentTime = 0;
1589
- if (state.totalDuration === 0) {
1590
  state.isPlaying = false;
1591
  document.getElementById('playIcon').innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
1592
  return;
1593
- }
1594
- }
1595
 
1596
  updatePlayhead();
1597
  updatePreview();
1598
  state.animationId = requestAnimationFrame(animate);
1599
- }
1600
 
1601
  state.animationId = requestAnimationFrame(animate);
1602
- }
1603
 
1604
- function stopPlayback() {
1605
  var state = window.editorState;
1606
- if (state.animationId) {
1607
  cancelAnimationFrame(state.animationId);
1608
  state.animationId = null;
1609
- }
1610
  var video = document.querySelector('#previewContainer video');
1611
  if (video && !video.paused) video.pause();
1612
- }
1613
 
1614
- function updatePlayhead() {
1615
  var state = window.editorState;
1616
  var playhead = document.getElementById('playhead');
1617
- if (playhead) {
1618
  playhead.style.left = (70 + state.currentTime * state.pixelsPerSecond * state.zoom) + 'px';
1619
- }
1620
  document.getElementById('currentTimeDisplay').textContent = formatTime(state.currentTime);
1621
- }
1622
 
1623
- function updatePreview() {
1624
  var state = window.editorState;
1625
  var container = document.getElementById('previewContainer');
1626
  if (!container) return;
1627
 
1628
- var currentClips = state.timelineClips.filter(function(c) {
1629
  var clipEnd = c.startTime + (c.trimEnd - c.trimStart);
1630
  return state.currentTime >= c.startTime && state.currentTime < clipEnd;
1631
- });
1632
 
1633
- var visualClip = currentClips.find(function(c) { return c.type === 'video' || c.type === 'image'; });
1634
 
1635
- if (visualClip) {
1636
  var clipTime = state.currentTime - visualClip.startTime + visualClip.trimStart;
1637
 
1638
- if (visualClip.type === 'image') {
1639
- if (!container.querySelector('img[data-clip-id="' + visualClip.id + '"]')) {
1640
  container.innerHTML = '<img src="' + visualClip.url + '" data-clip-id="' + visualClip.id + '">';
1641
- }
1642
- } else if (visualClip.type === 'video') {
1643
  var video = container.querySelector('video[data-clip-id="' + visualClip.id + '"]');
1644
- if (!video) {
1645
  container.innerHTML = '<video src="' + visualClip.url + '" data-clip-id="' + visualClip.id + '"' + (state.isMuted ? ' muted' : '') + '></video>';
1646
  video = container.querySelector('video');
1647
- }
1648
 
1649
- if (Math.abs(video.currentTime - clipTime) > 0.2) {
1650
  video.currentTime = clipTime;
1651
- }
1652
 
1653
- if (state.isPlaying && video.paused) {
1654
- video.play().catch(function(){});
1655
- } else if (!state.isPlaying && !video.paused) {
1656
  video.pause();
1657
- }
1658
 
1659
  video.volume = state.isMuted ? 0 : visualClip.volume;
1660
  video.muted = state.isMuted;
1661
- }
1662
- } else {
1663
- var hasAudio = currentClips.some(function(c) { return c.type === 'audio'; });
1664
- if (!container.querySelector('.preview-placeholder')) {
1665
  container.innerHTML = '<div class="preview-placeholder">' +
1666
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="2" y="2" width="20" height="20" rx="2"/><polygon points="10 8 16 12 10 16 10 8"/></svg>' +
1667
  '<p>' + (hasAudio ? '🎡 μ˜€λ””μ˜€ μž¬μƒ 쀑' : 'νƒ€μž„λΌμΈμ— λ―Έλ””μ–΄λ₯Ό μΆ”κ°€ν•˜μ„Έμš”') + '</p></div>';
1668
- }
1669
- }
1670
- }
1671
 
1672
- window.editorSkipStart = function() { window.editorState.currentTime = 0; updatePlayhead(); updatePreview(); };
1673
- window.editorSkipEnd = function() { window.editorState.currentTime = window.editorState.totalDuration; updatePlayhead(); updatePreview(); };
1674
- window.editorSkipBack = function() { window.editorState.currentTime = Math.max(0, window.editorState.currentTime - 5); updatePlayhead(); updatePreview(); };
1675
- window.editorSkipForward = function() { window.editorState.currentTime = Math.min(window.editorState.totalDuration, window.editorState.currentTime + 5); updatePlayhead(); updatePreview(); };
1676
 
1677
- window.editorToggleMute = function() {
1678
  var state = window.editorState;
1679
  state.isMuted = !state.isMuted;
1680
  var icon = document.getElementById('volumeIcon');
1681
 
1682
- if (state.isMuted) {
1683
  icon.innerHTML = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>';
1684
- } else {
1685
  icon.innerHTML = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/>';
1686
- }
1687
 
1688
  var video = document.querySelector('#previewContainer video');
1689
  if (video) video.muted = state.isMuted;
1690
- };
1691
 
1692
- window.editorSetZoom = function(value) {
1693
  window.editorState.zoom = parseFloat(value);
1694
  renderTimeline();
1695
  updatePlayhead();
1696
- };
1697
 
1698
- window.editorTimelineClick = function(e) {
1699
  if (e.target.closest('.timeline-clip')) return;
1700
  var state = window.editorState;
1701
  var container = document.getElementById('timelineContainer');
@@ -1704,44 +1712,44 @@ window.editorTimelineClick = function(e) {
1704
  state.currentTime = Math.max(0, Math.min(state.totalDuration, x / (state.pixelsPerSecond * state.zoom)));
1705
  updatePlayhead();
1706
  updatePreview();
1707
- };
1708
 
1709
  // ========================================
1710
  // μ»¨ν…μŠ€νŠΈ 메뉴
1711
  // ========================================
1712
- function showContextMenu(x, y) {
1713
  var menu = document.getElementById('contextMenu');
1714
  menu.style.display = 'block';
1715
  menu.style.left = x + 'px';
1716
  menu.style.top = y + 'px';
1717
- }
1718
 
1719
- function hideContextMenu() {
1720
  document.getElementById('contextMenu').style.display = 'none';
1721
- }
1722
 
1723
- document.addEventListener('click', function(e) {
1724
- if (!e.target.closest('.context-menu')) {
1725
  hideContextMenu();
1726
- }
1727
- });
1728
 
1729
  // ========================================
1730
  // 내보내기
1731
  // ========================================
1732
- window.editorExport = function() {
1733
  var state = window.editorState;
1734
- if (state.timelineClips.length === 0) {
1735
  alert('νƒ€μž„λΌμΈμ— 클립을 μΆ”κ°€ν•΄μ£Όμ„Έμš”.');
1736
  return;
1737
- }
1738
 
1739
  document.getElementById('exportModal').style.display = 'flex';
1740
  document.getElementById('exportProgress').style.width = '0%';
1741
  document.getElementById('exportStatus').textContent = 'μ˜μƒμ„ μ²˜λ¦¬ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€...';
1742
 
1743
  var progress = 0;
1744
- var interval = setInterval(function() {
1745
  progress += 2;
1746
  document.getElementById('exportProgress').style.width = progress + '%';
1747
 
@@ -1749,55 +1757,66 @@ window.editorExport = function() {
1749
  if (progress === 60) document.getElementById('exportStatus').textContent = 'μ˜€λ””μ˜€ 처리 쀑...';
1750
  if (progress === 90) document.getElementById('exportStatus').textContent = 'μ΅œμ’… λ Œλ”λ§ 쀑...';
1751
 
1752
- if (progress >= 100) {
1753
  clearInterval(interval);
1754
  document.getElementById('exportStatus').textContent = 'βœ… μ™„λ£Œ!';
1755
  document.getElementById('cancelExportBtn').textContent = 'λ‹«κΈ°';
1756
- }
1757
- }, 50);
1758
- };
1759
 
1760
- window.editorCancelExport = function() {
1761
  document.getElementById('exportModal').style.display = 'none';
1762
  document.getElementById('cancelExportBtn').textContent = 'μ·¨μ†Œ';
1763
- };
1764
 
1765
  // ========================================
1766
  // ν‚€λ³΄λ“œ 단좕킀
1767
  // ========================================
1768
- document.addEventListener('keydown', function(e) {
1769
  if (e.target.tagName === 'INPUT') return;
1770
 
1771
- if (e.code === 'Space') {
1772
  e.preventDefault();
1773
  editorTogglePlay();
1774
- } else if (e.code === 'Delete' || e.code === 'Backspace') {
1775
  e.preventDefault();
1776
  editorDelete();
1777
- } else if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey)) {
1778
  e.preventDefault();
1779
  editorDuplicate();
1780
- } else if (e.code === 'KeyZ' && (e.ctrlKey || e.metaKey)) {
1781
  e.preventDefault();
1782
  editorUndo();
1783
- } else if (e.code === 'ArrowLeft') {
1784
  e.preventDefault();
1785
  window.editorState.currentTime = Math.max(0, window.editorState.currentTime - (e.shiftKey ? 1 : 0.1));
1786
  updatePlayhead();
1787
  updatePreview();
1788
- } else if (e.code === 'ArrowRight') {
1789
  e.preventDefault();
1790
  window.editorState.currentTime = Math.min(window.editorState.totalDuration, window.editorState.currentTime + (e.shiftKey ? 1 : 0.1));
1791
  updatePlayhead();
1792
  updatePreview();
1793
- }
1794
- });
1795
 
1796
  // ========================================
1797
- // μ΄ˆκΈ°ν™”
1798
  // ========================================
1799
  renderTimeline();
1800
  updateStatus('쀀비됨');
 
 
 
 
 
 
 
 
 
 
 
1801
  console.log('Video Editor initialized');
1802
  </script>
1803
  </body>
@@ -1806,9 +1825,9 @@ console.log('Video Editor initialized');
1806
 
1807
 
1808
  def process_file(file):
1809
- """μ—…λ‘œλ“œλœ νŒŒμΌμ„ μ²˜λ¦¬ν•˜μ—¬ JavaScript둜 전달"""
1810
  if file is None:
1811
- return ""
1812
 
1813
  results = []
1814
 
@@ -1855,46 +1874,42 @@ def process_file(file):
1855
  def create_interface():
1856
  """Gradio μΈν„°νŽ˜μ΄μŠ€ 생성"""
1857
 
1858
- with gr.Blocks(title="Simple Video Editor") as demo:
 
 
 
1859
 
1860
- gr.Markdown("## 🎬 Simple Video Editor - νŒŒμΌμ„ μ•„λž˜μ—μ„œ μ—…λ‘œλ“œν•˜μ„Έμš”")
1861
 
1862
  with gr.Row():
1863
  file_input = gr.File(
1864
  label="πŸ“ 파일 μ—…λ‘œλ“œ (μ˜μƒ/이미지/μ˜€λ””μ˜€) - μ—¬λŸ¬ 파일 선택 κ°€λŠ₯",
1865
  file_count="multiple",
1866
  file_types=["video", "image", "audio"],
1867
- height=100
 
1868
  )
1869
 
1870
- # 에디터 HTML
1871
- editor = gr.HTML(EDITOR_HTML)
1872
-
1873
- # μˆ¨κ²¨μ§„ 좜λ ₯ (JavaScript μ‹€ν–‰μš©)
1874
- js_output = gr.HTML(visible=False)
1875
 
1876
  def on_file_upload(files):
 
1877
  if not files:
1878
- return ""
1879
 
1880
  results = process_file(files)
1881
  if not results:
1882
- return ""
1883
-
1884
- # JavaScript μ½”λ“œ 생성
1885
- js_code = "<script>"
1886
- for r in results:
1887
- # μ΄μŠ€μΌ€μ΄ν”„ 처리
1888
- name = r['name'].replace("'", "\\'").replace('"', '\\"')
1889
- js_code += f"window.addMediaToEditor('{name}', '{r['type']}', '{r['dataUrl']}');"
1890
- js_code += "</script>"
1891
 
1892
- return js_code
 
 
1893
 
1894
  file_input.change(
1895
  fn=on_file_upload,
1896
  inputs=[file_input],
1897
- outputs=[js_output]
1898
  )
1899
 
1900
  return demo
 
1
  """
2
  Simple Video Editor - ν—ˆκΉ…νŽ˜μ΄μŠ€ 슀페이슀용
3
  CapCut/VEED μŠ€νƒ€μΌ 간단 μ˜μƒ νŽΈμ§‘κΈ° (흰색 ν…Œλ§ˆ)
4
+ Gradio λ„€μ΄ν‹°λΈŒ 파일 μ—…λ‘œλ“œ - JavaScript μ‹€ν–‰ 문제 ν•΄κ²°
5
  """
6
 
7
  import gradio as gr
8
  import base64
9
  import os
10
+ import json
11
 
12
+ def get_editor_html(media_data="[]"):
13
+ """에디터 HTML 생성 - λ―Έλ””μ–΄ 데이터 포함"""
14
+ return f"""
15
  <!DOCTYPE html>
16
  <html lang="ko">
17
  <head>
 
20
  <title>Simple Video Editor</title>
21
  <style>
22
  /* ========== κΈ°λ³Έ 리셋 ========== */
23
+ * {{
24
  margin: 0;
25
  padding: 0;
26
  box-sizing: border-box;
27
+ }}
28
 
29
+ #editor-root {{
30
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif;
31
  background: #f5f5f7;
32
  color: #1d1d1f;
33
  font-size: 13px;
34
+ }}
35
 
36
  /* ========== 에디터 μ»¨ν…Œμ΄λ„ˆ ========== */
37
+ .editor-container {{
38
  display: flex;
39
  flex-direction: column;
40
  height: 750px;
 
42
  border-radius: 12px;
43
  overflow: hidden;
44
  border: 1px solid #e0e0e0;
45
+ }}
46
 
47
  /* ========== 상단 νˆ΄λ°” ========== */
48
+ .toolbar {{
49
  height: 48px;
50
  background: #ffffff;
51
  border-bottom: 1px solid #e0e0e0;
 
54
  justify-content: space-between;
55
  padding: 0 12px;
56
  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
57
+ }}
58
 
59
+ .toolbar-title {{
60
  font-size: 15px;
61
  font-weight: 600;
62
  color: #1d1d1f;
63
  display: flex;
64
  align-items: center;
65
  gap: 6px;
66
+ }}
67
 
68
+ .toolbar-title svg {{
69
  width: 20px;
70
  height: 20px;
71
  color: #6366f1;
72
+ }}
73
 
74
+ .toolbar-actions {{
75
  display: flex;
76
  gap: 8px;
77
+ }}
78
 
79
  /* ========== λ²„νŠΌ μŠ€νƒ€μΌ ========== */
80
+ .btn {{
81
  padding: 6px 12px;
82
  border: none;
83
  border-radius: 6px;
 
88
  align-items: center;
89
  gap: 4px;
90
  transition: all 0.15s;
91
+ }}
92
 
93
+ .btn svg {{
94
  width: 14px;
95
  height: 14px;
96
+ }}
97
 
98
+ .btn-primary {{
99
  background: #6366f1;
100
  color: white;
101
+ }}
102
 
103
+ .btn-primary:hover {{
104
  background: #4f46e5;
105
+ }}
106
 
107
+ .btn-success {{
108
  background: #10b981;
109
  color: white;
110
+ }}
111
 
112
+ .btn-success:hover {{
113
  background: #059669;
114
+ }}
115
 
116
+ .btn-danger {{
117
  background: #ef4444;
118
  color: white;
119
+ }}
120
 
121
+ .btn-danger:hover {{
122
  background: #dc2626;
123
+ }}
124
 
125
+ .btn-secondary {{
126
  background: #f3f4f6;
127
  color: #374151;
128
  border: 1px solid #e5e7eb;
129
+ }}
130
 
131
+ .btn-secondary:hover {{
132
  background: #e5e7eb;
133
+ }}
134
 
135
+ .btn:disabled {{
136
  opacity: 0.5;
137
  cursor: not-allowed;
138
+ }}
139
 
140
  /* ========== 메인 μ˜μ—­ ========== */
141
+ .main-area {{
142
  display: flex;
143
  flex: 1;
144
  overflow: hidden;
145
  background: #f5f5f7;
146
+ }}
147
 
148
  /* ========== λ―Έλ””μ–΄ 라이브러리 ========== */
149
+ .media-library {{
150
  width: 200px;
151
  background: #ffffff;
152
  border-right: 1px solid #e0e0e0;
153
  display: flex;
154
  flex-direction: column;
155
+ }}
156
 
157
+ .library-header {{
158
  padding: 10px 12px;
159
  border-bottom: 1px solid #e0e0e0;
160
  font-size: 11px;
 
162
  color: #6b7280;
163
  text-transform: uppercase;
164
  letter-spacing: 0.5px;
165
+ }}
166
 
167
+ .library-content {{
168
  flex: 1;
169
  overflow-y: auto;
170
  padding: 8px;
171
+ }}
172
 
173
+ .library-hint {{
174
  text-align: center;
175
  padding: 20px 10px;
176
  color: #9ca3af;
177
  font-size: 11px;
178
  line-height: 1.5;
179
+ }}
180
 
181
  /* ========== λ―Έλ””μ–΄ κ·Έλ¦¬λ“œ ========== */
182
+ .media-grid {{
183
  display: grid;
184
  grid-template-columns: repeat(2, 1fr);
185
  gap: 6px;
186
+ }}
187
 
188
+ .media-item {{
189
  aspect-ratio: 16/9;
190
  background: #f3f4f6;
191
  border-radius: 6px;
 
194
  position: relative;
195
  transition: all 0.15s;
196
  border: 1px solid #e5e7eb;
197
+ }}
198
 
199
+ .media-item:hover {{
200
  transform: scale(1.02);
201
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
202
  border-color: #6366f1;
203
+ }}
204
 
205
+ .media-item:active {{
206
  cursor: grabbing;
207
+ }}
208
 
209
  .media-item img,
210
+ .media-item video {{
211
  width: 100%;
212
  height: 100%;
213
  object-fit: cover;
214
+ }}
215
 
216
+ .media-item-overlay {{
217
  position: absolute;
218
  inset: 0;
219
  background: linear-gradient(transparent 40%, rgba(0,0,0,0.7));
 
222
  display: flex;
223
  align-items: flex-end;
224
  padding: 4px;
225
+ }}
226
 
227
+ .media-item:hover .media-item-overlay {{
228
  opacity: 1;
229
+ }}
230
 
231
+ .media-item-name {{
232
  font-size: 9px;
233
  color: white;
234
  white-space: nowrap;
235
  overflow: hidden;
236
  text-overflow: ellipsis;
237
+ }}
238
 
239
+ .media-item-duration {{
240
  position: absolute;
241
  top: 3px;
242
  right: 3px;
 
245
  border-radius: 3px;
246
  font-size: 9px;
247
  color: white;
248
+ }}
249
 
250
+ .media-item-type {{
251
  position: absolute;
252
  top: 3px;
253
  left: 3px;
 
258
  display: flex;
259
  align-items: center;
260
  justify-content: center;
261
+ }}
262
 
263
+ .media-item-type svg {{
264
  width: 10px;
265
  height: 10px;
266
  color: white;
267
+ }}
268
 
269
+ .media-item-icon {{
270
  width: 100%;
271
  height: 100%;
272
  display: flex;
 
275
  font-size: 24px;
276
  color: #9ca3af;
277
  background: #e5e7eb;
278
+ }}
279
 
280
  /* ========== 프리뷰 μ˜μ—­ ========== */
281
+ .preview-area {{
282
  flex: 1;
283
  display: flex;
284
  flex-direction: column;
 
287
  border-radius: 12px;
288
  overflow: hidden;
289
  box-shadow: 0 4px 20px rgba(0,0,0,0.15);
290
+ }}
291
 
292
+ .preview-container {{
293
  flex: 1;
294
  display: flex;
295
  align-items: center;
 
297
  position: relative;
298
  overflow: hidden;
299
  background: #000;
300
+ }}
301
 
302
  .preview-container video,
303
+ .preview-container img {{
304
  max-width: 100%;
305
  max-height: 100%;
306
  object-fit: contain;
307
+ }}
308
 
309
+ .preview-placeholder {{
310
  text-align: center;
311
  color: #666;
312
+ }}
313
 
314
+ .preview-placeholder svg {{
315
  width: 48px;
316
  height: 48px;
317
  margin-bottom: 12px;
318
  opacity: 0.5;
319
+ }}
320
 
321
+ .preview-placeholder p {{
322
  font-size: 12px;
323
+ }}
324
 
325
  /* ========== μž¬μƒ 컨트둀 ========== */
326
+ .playback-controls {{
327
  height: 50px;
328
  background: linear-gradient(180deg, #2a2a2a, #1a1a1a);
329
  display: flex;
 
331
  justify-content: center;
332
  gap: 8px;
333
  padding: 0 16px;
334
+ }}
335
 
336
+ .control-btn {{
337
  width: 32px;
338
  height: 32px;
339
  border: none;
 
345
  align-items: center;
346
  justify-content: center;
347
  transition: all 0.15s;
348
+ }}
349
 
350
+ .control-btn:hover {{
351
  background: rgba(255,255,255,0.2);
352
+ }}
353
 
354
+ .control-btn svg {{
355
  width: 14px;
356
  height: 14px;
357
+ }}
358
 
359
+ .control-btn.play-btn {{
360
  width: 40px;
361
  height: 40px;
362
  background: #6366f1;
363
+ }}
364
 
365
+ .control-btn.play-btn:hover {{
366
  background: #4f46e5;
367
  transform: scale(1.05);
368
+ }}
369
 
370
+ .control-btn.play-btn svg {{
371
  width: 18px;
372
  height: 18px;
373
+ }}
374
 
375
+ .time-display {{
376
  font-family: 'SF Mono', Monaco, monospace;
377
  font-size: 11px;
378
  color: #aaa;
379
  min-width: 110px;
380
  text-align: center;
381
+ }}
382
 
383
  /* ========== 속성 νŒ¨λ„ ========== */
384
+ .properties-panel {{
385
  width: 180px;
386
  background: #ffffff;
387
  border-left: 1px solid #e0e0e0;
388
  display: flex;
389
  flex-direction: column;
390
+ }}
391
 
392
+ .properties-header {{
393
  padding: 10px 12px;
394
  border-bottom: 1px solid #e0e0e0;
395
  font-size: 11px;
 
397
  color: #6b7280;
398
  text-transform: uppercase;
399
  letter-spacing: 0.5px;
400
+ }}
401
 
402
+ .properties-content {{
403
  flex: 1;
404
  padding: 12px;
405
  overflow-y: auto;
406
+ }}
407
 
408
+ .property-group {{
409
  margin-bottom: 14px;
410
+ }}
411
 
412
+ .property-label {{
413
  font-size: 10px;
414
  color: #6b7280;
415
  margin-bottom: 4px;
416
  text-transform: uppercase;
417
  letter-spacing: 0.3px;
418
+ }}
419
 
420
+ .property-input {{
421
  width: 100%;
422
  padding: 6px 8px;
423
  background: #f9fafb;
 
425
  border-radius: 5px;
426
  color: #1d1d1f;
427
  font-size: 12px;
428
+ }}
429
 
430
+ .property-input:focus {{
431
  outline: none;
432
  border-color: #6366f1;
433
  box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
434
+ }}
435
 
436
+ .property-row {{
437
  display: flex;
438
  gap: 8px;
439
+ }}
440
 
441
+ .property-row > div {{
442
  flex: 1;
443
+ }}
444
 
445
+ .no-selection {{
446
  color: #9ca3af;
447
  text-align: center;
448
  padding: 30px 15px;
449
  font-size: 12px;
450
  line-height: 1.5;
451
+ }}
452
 
453
  /* ========== νƒ€μž„λΌμΈ μ˜μ—­ ========== */
454
+ .timeline-area {{
455
  height: 180px;
456
  background: #ffffff;
457
  border-top: 1px solid #e0e0e0;
458
  display: flex;
459
  flex-direction: column;
460
+ }}
461
 
462
+ .timeline-toolbar {{
463
  height: 32px;
464
  background: #fafafa;
465
  border-bottom: 1px solid #e0e0e0;
 
467
  align-items: center;
468
  padding: 0 8px;
469
  gap: 6px;
470
+ }}
471
 
472
+ .timeline-toolbar .btn {{
473
  padding: 4px 8px;
474
  font-size: 11px;
475
+ }}
476
 
477
+ .timeline-toolbar .btn svg {{
478
  width: 12px;
479
  height: 12px;
480
+ }}
481
 
482
+ .timeline-zoom {{
483
  display: flex;
484
  align-items: center;
485
  gap: 4px;
486
  margin-left: auto;
487
  font-size: 11px;
488
  color: #6b7280;
489
+ }}
490
 
491
+ .timeline-zoom input {{
492
  width: 60px;
493
  height: 4px;
494
+ }}
495
 
496
+ .timeline-zoom svg {{
497
  width: 12px;
498
  height: 12px;
499
+ }}
500
 
501
+ .timeline-container {{
502
  flex: 1;
503
  overflow-x: auto;
504
  overflow-y: hidden;
505
  position: relative;
506
  background: #f9fafb;
507
+ }}
508
 
509
  /* ========== νƒ€μž„λΌμΈ 눈금자 ========== */
510
+ .timeline-ruler {{
511
  height: 22px;
512
  background: #fff;
513
  position: sticky;
514
  top: 0;
515
  z-index: 10;
516
  border-bottom: 1px solid #e5e7eb;
517
+ }}
518
 
519
  /* ========== νƒ€μž„λΌμΈ νŠΈλž™ ========== */
520
+ .timeline-tracks {{
521
  position: relative;
522
  min-height: 120px;
523
+ }}
524
 
525
+ .timeline-track {{
526
  height: 55px;
527
  border-bottom: 1px solid #e5e7eb;
528
  position: relative;
529
  display: flex;
530
  align-items: center;
531
  background: #fff;
532
+ }}
533
 
534
+ .timeline-track:nth-child(2) {{
535
  background: #fffbeb;
536
+ }}
537
 
538
+ .track-label {{
539
  width: 70px;
540
  padding: 0 8px;
541
  font-size: 10px;
 
549
  position: sticky;
550
  left: 0;
551
  z-index: 5;
552
+ }}
553
 
554
+ .track-label svg {{
555
  width: 12px;
556
  height: 12px;
557
+ }}
558
 
559
+ .track-content {{
560
  flex: 1;
561
  height: 100%;
562
  position: relative;
563
  min-width: 800px;
564
+ }}
565
 
566
  /* ========== νƒ€μž„λΌμΈ 클립 ========== */
567
+ .timeline-clip {{
568
  position: absolute;
569
  height: 45px;
570
  top: 5px;
 
576
  transition: box-shadow 0.15s;
577
  min-width: 40px;
578
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
579
+ }}
580
 
581
+ .timeline-clip:active {{
582
  cursor: grabbing;
583
+ }}
584
 
585
+ .timeline-clip:hover {{
586
  box-shadow: 0 0 0 2px #6366f1;
587
+ }}
588
 
589
+ .timeline-clip.selected {{
590
  box-shadow: 0 0 0 2px #6366f1, 0 4px 12px rgba(99, 102, 241, 0.3);
591
+ }}
592
 
593
+ .timeline-clip.video {{
594
  background: linear-gradient(135deg, #818cf8, #6366f1);
595
+ }}
596
 
597
+ .timeline-clip.image {{
598
  background: linear-gradient(135deg, #34d399, #10b981);
599
+ }}
600
 
601
+ .timeline-clip.audio {{
602
  background: linear-gradient(135deg, #fbbf24, #f59e0b);
603
+ }}
604
 
605
+ .clip-thumbnail {{
606
  width: 45px;
607
  height: 100%;
608
  object-fit: cover;
609
  flex-shrink: 0;
610
+ }}
611
 
612
+ .clip-info {{
613
  padding: 0 6px;
614
  flex: 1;
615
  overflow: hidden;
616
+ }}
617
 
618
+ .clip-name {{
619
  font-size: 10px;
620
  font-weight: 500;
621
  color: white;
622
  white-space: nowrap;
623
  overflow: hidden;
624
  text-overflow: ellipsis;
625
+ }}
626
 
627
+ .clip-duration {{
628
  font-size: 9px;
629
  color: rgba(255,255,255,0.7);
630
+ }}
631
 
632
  /* ========== 클립 트림 ν•Έλ“€ ========== */
633
+ .clip-handles {{
634
  position: absolute;
635
  top: 0;
636
  bottom: 0;
 
639
  cursor: ew-resize;
640
  opacity: 0;
641
  transition: opacity 0.15s;
642
+ }}
643
 
644
+ .timeline-clip:hover .clip-handles {{
645
  opacity: 1;
646
+ }}
647
 
648
+ .clip-handle-left {{
649
  left: 0;
650
  border-radius: 6px 0 0 6px;
651
+ }}
652
 
653
+ .clip-handle-right {{
654
  right: 0;
655
  border-radius: 0 6px 6px 0;
656
+ }}
657
 
658
  /* ========== ν”Œλ ˆμ΄ν—€λ“œ ========== */
659
+ .playhead {{
660
  position: absolute;
661
  top: 0;
662
  bottom: 0;
 
664
  background: #ef4444;
665
  z-index: 20;
666
  pointer-events: none;
667
+ }}
668
 
669
+ .playhead::before {{
670
  content: '';
671
  position: absolute;
672
  top: 0;
 
675
  height: 12px;
676
  background: #ef4444;
677
  clip-path: polygon(50% 100%, 0 0, 100% 0);
678
+ }}
679
 
680
  /* ========== μŠ€ν¬λ‘€λ°” μŠ€νƒ€μΌ ========== */
681
+ ::-webkit-scrollbar {{
682
  width: 6px;
683
  height: 6px;
684
+ }}
685
 
686
+ ::-webkit-scrollbar-track {{
687
  background: #f1f1f1;
688
+ }}
689
 
690
+ ::-webkit-scrollbar-thumb {{
691
  background: #c1c1c1;
692
  border-radius: 3px;
693
+ }}
694
 
695
+ ::-webkit-scrollbar-thumb:hover {{
696
  background: #a1a1a1;
697
+ }}
698
 
699
  /* ========== λ“œλ‘­μ‘΄ ν•˜μ΄λΌμ΄νŠΈ ========== */
700
+ .drop-highlight {{
701
  background: rgba(99, 102, 241, 0.1) !important;
702
  outline: 2px dashed #6366f1 !important;
703
  outline-offset: -2px;
704
+ }}
705
 
706
  /* ========== λͺ¨λ‹¬ ========== */
707
+ .modal-overlay {{
708
  position: fixed;
709
  inset: 0;
710
  background: rgba(0,0,0,0.5);
 
712
  align-items: center;
713
  justify-content: center;
714
  z-index: 1000;
715
+ }}
716
 
717
+ .modal {{
718
  background: #fff;
719
  border-radius: 12px;
720
  padding: 24px;
721
  min-width: 280px;
722
  text-align: center;
723
  box-shadow: 0 20px 60px rgba(0,0,0,0.3);
724
+ }}
725
 
726
+ .modal h3 {{
727
  margin-bottom: 12px;
728
  font-size: 16px;
729
+ }}
730
 
731
+ .modal p {{
732
  font-size: 13px;
733
  color: #6b7280;
734
+ }}
735
 
736
+ .progress-bar {{
737
  height: 6px;
738
  background: #e5e7eb;
739
  border-radius: 3px;
740
  overflow: hidden;
741
  margin: 16px 0;
742
+ }}
743
 
744
+ .progress-fill {{
745
  height: 100%;
746
  background: linear-gradient(90deg, #6366f1, #8b5cf6);
747
  transition: width 0.3s;
748
+ }}
749
 
750
  /* ========== μ»¨ν…μŠ€νŠΈ 메뉴 ========== */
751
+ .context-menu {{
752
  position: fixed;
753
  background: #fff;
754
  border: 1px solid #e5e7eb;
 
757
  min-width: 140px;
758
  z-index: 1000;
759
  box-shadow: 0 10px 40px rgba(0,0,0,0.15);
760
+ }}
761
 
762
+ .context-menu-item {{
763
  padding: 6px 12px;
764
  cursor: pointer;
765
  display: flex;
766
  align-items: center;
767
  gap: 8px;
768
  font-size: 12px;
769
+ }}
770
 
771
+ .context-menu-item:hover {{
772
  background: #f3f4f6;
773
+ }}
774
 
775
+ .context-menu-item svg {{
776
  width: 12px;
777
  height: 12px;
778
+ }}
779
 
780
+ .context-menu-item.danger {{
781
  color: #ef4444;
782
+ }}
783
 
784
+ .context-menu-divider {{
785
  height: 1px;
786
  background: #e5e7eb;
787
  margin: 4px 0;
788
+ }}
789
 
790
  /* ========== μƒνƒœλ°” ========== */
791
+ .status-bar {{
792
  height: 24px;
793
  background: #f0f0f0;
794
  border-top: 1px solid #e0e0e0;
 
797
  padding: 0 12px;
798
  font-size: 11px;
799
  color: #666;
800
+ }}
801
  </style>
802
  </head>
803
  <body>
 
1022
  // ========================================
1023
  // μ „μ—­ μƒνƒœ λ³€μˆ˜
1024
  // ========================================
1025
+ window.editorState = {{
1026
  mediaLibrary: [],
1027
  timelineClips: [],
1028
  selectedClipId: null,
 
1035
  undoStack: [],
1036
  animationId: null,
1037
  trimData: null
1038
+ }};
1039
 
1040
  // ========================================
1041
  // μœ ν‹Έλ¦¬ν‹° ν•¨μˆ˜
1042
  // ========================================
1043
+ function generateId() {{
1044
  return Date.now().toString(36) + Math.random().toString(36).substr(2);
1045
+ }}
1046
 
1047
+ function formatTime(seconds) {{
1048
  if (!seconds || isNaN(seconds)) seconds = 0;
1049
  var mins = Math.floor(seconds / 60);
1050
  var secs = Math.floor(seconds % 60);
1051
  var ms = Math.floor((seconds % 1) * 100);
1052
  return (mins < 10 ? '0' : '') + mins + ':' + (secs < 10 ? '0' : '') + secs + '.' + (ms < 10 ? '0' : '') + ms;
1053
+ }}
1054
 
1055
+ function updateStatus(msg) {{
1056
  var el = document.getElementById('statusBar');
1057
+ if (el) {{
1058
  var state = window.editorState;
1059
  el.textContent = msg + ' | λ―Έλ””μ–΄: ' + state.mediaLibrary.length + '개 | 클립: ' + state.timelineClips.length + '개';
1060
+ }}
1061
+ }}
1062
 
1063
+ function saveState() {{
1064
  var state = window.editorState;
1065
  state.undoStack.push(JSON.stringify(state.timelineClips));
1066
  if (state.undoStack.length > 50) state.undoStack.shift();
1067
+ }}
1068
 
1069
  // ========================================
1070
+ // λ―Έλ””μ–΄ μΆ”κ°€ ν•¨μˆ˜
1071
  // ========================================
1072
+ function addMediaToEditor(name, type, dataUrl) {{
1073
  console.log('Adding media:', name, type);
1074
 
1075
  var state = window.editorState;
1076
+ var media = {{
1077
  id: generateId(),
1078
  name: name,
1079
  type: type,
1080
  url: dataUrl,
1081
  duration: (type === 'image') ? 5 : 0,
1082
  thumbnail: (type === 'image') ? dataUrl : null
1083
+ }};
1084
 
1085
  state.mediaLibrary.push(media);
1086
 
1087
  // λΉ„λ””μ˜€/μ˜€λ””μ˜€ 메타데이터 λ‘œλ“œ
1088
+ if (type === 'video' || type === 'audio') {{
1089
  var el = document.createElement(type);
1090
  el.src = dataUrl;
1091
  el.preload = 'metadata';
1092
 
1093
+ el.onloadedmetadata = function() {{
1094
  media.duration = el.duration;
1095
  renderMediaLibrary();
1096
 
1097
+ if (type === 'video') {{
1098
  el.currentTime = Math.min(1, el.duration / 2);
1099
+ }}
1100
+ }};
1101
 
1102
+ el.onseeked = function() {{
1103
+ if (type === 'video') {{
1104
+ try {{
1105
  var canvas = document.createElement('canvas');
1106
  canvas.width = 160;
1107
  canvas.height = 90;
1108
  canvas.getContext('2d').drawImage(el, 0, 0, 160, 90);
1109
  media.thumbnail = canvas.toDataURL();
1110
  renderMediaLibrary();
1111
+ }} catch(e) {{
1112
  console.log('Thumbnail failed');
1113
+ }}
1114
+ }}
1115
+ }};
1116
+ }}
1117
 
1118
  renderMediaLibrary();
1119
  updateStatus('λ―Έλ””μ–΄ 좔가됨: ' + name);
1120
+
1121
+ // μžλ™μœΌλ‘œ νƒ€μž„λΌμΈμ— μΆ”κ°€
1122
+ setTimeout(function() {{
1123
+ addToTimeline(media);
1124
+ }}, 100);
1125
+ }}
1126
 
1127
  // ========================================
1128
  // λ―Έλ””μ–΄ 라이브러리 λ Œλ”λ§
1129
  // ========================================
1130
+ function renderMediaLibrary() {{
1131
  var state = window.editorState;
1132
  var grid = document.getElementById('mediaGrid');
1133
  var hint = document.getElementById('libraryHint');
1134
 
1135
  if (!grid) return;
1136
 
1137
+ if (hint) {{
1138
  hint.style.display = state.mediaLibrary.length === 0 ? 'block' : 'none';
1139
+ }}
1140
 
1141
  grid.innerHTML = '';
1142
 
1143
+ var typeIcons = {{
1144
  video: '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21"/></svg>',
1145
  image: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
1146
  audio: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>'
1147
+ }};
1148
 
1149
+ state.mediaLibrary.forEach(function(media) {{
1150
  var item = document.createElement('div');
1151
  item.className = 'media-item';
1152
  item.draggable = true;
1153
  item.setAttribute('data-id', media.id);
1154
 
1155
+ item.ondblclick = function() {{
1156
  addToTimeline(media);
1157
+ }};
1158
 
1159
+ item.ondragstart = function(e) {{
1160
  e.dataTransfer.setData('mediaId', media.id);
1161
  this.style.opacity = '0.5';
1162
+ }};
1163
 
1164
+ item.ondragend = function() {{
1165
  this.style.opacity = '1';
1166
+ }};
1167
 
1168
  var thumbHtml = '';
1169
+ if (media.thumbnail) {{
1170
  thumbHtml = '<img src="' + media.thumbnail + '" alt="' + media.name + '">';
1171
+ }} else {{
1172
  thumbHtml = '<div class="media-item-icon">' + (media.type === 'video' ? '🎬' : media.type === 'audio' ? '🎡' : 'πŸ–ΌοΈ') + '</div>';
1173
+ }}
1174
 
1175
  item.innerHTML = thumbHtml +
1176
  '<div class="media-item-type">' + typeIcons[media.type] + '</div>' +
 
1178
  '<div class="media-item-overlay"><span class="media-item-name">' + media.name + '</span></div>';
1179
 
1180
  grid.appendChild(item);
1181
+ }});
1182
+ }}
1183
 
1184
  // ========================================
1185
  // νƒ€μž„λΌμΈμ— μΆ”κ°€
1186
  // ========================================
1187
+ function addToTimeline(media, startTime) {{
1188
  var state = window.editorState;
1189
  saveState();
1190
 
1191
  var track = (media.type === 'audio') ? 1 : 0;
1192
  var start = (startTime !== undefined) ? startTime : getTrackEndTime(track);
1193
 
1194
+ var clip = {{
1195
  id: generateId(),
1196
  mediaId: media.id,
1197
  name: media.name,
 
1204
  trimStart: 0,
1205
  trimEnd: media.duration,
1206
  volume: 1
1207
+ }};
1208
 
1209
  state.timelineClips.push(clip);
1210
  renderTimeline();
1211
  updateDuration();
1212
  updateStatus('클립 좔가됨: ' + clip.name);
1213
+ }}
1214
 
1215
+ function getTrackEndTime(track) {{
1216
  var state = window.editorState;
1217
  var maxEnd = 0;
1218
+ state.timelineClips.forEach(function(c) {{
1219
+ if (c.track === track) {{
1220
  var end = c.startTime + (c.trimEnd - c.trimStart);
1221
  if (end > maxEnd) maxEnd = end;
1222
+ }}
1223
+ }});
1224
  return maxEnd;
1225
+ }}
1226
 
1227
  // ========================================
1228
  // νƒ€μž„λΌμΈ λ Œλ”λ§
1229
  // ========================================
1230
+ function renderTimeline() {{
1231
  var state = window.editorState;
1232
 
1233
  var track0 = document.getElementById('track0');
 
1235
  if (track0) track0.innerHTML = '';
1236
  if (track1) track1.innerHTML = '';
1237
 
1238
+ state.timelineClips.forEach(function(clip) {{
1239
  var trackEl = document.getElementById('track' + clip.track);
1240
  if (!trackEl) return;
1241
 
 
1251
  clipEl.style.width = width + 'px';
1252
  clipEl.draggable = true;
1253
 
1254
+ clipEl.onclick = function(e) {{
1255
  e.stopPropagation();
1256
  selectClip(clip.id);
1257
+ }};
1258
 
1259
+ clipEl.oncontextmenu = function(e) {{
1260
  e.preventDefault();
1261
  selectClip(clip.id);
1262
  showContextMenu(e.clientX, e.clientY);
1263
+ }};
1264
 
1265
+ clipEl.ondragstart = function(e) {{
1266
  e.dataTransfer.setData('clipId', clip.id);
1267
  e.dataTransfer.setData('offsetX', e.offsetX.toString());
1268
+ }};
1269
 
1270
  var thumbHtml = clip.thumbnail ? '<img class="clip-thumbnail" src="' + clip.thumbnail + '">' : '';
1271
 
1272
  clipEl.innerHTML = thumbHtml +
1273
  '<div class="clip-info"><div class="clip-name">' + clip.name + '</div><div class="clip-duration">' + formatTime(clipDuration) + '</div></div>' +
1274
+ '<div class="clip-handles clip-handle-left" onmousedown="startTrim(event, \\'' + clip.id + '\\', \\'left\\')"></div>' +
1275
+ '<div class="clip-handles clip-handle-right" onmousedown="startTrim(event, \\'' + clip.id + '\\', \\'right\\')"></div>';
1276
 
1277
  trackEl.appendChild(clipEl);
1278
+ }});
1279
 
1280
  renderRuler();
1281
  setupTrackDropZones();
1282
+ }}
1283
 
1284
+ function renderRuler() {{
1285
  var state = window.editorState;
1286
  var ruler = document.getElementById('timelineRuler');
1287
  if (!ruler) return;
 
1292
  var html = '<svg width="100%" height="22" style="position:absolute;left:70px">';
1293
  var step = state.zoom < 0.7 ? 5 : state.zoom < 1.5 ? 2 : 1;
1294
 
1295
+ for (var i = 0; i <= Math.ceil(state.totalDuration) + 10; i += step) {{
1296
  var x = i * state.pixelsPerSecond * state.zoom;
1297
  html += '<line x1="' + x + '" y1="17" x2="' + x + '" y2="22" stroke="#d1d5db" stroke-width="1"/>';
1298
  html += '<text x="' + x + '" y="12" fill="#9ca3af" font-size="9" text-anchor="middle">' + formatTime(i) + '</text>';
1299
+ }}
1300
 
1301
  html += '</svg>';
1302
  ruler.innerHTML = html;
1303
+ }}
1304
 
1305
+ function setupTrackDropZones() {{
1306
  var state = window.editorState;
1307
 
1308
+ ['track0', 'track1'].forEach(function(trackId, trackIdx) {{
1309
  var track = document.getElementById(trackId);
1310
  if (!track) return;
1311
 
1312
+ track.ondragover = function(e) {{
1313
  e.preventDefault();
1314
  track.classList.add('drop-highlight');
1315
+ }};
1316
 
1317
+ track.ondragleave = function() {{
1318
  track.classList.remove('drop-highlight');
1319
+ }};
1320
 
1321
+ track.ondrop = function(e) {{
1322
  e.preventDefault();
1323
  track.classList.remove('drop-highlight');
1324
 
 
1330
  var clipId = e.dataTransfer.getData('clipId');
1331
  var offsetX = parseFloat(e.dataTransfer.getData('offsetX') || 0);
1332
 
1333
+ if (mediaId) {{
1334
+ var media = state.mediaLibrary.find(function(m) {{ return m.id === mediaId; }});
1335
+ if (media) {{
1336
  var targetTrack = (media.type === 'audio') ? 1 : trackIdx;
1337
  addToTimeline(media, time);
1338
+ if (targetTrack !== trackIdx) {{
1339
  state.timelineClips[state.timelineClips.length - 1].track = targetTrack;
1340
  renderTimeline();
1341
+ }}
1342
+ }}
1343
+ }} else if (clipId) {{
1344
  saveState();
1345
+ var clip = state.timelineClips.find(function(c) {{ return c.id === clipId; }});
1346
+ if (clip) {{
1347
  clip.startTime = Math.max(0, time - offsetX / (state.pixelsPerSecond * state.zoom));
1348
  clip.track = (clip.type === 'audio') ? 1 : trackIdx;
1349
  renderTimeline();
1350
  updateDuration();
1351
+ }}
1352
+ }}
1353
+ }};
1354
+ }});
1355
+ }}
1356
 
1357
  // ========================================
1358
  // 클립 선택 및 속성
1359
  // ========================================
1360
+ function selectClip(clipId) {{
1361
  var state = window.editorState;
1362
  state.selectedClipId = clipId;
1363
  renderTimeline();
1364
  renderProperties();
1365
+ }}
1366
 
1367
+ function renderProperties() {{
1368
  var state = window.editorState;
1369
  var container = document.getElementById('propertiesContent');
1370
  if (!container) return;
1371
 
1372
+ var clip = state.timelineClips.find(function(c) {{ return c.id === state.selectedClipId; }});
1373
 
1374
+ if (!clip) {{
1375
  container.innerHTML = '<div class="no-selection">클립을 μ„ νƒν•˜λ©΄<br>속성을 νŽΈμ§‘ν•  수 μžˆμŠ΅λ‹ˆλ‹€</div>';
1376
  return;
1377
+ }}
1378
 
1379
  var clipDuration = clip.trimEnd - clip.trimStart;
1380
 
1381
  var html = '<div class="property-group">' +
1382
  '<div class="property-label">이름</div>' +
1383
+ '<input type="text" class="property-input" value="' + clip.name + '" onchange="updateClipProp(\\'name\\', this.value)">' +
1384
  '</div>' +
1385
  '<div class="property-group">' +
1386
  '<div class="property-label">μ‹œμž‘ μ‹œκ°„</div>' +
1387
+ '<input type="number" class="property-input" value="' + clip.startTime.toFixed(2) + '" step="0.1" min="0" onchange="updateClipProp(\\'startTime\\', parseFloat(this.value))">' +
1388
  '</div>' +
1389
  '<div class="property-group">' +
1390
  '<div class="property-row">' +
1391
+ '<div><div class="property-label">트림 μ‹œμž‘</div><input type="number" class="property-input" value="' + clip.trimStart.toFixed(2) + '" step="0.1" min="0" max="' + clip.duration + '" onchange="updateClipProp(\\'trimStart\\', parseFloat(this.value))"></div>' +
1392
+ '<div><div class="property-label">트림 끝</div><input type="number" class="property-input" value="' + clip.trimEnd.toFixed(2) + '" step="0.1" min="0" max="' + clip.duration + '" onchange="updateClipProp(\\'trimEnd\\', parseFloat(this.value))"></div>' +
1393
  '</div></div>' +
1394
  '<div class="property-group"><div class="property-label">길이</div><div style="padding:6px 0;font-size:12px;color:#374151">' + formatTime(clipDuration) + '</div></div>';
1395
 
1396
+ if (clip.type !== 'image') {{
1397
  html += '<div class="property-group">' +
1398
  '<div class="property-label">λ³Όλ₯¨ (' + Math.round(clip.volume * 100) + '%)</div>' +
1399
+ '<input type="range" class="property-input" style="padding:0" min="0" max="1" step="0.05" value="' + clip.volume + '" oninput="updateClipProp(\\'volume\\', parseFloat(this.value))">' +
1400
  '</div>';
1401
+ }}
1402
 
1403
  container.innerHTML = html;
1404
+ }}
1405
 
1406
+ function updateClipProp(prop, value) {{
1407
  var state = window.editorState;
1408
  saveState();
1409
+ var clip = state.timelineClips.find(function(c) {{ return c.id === state.selectedClipId; }});
1410
+ if (clip) {{
1411
  clip[prop] = value;
1412
  renderTimeline();
1413
  updateDuration();
1414
  renderProperties();
1415
+ }}
1416
+ }}
1417
 
1418
  // ========================================
1419
  // 트림 ν•Έλ“€
1420
  // ========================================
1421
+ window.startTrim = function(event, clipId, side) {{
1422
  event.stopPropagation();
1423
  event.preventDefault();
1424
 
1425
  var state = window.editorState;
1426
+ var clip = state.timelineClips.find(function(c) {{ return c.id === clipId; }});
1427
  if (!clip) return;
1428
 
1429
  saveState();
1430
 
1431
+ state.trimData = {{
1432
  clipId: clipId,
1433
  side: side,
1434
  startX: event.clientX,
1435
  originalTrimStart: clip.trimStart,
1436
  originalTrimEnd: clip.trimEnd,
1437
  originalStartTime: clip.startTime
1438
+ }};
1439
 
1440
  document.addEventListener('mousemove', handleTrim);
1441
  document.addEventListener('mouseup', endTrim);
1442
+ }};
1443
 
1444
+ function handleTrim(event) {{
1445
  var state = window.editorState;
1446
  if (!state.trimData) return;
1447
 
1448
+ var clip = state.timelineClips.find(function(c) {{ return c.id === state.trimData.clipId; }});
1449
  if (!clip) return;
1450
 
1451
  var deltaX = event.clientX - state.trimData.startX;
1452
  var deltaTime = deltaX / (state.pixelsPerSecond * state.zoom);
1453
 
1454
+ if (state.trimData.side === 'left') {{
1455
  var newTrimStart = Math.max(0, Math.min(clip.trimEnd - 0.1, state.trimData.originalTrimStart + deltaTime));
1456
  var trimDelta = newTrimStart - state.trimData.originalTrimStart;
1457
  clip.trimStart = newTrimStart;
1458
  clip.startTime = state.trimData.originalStartTime + trimDelta;
1459
+ }} else {{
1460
  clip.trimEnd = Math.max(clip.trimStart + 0.1, Math.min(clip.duration, state.trimData.originalTrimEnd + deltaTime));
1461
+ }}
1462
 
1463
  renderTimeline();
1464
  updateDuration();
1465
+ }}
1466
 
1467
+ function endTrim() {{
1468
  var state = window.editorState;
1469
  state.trimData = null;
1470
  document.removeEventListener('mousemove', handleTrim);
1471
  document.removeEventListener('mouseup', endTrim);
1472
+ }}
1473
 
1474
  // ========================================
1475
  // νŽΈμ§‘ κΈ°λŠ₯
1476
  // ========================================
1477
+ window.editorSplit = function() {{
1478
  var state = window.editorState;
1479
  if (!state.selectedClipId) return;
1480
 
1481
  var idx = -1;
1482
  var clip = null;
1483
+ for (var i = 0; i < state.timelineClips.length; i++) {{
1484
+ if (state.timelineClips[i].id === state.selectedClipId) {{
1485
  idx = i;
1486
  clip = state.timelineClips[i];
1487
  break;
1488
+ }}
1489
+ }}
1490
  if (!clip) return;
1491
 
1492
  saveState();
 
1505
  renderTimeline();
1506
  hideContextMenu();
1507
  updateStatus('클립 뢄할됨');
1508
+ }};
1509
 
1510
+ window.editorDuplicate = function() {{
1511
  var state = window.editorState;
1512
  if (!state.selectedClipId) return;
1513
 
1514
+ var clip = state.timelineClips.find(function(c) {{ return c.id === state.selectedClipId; }});
1515
  if (!clip) return;
1516
 
1517
  saveState();
 
1526
  updateDuration();
1527
  hideContextMenu();
1528
  updateStatus('클립 볡제됨');
1529
+ }};
1530
 
1531
+ window.editorDelete = function() {{
1532
  var state = window.editorState;
1533
  if (!state.selectedClipId) return;
1534
 
1535
  saveState();
1536
+ state.timelineClips = state.timelineClips.filter(function(c) {{ return c.id !== state.selectedClipId; }});
1537
  state.selectedClipId = null;
1538
 
1539
  renderTimeline();
 
1541
  updateDuration();
1542
  hideContextMenu();
1543
  updateStatus('클립 μ‚­μ œλ¨');
1544
+ }};
1545
 
1546
+ window.editorUndo = function() {{
1547
  var state = window.editorState;
1548
+ if (state.undoStack.length > 0) {{
1549
  state.timelineClips = JSON.parse(state.undoStack.pop());
1550
  renderTimeline();
1551
  updateDuration();
1552
  updateStatus('μ‹€ν–‰μ·¨μ†Œ');
1553
+ }}
1554
+ }};
1555
 
1556
  // ========================================
1557
  // μž¬μƒ 컨트둀
1558
  // ========================================
1559
+ function updateDuration() {{
1560
  var state = window.editorState;
1561
  var maxEnd = 0;
1562
+ state.timelineClips.forEach(function(c) {{
1563
  var end = c.startTime + (c.trimEnd - c.trimStart);
1564
  if (end > maxEnd) maxEnd = end;
1565
+ }});
1566
  state.totalDuration = maxEnd;
1567
  document.getElementById('durationDisplay').textContent = formatTime(maxEnd);
1568
+ }}
1569
 
1570
+ window.editorTogglePlay = function() {{
1571
  var state = window.editorState;
1572
  state.isPlaying = !state.isPlaying;
1573
  var icon = document.getElementById('playIcon');
1574
 
1575
+ if (state.isPlaying) {{
1576
  icon.innerHTML = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';
1577
  startPlayback();
1578
+ }} else {{
1579
  icon.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
1580
  stopPlayback();
1581
+ }}
1582
+ }};
1583
 
1584
+ function startPlayback() {{
1585
  var state = window.editorState;
1586
  var lastTime = performance.now();
1587
 
1588
+ function animate(now) {{
1589
  if (!state.isPlaying) return;
1590
 
1591
  var delta = (now - lastTime) / 1000;
1592
  lastTime = now;
1593
  state.currentTime += delta;
1594
 
1595
+ if (state.currentTime >= state.totalDuration) {{
1596
  state.currentTime = 0;
1597
+ if (state.totalDuration === 0) {{
1598
  state.isPlaying = false;
1599
  document.getElementById('playIcon').innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>';
1600
  return;
1601
+ }}
1602
+ }}
1603
 
1604
  updatePlayhead();
1605
  updatePreview();
1606
  state.animationId = requestAnimationFrame(animate);
1607
+ }}
1608
 
1609
  state.animationId = requestAnimationFrame(animate);
1610
+ }}
1611
 
1612
+ function stopPlayback() {{
1613
  var state = window.editorState;
1614
+ if (state.animationId) {{
1615
  cancelAnimationFrame(state.animationId);
1616
  state.animationId = null;
1617
+ }}
1618
  var video = document.querySelector('#previewContainer video');
1619
  if (video && !video.paused) video.pause();
1620
+ }}
1621
 
1622
+ function updatePlayhead() {{
1623
  var state = window.editorState;
1624
  var playhead = document.getElementById('playhead');
1625
+ if (playhead) {{
1626
  playhead.style.left = (70 + state.currentTime * state.pixelsPerSecond * state.zoom) + 'px';
1627
+ }}
1628
  document.getElementById('currentTimeDisplay').textContent = formatTime(state.currentTime);
1629
+ }}
1630
 
1631
+ function updatePreview() {{
1632
  var state = window.editorState;
1633
  var container = document.getElementById('previewContainer');
1634
  if (!container) return;
1635
 
1636
+ var currentClips = state.timelineClips.filter(function(c) {{
1637
  var clipEnd = c.startTime + (c.trimEnd - c.trimStart);
1638
  return state.currentTime >= c.startTime && state.currentTime < clipEnd;
1639
+ }});
1640
 
1641
+ var visualClip = currentClips.find(function(c) {{ return c.type === 'video' || c.type === 'image'; }});
1642
 
1643
+ if (visualClip) {{
1644
  var clipTime = state.currentTime - visualClip.startTime + visualClip.trimStart;
1645
 
1646
+ if (visualClip.type === 'image') {{
1647
+ if (!container.querySelector('img[data-clip-id="' + visualClip.id + '"]')) {{
1648
  container.innerHTML = '<img src="' + visualClip.url + '" data-clip-id="' + visualClip.id + '">';
1649
+ }}
1650
+ }} else if (visualClip.type === 'video') {{
1651
  var video = container.querySelector('video[data-clip-id="' + visualClip.id + '"]');
1652
+ if (!video) {{
1653
  container.innerHTML = '<video src="' + visualClip.url + '" data-clip-id="' + visualClip.id + '"' + (state.isMuted ? ' muted' : '') + '></video>';
1654
  video = container.querySelector('video');
1655
+ }}
1656
 
1657
+ if (Math.abs(video.currentTime - clipTime) > 0.2) {{
1658
  video.currentTime = clipTime;
1659
+ }}
1660
 
1661
+ if (state.isPlaying && video.paused) {{
1662
+ video.play().catch(function(){{}});
1663
+ }} else if (!state.isPlaying && !video.paused) {{
1664
  video.pause();
1665
+ }}
1666
 
1667
  video.volume = state.isMuted ? 0 : visualClip.volume;
1668
  video.muted = state.isMuted;
1669
+ }}
1670
+ }} else {{
1671
+ var hasAudio = currentClips.some(function(c) {{ return c.type === 'audio'; }});
1672
+ if (!container.querySelector('.preview-placeholder')) {{
1673
  container.innerHTML = '<div class="preview-placeholder">' +
1674
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="2" y="2" width="20" height="20" rx="2"/><polygon points="10 8 16 12 10 16 10 8"/></svg>' +
1675
  '<p>' + (hasAudio ? '🎡 μ˜€λ””μ˜€ μž¬μƒ 쀑' : 'νƒ€μž„λΌμΈμ— λ―Έλ””μ–΄λ₯Ό μΆ”κ°€ν•˜μ„Έμš”') + '</p></div>';
1676
+ }}
1677
+ }}
1678
+ }}
1679
 
1680
+ window.editorSkipStart = function() {{ window.editorState.currentTime = 0; updatePlayhead(); updatePreview(); }};
1681
+ window.editorSkipEnd = function() {{ window.editorState.currentTime = window.editorState.totalDuration; updatePlayhead(); updatePreview(); }};
1682
+ window.editorSkipBack = function() {{ window.editorState.currentTime = Math.max(0, window.editorState.currentTime - 5); updatePlayhead(); updatePreview(); }};
1683
+ window.editorSkipForward = function() {{ window.editorState.currentTime = Math.min(window.editorState.totalDuration, window.editorState.currentTime + 5); updatePlayhead(); updatePreview(); }};
1684
 
1685
+ window.editorToggleMute = function() {{
1686
  var state = window.editorState;
1687
  state.isMuted = !state.isMuted;
1688
  var icon = document.getElementById('volumeIcon');
1689
 
1690
+ if (state.isMuted) {{
1691
  icon.innerHTML = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>';
1692
+ }} else {{
1693
  icon.innerHTML = '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/>';
1694
+ }}
1695
 
1696
  var video = document.querySelector('#previewContainer video');
1697
  if (video) video.muted = state.isMuted;
1698
+ }};
1699
 
1700
+ window.editorSetZoom = function(value) {{
1701
  window.editorState.zoom = parseFloat(value);
1702
  renderTimeline();
1703
  updatePlayhead();
1704
+ }};
1705
 
1706
+ window.editorTimelineClick = function(e) {{
1707
  if (e.target.closest('.timeline-clip')) return;
1708
  var state = window.editorState;
1709
  var container = document.getElementById('timelineContainer');
 
1712
  state.currentTime = Math.max(0, Math.min(state.totalDuration, x / (state.pixelsPerSecond * state.zoom)));
1713
  updatePlayhead();
1714
  updatePreview();
1715
+ }};
1716
 
1717
  // ========================================
1718
  // μ»¨ν…μŠ€νŠΈ 메뉴
1719
  // ========================================
1720
+ function showContextMenu(x, y) {{
1721
  var menu = document.getElementById('contextMenu');
1722
  menu.style.display = 'block';
1723
  menu.style.left = x + 'px';
1724
  menu.style.top = y + 'px';
1725
+ }}
1726
 
1727
+ function hideContextMenu() {{
1728
  document.getElementById('contextMenu').style.display = 'none';
1729
+ }}
1730
 
1731
+ document.addEventListener('click', function(e) {{
1732
+ if (!e.target.closest('.context-menu')) {{
1733
  hideContextMenu();
1734
+ }}
1735
+ }});
1736
 
1737
  // ========================================
1738
  // 내보내기
1739
  // ========================================
1740
+ window.editorExport = function() {{
1741
  var state = window.editorState;
1742
+ if (state.timelineClips.length === 0) {{
1743
  alert('νƒ€μž„λΌμΈμ— 클립을 μΆ”κ°€ν•΄μ£Όμ„Έμš”.');
1744
  return;
1745
+ }}
1746
 
1747
  document.getElementById('exportModal').style.display = 'flex';
1748
  document.getElementById('exportProgress').style.width = '0%';
1749
  document.getElementById('exportStatus').textContent = 'μ˜μƒμ„ μ²˜λ¦¬ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€...';
1750
 
1751
  var progress = 0;
1752
+ var interval = setInterval(function() {{
1753
  progress += 2;
1754
  document.getElementById('exportProgress').style.width = progress + '%';
1755
 
 
1757
  if (progress === 60) document.getElementById('exportStatus').textContent = 'μ˜€λ””μ˜€ 처리 쀑...';
1758
  if (progress === 90) document.getElementById('exportStatus').textContent = 'μ΅œμ’… λ Œλ”λ§ 쀑...';
1759
 
1760
+ if (progress >= 100) {{
1761
  clearInterval(interval);
1762
  document.getElementById('exportStatus').textContent = 'βœ… μ™„λ£Œ!';
1763
  document.getElementById('cancelExportBtn').textContent = 'λ‹«κΈ°';
1764
+ }}
1765
+ }}, 50);
1766
+ }};
1767
 
1768
+ window.editorCancelExport = function() {{
1769
  document.getElementById('exportModal').style.display = 'none';
1770
  document.getElementById('cancelExportBtn').textContent = 'μ·¨μ†Œ';
1771
+ }};
1772
 
1773
  // ========================================
1774
  // ν‚€λ³΄λ“œ 단좕킀
1775
  // ========================================
1776
+ document.addEventListener('keydown', function(e) {{
1777
  if (e.target.tagName === 'INPUT') return;
1778
 
1779
+ if (e.code === 'Space') {{
1780
  e.preventDefault();
1781
  editorTogglePlay();
1782
+ }} else if (e.code === 'Delete' || e.code === 'Backspace') {{
1783
  e.preventDefault();
1784
  editorDelete();
1785
+ }} else if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey)) {{
1786
  e.preventDefault();
1787
  editorDuplicate();
1788
+ }} else if (e.code === 'KeyZ' && (e.ctrlKey || e.metaKey)) {{
1789
  e.preventDefault();
1790
  editorUndo();
1791
+ }} else if (e.code === 'ArrowLeft') {{
1792
  e.preventDefault();
1793
  window.editorState.currentTime = Math.max(0, window.editorState.currentTime - (e.shiftKey ? 1 : 0.1));
1794
  updatePlayhead();
1795
  updatePreview();
1796
+ }} else if (e.code === 'ArrowRight') {{
1797
  e.preventDefault();
1798
  window.editorState.currentTime = Math.min(window.editorState.totalDuration, window.editorState.currentTime + (e.shiftKey ? 1 : 0.1));
1799
  updatePlayhead();
1800
  updatePreview();
1801
+ }}
1802
+ }});
1803
 
1804
  // ========================================
1805
+ // μ΄ˆκΈ°ν™” - μ—…λ‘œλ“œλœ λ―Έλ””μ–΄ λ‘œλ“œ
1806
  // ========================================
1807
  renderTimeline();
1808
  updateStatus('쀀비됨');
1809
+
1810
+ // μ—…λ‘œλ“œλœ λ―Έλ””μ–΄ 데이터 λ‘œλ“œ
1811
+ var mediaData = {media_data};
1812
+ console.log('Loading media data:', mediaData);
1813
+
1814
+ if (mediaData && mediaData.length > 0) {{
1815
+ mediaData.forEach(function(item) {{
1816
+ addMediaToEditor(item.name, item.type, item.dataUrl);
1817
+ }});
1818
+ }}
1819
+
1820
  console.log('Video Editor initialized');
1821
  </script>
1822
  </body>
 
1825
 
1826
 
1827
  def process_file(file):
1828
+ """μ—…λ‘œλ“œλœ νŒŒμΌμ„ μ²˜λ¦¬ν•˜μ—¬ λ―Έλ””μ–΄ 데이터 생성"""
1829
  if file is None:
1830
+ return []
1831
 
1832
  results = []
1833
 
 
1874
  def create_interface():
1875
  """Gradio μΈν„°νŽ˜μ΄μŠ€ 생성"""
1876
 
1877
+ with gr.Blocks(title="Simple Video Editor", css="""
1878
+ .file-upload { margin-bottom: 10px; }
1879
+ #editor-frame { border: none; }
1880
+ """) as demo:
1881
 
1882
+ gr.Markdown("## 🎬 Simple Video Editor - νŒŒμΌμ„ μ—…λ‘œλ“œν•˜λ©΄ μžλ™μœΌλ‘œ νƒ€μž„λΌμΈμ— μΆ”κ°€λ©λ‹ˆλ‹€")
1883
 
1884
  with gr.Row():
1885
  file_input = gr.File(
1886
  label="πŸ“ 파일 μ—…λ‘œλ“œ (μ˜μƒ/이미지/μ˜€λ””μ˜€) - μ—¬λŸ¬ 파일 선택 κ°€λŠ₯",
1887
  file_count="multiple",
1888
  file_types=["video", "image", "audio"],
1889
+ height=80,
1890
+ elem_classes=["file-upload"]
1891
  )
1892
 
1893
+ # 에디터 HTML - 초기 μƒνƒœ
1894
+ editor = gr.HTML(get_editor_html("[]"), elem_id="editor-frame")
 
 
 
1895
 
1896
  def on_file_upload(files):
1897
+ """파일 μ—…λ‘œλ“œ μ‹œ 에디터 HTML을 λ―Έλ””μ–΄ 데이터와 ν•¨κ»˜ μž¬μƒμ„±"""
1898
  if not files:
1899
+ return get_editor_html("[]")
1900
 
1901
  results = process_file(files)
1902
  if not results:
1903
+ return get_editor_html("[]")
 
 
 
 
 
 
 
 
1904
 
1905
+ # JSON으둜 λ³€ν™˜ν•˜μ—¬ HTML에 포함
1906
+ media_json = json.dumps(results, ensure_ascii=False)
1907
+ return get_editor_html(media_json)
1908
 
1909
  file_input.change(
1910
  fn=on_file_upload,
1911
  inputs=[file_input],
1912
+ outputs=[editor]
1913
  )
1914
 
1915
  return demo