seawolf2357 commited on
Commit
86017ce
Β·
verified Β·
1 Parent(s): efdb22c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +396 -521
app.py CHANGED
@@ -1,552 +1,421 @@
1
  """
2
  Simple Video Editor - ν—ˆκΉ…νŽ˜μ΄μŠ€ 슀페이슀용
3
- CapCut/VEED μŠ€νƒ€μΌ 간단 μ˜μƒ νŽΈμ§‘κΈ° (흰색 ν…Œλ§ˆ)
4
- iframe + base64 λ°©μ‹μœΌλ‘œ 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"""<!DOCTYPE html>
15
  <html lang="ko">
16
  <head>
17
- <meta charset="UTF-8">
18
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
19
- <title>Simple Video Editor</title>
20
- <style>
21
- * {{ margin: 0; padding: 0; box-sizing: border-box; }}
22
- body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', sans-serif; background: #f5f5f7; color: #1d1d1f; font-size: 13px; }}
23
- .editor-container {{ display: flex; flex-direction: column; height: 100vh; background: #f5f5f7; overflow: hidden; }}
24
- .toolbar {{ height: 48px; background: #ffffff; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }}
25
- .toolbar-title {{ font-size: 15px; font-weight: 600; color: #1d1d1f; display: flex; align-items: center; gap: 6px; }}
26
- .toolbar-title svg {{ width: 20px; height: 20px; color: #6366f1; }}
27
- .toolbar-actions {{ display: flex; gap: 8px; }}
28
- .btn {{ padding: 6px 12px; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 4px; transition: all 0.15s; }}
29
- .btn svg {{ width: 14px; height: 14px; }}
30
- .btn-primary {{ background: #6366f1; color: white; }}
31
- .btn-primary:hover {{ background: #4f46e5; }}
32
- .btn-success {{ background: #10b981; color: white; }}
33
- .btn-success:hover {{ background: #059669; }}
34
- .btn-danger {{ background: #ef4444; color: white; }}
35
- .btn-danger:hover {{ background: #dc2626; }}
36
- .btn-secondary {{ background: #f3f4f6; color: #374151; border: 1px solid #e5e7eb; }}
37
- .btn-secondary:hover {{ background: #e5e7eb; }}
38
- .btn:disabled {{ opacity: 0.5; cursor: not-allowed; }}
39
- .main-area {{ display: flex; flex: 1; overflow: hidden; background: #f5f5f7; }}
40
- .media-library {{ width: 200px; background: #ffffff; border-right: 1px solid #e0e0e0; display: flex; flex-direction: column; }}
41
- .library-header {{ padding: 10px 12px; border-bottom: 1px solid #e0e0e0; font-size: 11px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }}
42
- .library-content {{ flex: 1; overflow-y: auto; padding: 8px; }}
43
- .library-hint {{ text-align: center; padding: 20px 10px; color: #9ca3af; font-size: 11px; line-height: 1.5; }}
44
- .media-grid {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; }}
45
- .media-item {{ aspect-ratio: 16/9; background: #f3f4f6; border-radius: 6px; overflow: hidden; cursor: grab; position: relative; transition: all 0.15s; border: 1px solid #e5e7eb; }}
46
- .media-item:hover {{ transform: scale(1.02); box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-color: #6366f1; }}
47
- .media-item:active {{ cursor: grabbing; }}
48
- .media-item img, .media-item video {{ width: 100%; height: 100%; object-fit: cover; }}
49
- .media-item-overlay {{ position: absolute; inset: 0; background: linear-gradient(transparent 40%, rgba(0,0,0,0.7)); opacity: 0; transition: opacity 0.15s; display: flex; align-items: flex-end; padding: 4px; }}
50
- .media-item:hover .media-item-overlay {{ opacity: 1; }}
51
- .media-item-name {{ font-size: 9px; color: white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
52
- .media-item-duration {{ position: absolute; top: 3px; right: 3px; background: rgba(0,0,0,0.6); padding: 1px 4px; border-radius: 3px; font-size: 9px; color: white; }}
53
- .media-item-type {{ position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; background: rgba(0,0,0,0.6); border-radius: 50%; display: flex; align-items: center; justify-content: center; }}
54
- .media-item-type svg {{ width: 10px; height: 10px; color: white; }}
55
- .media-item-icon {{ width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 24px; color: #9ca3af; background: #e5e7eb; }}
56
- .preview-area {{ flex: 1; display: flex; flex-direction: column; background: #1a1a1a; margin: 8px; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.15); }}
57
- .preview-container {{ flex: 1; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; background: #000; }}
58
- .preview-container video, .preview-container img {{ max-width: 100%; max-height: 100%; object-fit: contain; }}
59
- .preview-placeholder {{ text-align: center; color: #666; }}
60
- .preview-placeholder svg {{ width: 48px; height: 48px; margin-bottom: 12px; opacity: 0.5; }}
61
- .preview-placeholder p {{ font-size: 12px; }}
62
- .playback-controls {{ height: 50px; background: linear-gradient(180deg, #2a2a2a, #1a1a1a); display: flex; align-items: center; justify-content: center; gap: 8px; padding: 0 16px; }}
63
- .control-btn {{ width: 32px; height: 32px; border: none; border-radius: 50%; background: rgba(255,255,255,0.1); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; }}
64
- .control-btn:hover {{ background: rgba(255,255,255,0.2); }}
65
- .control-btn svg {{ width: 14px; height: 14px; }}
66
- .control-btn.play-btn {{ width: 40px; height: 40px; background: #6366f1; }}
67
- .control-btn.play-btn:hover {{ background: #4f46e5; transform: scale(1.05); }}
68
- .control-btn.play-btn svg {{ width: 18px; height: 18px; }}
69
- .time-display {{ font-family: 'SF Mono', Monaco, monospace; font-size: 11px; color: #aaa; min-width: 110px; text-align: center; }}
70
- .properties-panel {{ width: 180px; background: #ffffff; border-left: 1px solid #e0e0e0; display: flex; flex-direction: column; }}
71
- .properties-header {{ padding: 10px 12px; border-bottom: 1px solid #e0e0e0; font-size: 11px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }}
72
- .properties-content {{ flex: 1; padding: 12px; overflow-y: auto; }}
73
- .property-group {{ margin-bottom: 14px; }}
74
- .property-label {{ font-size: 10px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.3px; }}
75
- .property-input {{ width: 100%; padding: 6px 8px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 5px; color: #1d1d1f; font-size: 12px; }}
76
- .property-input:focus {{ outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1); }}
77
- .property-row {{ display: flex; gap: 8px; }}
78
- .property-row > div {{ flex: 1; }}
79
- .no-selection {{ color: #9ca3af; text-align: center; padding: 30px 15px; font-size: 12px; line-height: 1.5; }}
80
- .timeline-area {{ height: 180px; background: #ffffff; border-top: 1px solid #e0e0e0; display: flex; flex-direction: column; }}
81
- .timeline-toolbar {{ height: 32px; background: #fafafa; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; padding: 0 8px; gap: 6px; }}
82
- .timeline-toolbar .btn {{ padding: 4px 8px; font-size: 11px; }}
83
- .timeline-toolbar .btn svg {{ width: 12px; height: 12px; }}
84
- .timeline-zoom {{ display: flex; align-items: center; gap: 4px; margin-left: auto; font-size: 11px; color: #6b7280; }}
85
- .timeline-zoom input {{ width: 60px; height: 4px; }}
86
- .timeline-zoom svg {{ width: 12px; height: 12px; }}
87
- .timeline-container {{ flex: 1; overflow-x: auto; overflow-y: hidden; position: relative; background: #f9fafb; }}
88
- .timeline-ruler {{ height: 22px; background: #fff; position: sticky; top: 0; z-index: 10; border-bottom: 1px solid #e5e7eb; }}
89
- .timeline-tracks {{ position: relative; min-height: 120px; }}
90
- .timeline-track {{ height: 55px; border-bottom: 1px solid #e5e7eb; position: relative; display: flex; align-items: center; background: #fff; }}
91
- .timeline-track:nth-child(2) {{ background: #fffbeb; }}
92
- .track-label {{ width: 70px; padding: 0 8px; font-size: 10px; color: #6b7280; background: #f9fafb; height: 100%; display: flex; align-items: center; gap: 4px; border-right: 1px solid #e5e7eb; position: sticky; left: 0; z-index: 5; }}
93
- .track-label svg {{ width: 12px; height: 12px; }}
94
- .track-content {{ flex: 1; height: 100%; position: relative; min-width: 800px; }}
95
- .timeline-clip {{ position: absolute; height: 45px; top: 5px; border-radius: 6px; cursor: grab; display: flex; align-items: center; overflow: hidden; transition: box-shadow 0.15s; min-width: 40px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
96
- .timeline-clip:active {{ cursor: grabbing; }}
97
- .timeline-clip:hover {{ box-shadow: 0 0 0 2px #6366f1; }}
98
- .timeline-clip.selected {{ box-shadow: 0 0 0 2px #6366f1, 0 4px 12px rgba(99, 102, 241, 0.3); }}
99
- .timeline-clip.video {{ background: linear-gradient(135deg, #818cf8, #6366f1); }}
100
- .timeline-clip.image {{ background: linear-gradient(135deg, #34d399, #10b981); }}
101
- .timeline-clip.audio {{ background: linear-gradient(135deg, #fbbf24, #f59e0b); }}
102
- .clip-thumbnail {{ width: 45px; height: 100%; object-fit: cover; flex-shrink: 0; }}
103
- .clip-info {{ padding: 0 6px; flex: 1; overflow: hidden; }}
104
- .clip-name {{ font-size: 10px; font-weight: 500; color: white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
105
- .clip-duration {{ font-size: 9px; color: rgba(255,255,255,0.7); }}
106
- .clip-handles {{ position: absolute; top: 0; bottom: 0; width: 6px; background: rgba(255,255,255,0.4); cursor: ew-resize; opacity: 0; transition: opacity 0.15s; }}
107
- .timeline-clip:hover .clip-handles {{ opacity: 1; }}
108
- .clip-handle-left {{ left: 0; border-radius: 6px 0 0 6px; }}
109
- .clip-handle-right {{ right: 0; border-radius: 0 6px 6px 0; }}
110
- .playhead {{ position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 20; pointer-events: none; }}
111
- .playhead::before {{ content: ''; position: absolute; top: 0; left: -5px; width: 12px; height: 12px; background: #ef4444; clip-path: polygon(50% 100%, 0 0, 100% 0); }}
112
- ::-webkit-scrollbar {{ width: 6px; height: 6px; }}
113
- ::-webkit-scrollbar-track {{ background: #f1f1f1; }}
114
- ::-webkit-scrollbar-thumb {{ background: #c1c1c1; border-radius: 3px; }}
115
- ::-webkit-scrollbar-thumb:hover {{ background: #a1a1a1; }}
116
- .drop-highlight {{ background: rgba(99, 102, 241, 0.1) !important; outline: 2px dashed #6366f1 !important; outline-offset: -2px; }}
117
- .modal-overlay {{ position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }}
118
- .modal {{ background: #fff; border-radius: 12px; padding: 24px; min-width: 280px; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }}
119
- .modal h3 {{ margin-bottom: 12px; font-size: 16px; }}
120
- .modal p {{ font-size: 13px; color: #6b7280; }}
121
- .progress-bar {{ height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; margin: 16px 0; }}
122
- .progress-fill {{ height: 100%; background: linear-gradient(90deg, #6366f1, #8b5cf6); transition: width 0.3s; }}
123
- .context-menu {{ position: fixed; background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 4px 0; min-width: 140px; z-index: 1000; box-shadow: 0 10px 40px rgba(0,0,0,0.15); }}
124
- .context-menu-item {{ padding: 6px 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 12px; }}
125
- .context-menu-item:hover {{ background: #f3f4f6; }}
126
- .context-menu-item svg {{ width: 12px; height: 12px; }}
127
- .context-menu-item.danger {{ color: #ef4444; }}
128
- .context-menu-divider {{ height: 1px; background: #e5e7eb; margin: 4px 0; }}
129
- .status-bar {{ height: 24px; background: #f0f0f0; border-top: 1px solid #e0e0e0; display: flex; align-items: center; padding: 0 12px; font-size: 11px; color: #666; }}
130
- </style>
131
  </head>
132
  <body>
133
  <div class="editor-container">
134
- <div class="toolbar">
135
- <div class="toolbar-title">
136
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="2"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/></svg>
137
- Simple Video Editor
138
- </div>
139
- <div class="toolbar-actions">
140
- <button class="btn btn-secondary" onclick="editorUndo()" title="Ctrl+Z">
141
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6M3 13a9 9 0 1 0 2.5-6.5"/></svg>
142
- μ‹€ν–‰μ·¨μ†Œ
143
- </button>
144
- <button class="btn btn-success" onclick="editorExport()">
145
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
146
- 내보내기
147
- </button>
148
- </div>
149
- </div>
150
- <div class="main-area">
151
- <div class="media-library">
152
- <div class="library-header">πŸ“ λ―Έλ””μ–΄</div>
153
- <div class="library-content">
154
- <div class="library-hint" id="libraryHint">⬆️ μœ„μ˜ 파일 μ—…λ‘œλ“œλ₯Ό<br>μ‚¬μš©ν•˜μ„Έμš”</div>
155
- <div class="media-grid" id="mediaGrid"></div>
156
- </div>
157
- </div>
158
- <div class="preview-area">
159
- <div class="preview-container" id="previewContainer">
160
- <div class="preview-placeholder">
161
- <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>
162
- <p>νƒ€μž„λΌμΈμ— λ―Έλ””μ–΄λ₯Ό μΆ”κ°€ν•˜μ„Έμš”</p>
163
- </div>
164
- </div>
165
- <div class="playback-controls">
166
- <button class="control-btn" onclick="editorSkipStart()" title="처음으둜"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg></button>
167
- <button class="control-btn" onclick="editorSkipBack()" title="-5초"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/></svg></button>
168
- <button class="control-btn play-btn" onclick="editorTogglePlay()" id="playBtn" title="Space"><svg viewBox="0 0 24 24" fill="currentColor" id="playIcon"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
169
- <button class="control-btn" onclick="editorSkipForward()" title="+5초"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg></button>
170
- <button class="control-btn" onclick="editorSkipEnd()" title="끝으둜"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg></button>
171
- <div class="time-display"><span id="currentTimeDisplay">00:00.00</span> / <span id="durationDisplay">00:00.00</span></div>
172
- <button class="control-btn" onclick="editorToggleMute()" title="μŒμ†Œκ±°"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" id="volumeIcon"><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"/></svg></button>
173
- </div>
174
- </div>
175
- <div class="properties-panel">
176
- <div class="properties-header">βš™οΈ 속성</div>
177
- <div class="properties-content" id="propertiesContent"><div class="no-selection">클립을 μ„ νƒν•˜λ©΄<br>속성을 νŽΈμ§‘ν•  수 μžˆμŠ΅λ‹ˆλ‹€</div></div>
178
- </div>
179
- </div>
180
- <div class="timeline-area">
181
- <div class="timeline-toolbar">
182
- <button class="btn btn-secondary" onclick="editorSplit()" title="선택 클립 자λ₯΄κΈ°"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg>자λ₯΄κΈ°</button>
183
- <button class="btn btn-secondary" onclick="editorDuplicate()" title="Ctrl+D"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>볡제</button>
184
- <button class="btn btn-danger" onclick="editorDelete()" title="Delete"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>μ‚­μ œ</button>
185
- <div class="timeline-zoom">
186
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
187
- <input type="range" min="0.5" max="3" step="0.1" value="1" oninput="editorSetZoom(this.value)" id="zoomSlider">
188
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
189
- </div>
190
- </div>
191
- <div class="timeline-container" id="timelineContainer" onclick="editorTimelineClick(event)">
192
- <div class="timeline-ruler" id="timelineRuler"></div>
193
- <div class="timeline-tracks" id="timelineTracks">
194
- <div class="timeline-track" data-track="0">
195
- <div class="track-label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>μ˜μƒ</div>
196
- <div class="track-content" id="track0"></div>
197
- </div>
198
- <div class="timeline-track" data-track="1">
199
- <div class="track-label"><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>μ˜€λ””μ˜€</div>
200
- <div class="track-content" id="track1"></div>
201
- </div>
202
- </div>
203
- <div class="playhead" id="playhead" style="left: 70px;"></div>
204
- </div>
205
- </div>
206
- <div class="status-bar" id="statusBar">쀀비됨 | λ―Έλ””μ–΄: 0개 | 클립: 0개</div>
207
  </div>
208
- <div class="context-menu" id="contextMenu" style="display:none;">
209
- <div class="context-menu-item" onclick="editorSplit()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/></svg>자λ₯΄κΈ°</div>
210
- <div class="context-menu-item" onclick="editorDuplicate()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>볡제</div>
211
- <div class="context-menu-divider"></div>
212
- <div class="context-menu-item danger" onclick="editorDelete()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>μ‚­μ œ</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  </div>
214
- <div class="modal-overlay" id="exportModal" style="display:none;">
215
- <div class="modal">
216
- <h3>🎬 μ˜μƒ 내보내기</h3>
217
- <p id="exportStatus">μ˜μƒμ„ μ²˜λ¦¬ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€...</p>
218
- <div class="progress-bar"><div class="progress-fill" id="exportProgress" style="width: 0%"></div></div>
219
- <button class="btn btn-secondary" onclick="editorCancelExport()" id="cancelExportBtn">μ·¨μ†Œ</button>
220
- </div>
221
  </div>
222
  <script>
223
- var editorState = {{ mediaLibrary: [], timelineClips: [], selectedClipId: null, isPlaying: false, isMuted: false, currentTime: 0, totalDuration: 0, zoom: 1, pixelsPerSecond: 80, undoStack: [], animationId: null, trimData: null }};
224
-
225
- function generateId() {{ return Date.now().toString(36) + Math.random().toString(36).substr(2); }}
226
- function formatTime(seconds) {{ if (!seconds || isNaN(seconds)) seconds = 0; var mins = Math.floor(seconds / 60); var secs = Math.floor(seconds % 60); var ms = Math.floor((seconds % 1) * 100); return (mins < 10 ? '0' : '') + mins + ':' + (secs < 10 ? '0' : '') + secs + '.' + (ms < 10 ? '0' : '') + ms; }}
227
- function updateStatus(msg) {{ var el = document.getElementById('statusBar'); if (el) el.textContent = msg + ' | λ―Έλ””μ–΄: ' + editorState.mediaLibrary.length + '개 | 클립: ' + editorState.timelineClips.length + '개'; }}
228
- function saveState() {{ editorState.undoStack.push(JSON.stringify(editorState.timelineClips)); if (editorState.undoStack.length > 50) editorState.undoStack.shift(); }}
229
-
230
- function addMediaToEditor(name, type, dataUrl) {{
231
- console.log('Adding media:', name, type);
232
- var media = {{ id: generateId(), name: name, type: type, url: dataUrl, duration: (type === 'image') ? 5 : 0, thumbnail: (type === 'image') ? dataUrl : null }};
233
- editorState.mediaLibrary.push(media);
234
- if (type === 'video' || type === 'audio') {{
235
- var el = document.createElement(type);
236
- el.src = dataUrl;
237
- el.preload = 'metadata';
238
- el.onloadedmetadata = function() {{ media.duration = el.duration; renderMediaLibrary(); if (type === 'video') el.currentTime = Math.min(1, el.duration / 2); }};
239
- el.onseeked = function() {{ if (type === 'video') {{ try {{ var canvas = document.createElement('canvas'); canvas.width = 160; canvas.height = 90; canvas.getContext('2d').drawImage(el, 0, 0, 160, 90); media.thumbnail = canvas.toDataURL(); renderMediaLibrary(); }} catch(e) {{}} }} }};
240
- }}
241
- renderMediaLibrary();
242
- updateStatus('λ―Έλ””μ–΄ 좔가됨: ' + name);
243
- setTimeout(function() {{ addToTimeline(media); }}, 100);
244
  }}
245
-
246
- function renderMediaLibrary() {{
247
- var grid = document.getElementById('mediaGrid');
248
- var hint = document.getElementById('libraryHint');
249
- if (!grid) return;
250
- if (hint) hint.style.display = editorState.mediaLibrary.length === 0 ? 'block' : 'none';
251
- grid.innerHTML = '';
252
- var typeIcons = {{ video: '<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21"/></svg>', 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>', 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>' }};
253
- editorState.mediaLibrary.forEach(function(media) {{
254
- var item = document.createElement('div');
255
- item.className = 'media-item';
256
- item.draggable = true;
257
- item.setAttribute('data-id', media.id);
258
- item.ondblclick = function() {{ addToTimeline(media); }};
259
- item.ondragstart = function(e) {{ e.dataTransfer.setData('mediaId', media.id); this.style.opacity = '0.5'; }};
260
- item.ondragend = function() {{ this.style.opacity = '1'; }};
261
- var thumbHtml = media.thumbnail ? '<img src="' + media.thumbnail + '" alt="' + media.name + '">' : '<div class="media-item-icon">' + (media.type === 'video' ? '🎬' : media.type === 'audio' ? '🎡' : 'πŸ–ΌοΈ') + '</div>';
262
- item.innerHTML = thumbHtml + '<div class="media-item-type">' + typeIcons[media.type] + '</div>' + (media.duration > 0 ? '<div class="media-item-duration">' + formatTime(media.duration) + '</div>' : '') + '<div class="media-item-overlay"><span class="media-item-name">' + media.name + '</span></div>';
263
- grid.appendChild(item);
264
- }});
265
  }}
266
-
267
- function addToTimeline(media, startTime) {{
268
- saveState();
269
- var track = (media.type === 'audio') ? 1 : 0;
270
- var start = (startTime !== undefined) ? startTime : getTrackEndTime(track);
271
- var clip = {{ id: generateId(), mediaId: media.id, name: media.name, type: media.type, url: media.url, thumbnail: media.thumbnail, track: track, startTime: start, duration: media.duration, trimStart: 0, trimEnd: media.duration, volume: 1 }};
272
- editorState.timelineClips.push(clip);
273
- renderTimeline();
274
- updateDuration();
275
- updateStatus('클립 좔가됨: ' + clip.name);
 
 
 
 
 
276
  }}
277
-
278
- function getTrackEndTime(track) {{
279
- var maxEnd = 0;
280
- editorState.timelineClips.forEach(function(c) {{ if (c.track === track) {{ var end = c.startTime + (c.trimEnd - c.trimStart); if (end > maxEnd) maxEnd = end; }} }});
281
- return maxEnd;
 
 
282
  }}
283
-
284
- function renderTimeline() {{
285
- var track0 = document.getElementById('track0');
286
- var track1 = document.getElementById('track1');
287
- if (track0) track0.innerHTML = '';
288
- if (track1) track1.innerHTML = '';
289
- editorState.timelineClips.forEach(function(clip) {{
290
- var trackEl = document.getElementById('track' + clip.track);
291
- if (!trackEl) return;
292
- var clipEl = document.createElement('div');
293
- clipEl.className = 'timeline-clip ' + clip.type + (editorState.selectedClipId === clip.id ? ' selected' : '');
294
- clipEl.setAttribute('data-clip-id', clip.id);
295
- var clipDuration = clip.trimEnd - clip.trimStart;
296
- var left = clip.startTime * editorState.pixelsPerSecond * editorState.zoom;
297
- var width = Math.max(40, clipDuration * editorState.pixelsPerSecond * editorState.zoom);
298
- clipEl.style.left = left + 'px';
299
- clipEl.style.width = width + 'px';
300
- clipEl.draggable = true;
301
- clipEl.onclick = function(e) {{ e.stopPropagation(); selectClip(clip.id); }};
302
- clipEl.oncontextmenu = function(e) {{ e.preventDefault(); selectClip(clip.id); showContextMenu(e.clientX, e.clientY); }};
303
- clipEl.ondragstart = function(e) {{ e.dataTransfer.setData('clipId', clip.id); e.dataTransfer.setData('offsetX', e.offsetX.toString()); }};
304
- var thumbHtml = clip.thumbnail ? '<img class="clip-thumbnail" src="' + clip.thumbnail + '">' : '';
305
- clipEl.innerHTML = thumbHtml + '<div class="clip-info"><div class="clip-name">' + clip.name + '</div><div class="clip-duration">' + formatTime(clipDuration) + '</div></div><div class="clip-handles clip-handle-left"></div><div class="clip-handles clip-handle-right"></div>';
306
- trackEl.appendChild(clipEl);
307
- }});
308
- renderRuler();
309
- setupTrackDropZones();
310
  }}
311
-
312
- function renderRuler() {{
313
- var ruler = document.getElementById('timelineRuler');
314
- if (!ruler) return;
315
- var width = Math.max(editorState.totalDuration * editorState.pixelsPerSecond * editorState.zoom + 300, 1000);
316
- ruler.style.width = width + 'px';
317
- var html = '<svg width="100%" height="22" style="position:absolute;left:70px">';
318
- var step = editorState.zoom < 0.7 ? 5 : editorState.zoom < 1.5 ? 2 : 1;
319
- for (var i = 0; i <= Math.ceil(editorState.totalDuration) + 10; i += step) {{
320
- var x = i * editorState.pixelsPerSecond * editorState.zoom;
321
- html += '<line x1="' + x + '" y1="17" x2="' + x + '" y2="22" stroke="#d1d5db" stroke-width="1"/>';
322
- html += '<text x="' + x + '" y="12" fill="#9ca3af" font-size="9" text-anchor="middle">' + formatTime(i) + '</text>';
323
- }}
324
- html += '</svg>';
325
- ruler.innerHTML = html;
326
  }}
327
-
328
- function setupTrackDropZones() {{
329
- ['track0', 'track1'].forEach(function(trackId, trackIdx) {{
330
- var track = document.getElementById(trackId);
331
- if (!track) return;
332
- track.ondragover = function(e) {{ e.preventDefault(); track.classList.add('drop-highlight'); }};
333
- track.ondragleave = function() {{ track.classList.remove('drop-highlight'); }};
334
- track.ondrop = function(e) {{
335
- e.preventDefault();
336
- track.classList.remove('drop-highlight');
337
- var rect = track.getBoundingClientRect();
338
- var x = e.clientX - rect.left;
339
- var time = Math.max(0, x / (editorState.pixelsPerSecond * editorState.zoom));
340
- var mediaId = e.dataTransfer.getData('mediaId');
341
- var clipId = e.dataTransfer.getData('clipId');
342
- var offsetX = parseFloat(e.dataTransfer.getData('offsetX') || 0);
343
- if (mediaId) {{
344
- var media = editorState.mediaLibrary.find(function(m) {{ return m.id === mediaId; }});
345
- if (media) {{ var targetTrack = (media.type === 'audio') ? 1 : trackIdx; addToTimeline(media, time); if (targetTrack !== trackIdx) {{ editorState.timelineClips[editorState.timelineClips.length - 1].track = targetTrack; renderTimeline(); }} }}
346
- }} else if (clipId) {{
347
- saveState();
348
- var clip = editorState.timelineClips.find(function(c) {{ return c.id === clipId; }});
349
- if (clip) {{ clip.startTime = Math.max(0, time - offsetX / (editorState.pixelsPerSecond * editorState.zoom)); clip.track = (clip.type === 'audio') ? 1 : trackIdx; renderTimeline(); updateDuration(); }}
350
- }}
351
- }};
352
- }});
353
  }}
354
-
355
- function selectClip(clipId) {{ editorState.selectedClipId = clipId; renderTimeline(); renderProperties(); }}
356
-
357
- function renderProperties() {{
358
- var container = document.getElementById('propertiesContent');
359
- if (!container) return;
360
- var clip = editorState.timelineClips.find(function(c) {{ return c.id === editorState.selectedClipId; }});
361
- if (!clip) {{ container.innerHTML = '<div class="no-selection">클립을 μ„ νƒν•˜λ©΄<br>속성을 νŽΈμ§‘ν•  수 μžˆμŠ΅λ‹ˆλ‹€</div>'; return; }}
362
- var clipDuration = clip.trimEnd - clip.trimStart;
363
- var html = '<div class="property-group"><div class="property-label">이름</div><input type="text" class="property-input" value="' + clip.name + '" onchange="updateClipProp(\\'name\\', this.value)"></div>' +
364
- '<div class="property-group"><div class="property-label">μ‹œμž‘ μ‹œκ°„</div><input type="number" class="property-input" value="' + clip.startTime.toFixed(2) + '" step="0.1" min="0" onchange="updateClipProp(\\'startTime\\', parseFloat(this.value))"></div>' +
365
- '<div class="property-group"><div class="property-row"><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><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></div></div>' +
366
- '<div class="property-group"><div class="property-label">길이</div><div style="padding:6px 0;font-size:12px;color:#374151">' + formatTime(clipDuration) + '</div></div>';
367
- if (clip.type !== 'image') html += '<div class="property-group"><div class="property-label">λ³Όλ₯¨ (' + Math.round(clip.volume * 100) + '%)</div><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))"></div>';
368
- container.innerHTML = html;
369
  }}
370
-
371
- function updateClipProp(prop, value) {{ saveState(); var clip = editorState.timelineClips.find(function(c) {{ return c.id === editorState.selectedClipId; }}); if (clip) {{ clip[prop] = value; renderTimeline(); updateDuration(); renderProperties(); }} }}
372
-
373
- function editorSplit() {{
374
- if (!editorState.selectedClipId) return;
375
- var clip = null;
376
- for (var i = 0; i < editorState.timelineClips.length; i++) {{ if (editorState.timelineClips[i].id === editorState.selectedClipId) {{ clip = editorState.timelineClips[i]; break; }} }}
377
- if (!clip) return;
378
- saveState();
379
- var clipDuration = clip.trimEnd - clip.trimStart;
380
- var splitPoint = clipDuration / 2;
381
- var clip2 = JSON.parse(JSON.stringify(clip));
382
- clip2.id = generateId();
383
- clip2.startTime = clip.startTime + splitPoint;
384
- clip2.trimStart = clip.trimStart + splitPoint;
385
- clip.trimEnd = clip.trimStart + splitPoint;
386
- editorState.timelineClips.push(clip2);
387
- renderTimeline();
388
- hideContextMenu();
389
- updateStatus('클립 뢄할됨');
390
  }}
391
-
392
- function editorDuplicate() {{
393
- if (!editorState.selectedClipId) return;
394
- var clip = editorState.timelineClips.find(function(c) {{ return c.id === editorState.selectedClipId; }});
395
- if (!clip) return;
396
- saveState();
397
- var clipDuration = clip.trimEnd - clip.trimStart;
398
- var newClip = JSON.parse(JSON.stringify(clip));
399
- newClip.id = generateId();
400
- newClip.startTime = clip.startTime + clipDuration;
401
- editorState.timelineClips.push(newClip);
402
- renderTimeline();
403
- updateDuration();
404
- hideContextMenu();
405
- updateStatus('클립 볡제됨');
406
  }}
407
-
408
- function editorDelete() {{
409
- if (!editorState.selectedClipId) return;
410
- saveState();
411
- editorState.timelineClips = editorState.timelineClips.filter(function(c) {{ return c.id !== editorState.selectedClipId; }});
412
- editorState.selectedClipId = null;
413
- renderTimeline();
414
- renderProperties();
415
- updateDuration();
416
- hideContextMenu();
417
- updateStatus('클립 μ‚­μ œλ¨');
418
  }}
419
-
420
- function editorUndo() {{ if (editorState.undoStack.length > 0) {{ editorState.timelineClips = JSON.parse(editorState.undoStack.pop()); renderTimeline(); updateDuration(); updateStatus('μ‹€ν–‰μ·¨μ†Œ'); }} }}
421
-
422
- function updateDuration() {{
423
- var maxEnd = 0;
424
- editorState.timelineClips.forEach(function(c) {{ var end = c.startTime + (c.trimEnd - c.trimStart); if (end > maxEnd) maxEnd = end; }});
425
- editorState.totalDuration = maxEnd;
426
- document.getElementById('durationDisplay').textContent = formatTime(maxEnd);
427
  }}
428
-
429
- function editorTogglePlay() {{
430
- editorState.isPlaying = !editorState.isPlaying;
431
- var icon = document.getElementById('playIcon');
432
- if (editorState.isPlaying) {{ icon.innerHTML = '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'; startPlayback(); }}
433
- else {{ icon.innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>'; stopPlayback(); }}
 
434
  }}
435
-
436
- function startPlayback() {{
437
- var lastTime = performance.now();
438
- function animate(now) {{
439
- if (!editorState.isPlaying) return;
440
- var delta = (now - lastTime) / 1000;
441
- lastTime = now;
442
- editorState.currentTime += delta;
443
- if (editorState.currentTime >= editorState.totalDuration) {{ editorState.currentTime = 0; if (editorState.totalDuration === 0) {{ editorState.isPlaying = false; document.getElementById('playIcon').innerHTML = '<polygon points="5 3 19 12 5 21 5 3"/>'; return; }} }}
444
- updatePlayhead();
445
- updatePreview();
446
- editorState.animationId = requestAnimationFrame(animate);
447
- }}
448
- editorState.animationId = requestAnimationFrame(animate);
449
  }}
450
-
451
- function stopPlayback() {{ if (editorState.animationId) {{ cancelAnimationFrame(editorState.animationId); editorState.animationId = null; }} var video = document.querySelector('#previewContainer video'); if (video && !video.paused) video.pause(); }}
452
-
453
- function updatePlayhead() {{
454
- var playhead = document.getElementById('playhead');
455
- if (playhead) playhead.style.left = (70 + editorState.currentTime * editorState.pixelsPerSecond * editorState.zoom) + 'px';
456
- document.getElementById('currentTimeDisplay').textContent = formatTime(editorState.currentTime);
 
 
 
 
 
 
 
 
 
457
  }}
458
-
459
- function updatePreview() {{
460
- var container = document.getElementById('previewContainer');
461
- if (!container) return;
462
- var currentClips = editorState.timelineClips.filter(function(c) {{ var clipEnd = c.startTime + (c.trimEnd - c.trimStart); return editorState.currentTime >= c.startTime && editorState.currentTime < clipEnd; }});
463
- var visualClip = currentClips.find(function(c) {{ return c.type === 'video' || c.type === 'image'; }});
464
- if (visualClip) {{
465
- var clipTime = editorState.currentTime - visualClip.startTime + visualClip.trimStart;
466
- if (visualClip.type === 'image') {{ if (!container.querySelector('img[data-clip-id="' + visualClip.id + '"]')) container.innerHTML = '<img src="' + visualClip.url + '" data-clip-id="' + visualClip.id + '">'; }}
467
- else if (visualClip.type === 'video') {{
468
- var video = container.querySelector('video[data-clip-id="' + visualClip.id + '"]');
469
- if (!video) {{ container.innerHTML = '<video src="' + visualClip.url + '" data-clip-id="' + visualClip.id + '"' + (editorState.isMuted ? ' muted' : '') + '></video>'; video = container.querySelector('video'); }}
470
- if (Math.abs(video.currentTime - clipTime) > 0.2) video.currentTime = clipTime;
471
- if (editorState.isPlaying && video.paused) video.play().catch(function(){{}});
472
- else if (!editorState.isPlaying && !video.paused) video.pause();
473
- video.volume = editorState.isMuted ? 0 : visualClip.volume;
474
- video.muted = editorState.isMuted;
475
- }}
476
- }} else {{
477
- var hasAudio = currentClips.some(function(c) {{ return c.type === 'audio'; }});
478
- if (!container.querySelector('.preview-placeholder')) container.innerHTML = '<div class="preview-placeholder"><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><p>' + (hasAudio ? '🎡 μ˜€λ””μ˜€ μž¬μƒ 쀑' : 'νƒ€μž„λΌμΈμ— λ―Έλ””μ–΄λ₯Ό μΆ”κ°€ν•˜μ„Έμš”') + '</p></div>';
479
- }}
480
  }}
481
-
482
- function editorSkipStart() {{ editorState.currentTime = 0; updatePlayhead(); updatePreview(); }}
483
- function editorSkipEnd() {{ editorState.currentTime = editorState.totalDuration; updatePlayhead(); updatePreview(); }}
484
- function editorSkipBack() {{ editorState.currentTime = Math.max(0, editorState.currentTime - 5); updatePlayhead(); updatePreview(); }}
485
- function editorSkipForward() {{ editorState.currentTime = Math.min(editorState.totalDuration, editorState.currentTime + 5); updatePlayhead(); updatePreview(); }}
486
-
487
- function editorToggleMute() {{
488
- editorState.isMuted = !editorState.isMuted;
489
- var icon = document.getElementById('volumeIcon');
490
- if (editorState.isMuted) 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"/>';
491
- else 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"/>';
492
- var video = document.querySelector('#previewContainer video');
493
- if (video) video.muted = editorState.isMuted;
494
  }}
495
-
496
- function editorSetZoom(value) {{ editorState.zoom = parseFloat(value); renderTimeline(); updatePlayhead(); }}
497
-
498
- function editorTimelineClick(e) {{
499
- if (e.target.closest('.timeline-clip')) return;
500
- var container = document.getElementById('timelineContainer');
501
- var rect = container.getBoundingClientRect();
502
- var x = e.clientX - rect.left - 70 + container.scrollLeft;
503
- editorState.currentTime = Math.max(0, Math.min(editorState.totalDuration, x / (editorState.pixelsPerSecond * editorState.zoom)));
504
- updatePlayhead();
505
- updatePreview();
506
  }}
507
-
508
- function showContextMenu(x, y) {{ var menu = document.getElementById('contextMenu'); menu.style.display = 'block'; menu.style.left = x + 'px'; menu.style.top = y + 'px'; }}
509
- function hideContextMenu() {{ document.getElementById('contextMenu').style.display = 'none'; }}
510
- document.addEventListener('click', function(e) {{ if (!e.target.closest('.context-menu')) hideContextMenu(); }});
511
-
512
- function editorExport() {{
513
- if (editorState.timelineClips.length === 0) {{ alert('νƒ€μž„λΌμΈμ— 클립을 μΆ”κ°€ν•΄μ£Όμ„Έμš”.'); return; }}
514
- document.getElementById('exportModal').style.display = 'flex';
515
- document.getElementById('exportProgress').style.width = '0%';
516
- document.getElementById('exportStatus').textContent = 'μ˜μƒμ„ μ²˜λ¦¬ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€...';
517
- var progress = 0;
518
- var interval = setInterval(function() {{
519
- progress += 2;
520
- document.getElementById('exportProgress').style.width = progress + '%';
521
- if (progress === 30) document.getElementById('exportStatus').textContent = '클립 병합 쀑...';
522
- if (progress === 60) document.getElementById('exportStatus').textContent = 'μ˜€λ””μ˜€ 처리 쀑...';
523
- if (progress === 90) document.getElementById('exportStatus').textContent = 'μ΅œμ’… λ Œλ”λ§ 쀑...';
524
- if (progress >= 100) {{ clearInterval(interval); document.getElementById('exportStatus').textContent = 'βœ… μ™„λ£Œ!'; document.getElementById('cancelExportBtn').textContent = 'λ‹«κΈ°'; }}
525
- }}, 50);
526
  }}
527
-
528
- function editorCancelExport() {{ document.getElementById('exportModal').style.display = 'none'; document.getElementById('cancelExportBtn').textContent = 'μ·¨μ†Œ'; }}
529
-
530
- document.addEventListener('keydown', function(e) {{
531
- if (e.target.tagName === 'INPUT') return;
532
- if (e.code === 'Space') {{ e.preventDefault(); editorTogglePlay(); }}
533
- else if (e.code === 'Delete' || e.code === 'Backspace') {{ e.preventDefault(); editorDelete(); }}
534
- else if (e.code === 'KeyD' && (e.ctrlKey || e.metaKey)) {{ e.preventDefault(); editorDuplicate(); }}
535
- else if (e.code === 'KeyZ' && (e.ctrlKey || e.metaKey)) {{ e.preventDefault(); editorUndo(); }}
536
- else if (e.code === 'ArrowLeft') {{ e.preventDefault(); editorState.currentTime = Math.max(0, editorState.currentTime - (e.shiftKey ? 1 : 0.1)); updatePlayhead(); updatePreview(); }}
537
- else if (e.code === 'ArrowRight') {{ e.preventDefault(); editorState.currentTime = Math.min(editorState.totalDuration, editorState.currentTime + (e.shiftKey ? 1 : 0.1)); updatePlayhead(); updatePreview(); }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  }});
539
-
540
- renderTimeline();
541
- updateStatus('쀀비됨');
542
-
543
- var mediaData = {media_data};
544
- console.log('Loading media data:', mediaData);
545
- if (mediaData && mediaData.length > 0) {{ mediaData.forEach(function(item) {{ addMediaToEditor(item.name, item.type, item.dataUrl); }}); }}
546
- console.log('Video Editor initialized');
547
  </script>
548
  </body>
549
- </html>"""
550
 
551
 
552
  def process_file(file):
@@ -561,14 +430,11 @@ def process_file(file):
561
  file_name = os.path.basename(file_path)
562
  ext = file_name.lower().split('.')[-1]
563
  if ext in ['mp4', 'webm', 'mov', 'avi', 'mkv']:
564
- file_type = 'video'
565
- mime = f'video/{ext}'
566
  elif ext in ['jpg', 'jpeg', 'png', 'gif', 'webp']:
567
- file_type = 'image'
568
- mime = f'image/{ext}'
569
  elif ext in ['mp3', 'wav', 'ogg', 'm4a', 'aac']:
570
- file_type = 'audio'
571
- mime = f'audio/{ext}'
572
  else:
573
  continue
574
  with open(file_path, 'rb') as fp:
@@ -578,22 +444,31 @@ def process_file(file):
578
 
579
 
580
  def create_editor_iframe(media_data):
 
581
  media_json = json.dumps(media_data, ensure_ascii=False)
582
  html_content = get_editor_html(media_json)
583
- html_b64 = base64.b64encode(html_content.encode('utf-8')).decode('utf-8')
584
- return f'<iframe src="data:text/html;base64,{html_b64}" style="width:100%;height:800px;border:none;border-radius:12px;"></iframe>'
 
585
 
586
 
587
- with gr.Blocks(title="Simple Video Editor") as demo:
588
  gr.Markdown("## 🎬 Simple Video Editor")
589
- gr.Markdown("νŒŒμΌμ„ μ—…λ‘œλ“œν•˜λ©΄ μžλ™μœΌλ‘œ νƒ€μž„λΌμΈμ— μΆ”κ°€λ©λ‹ˆλ‹€. λ―Έλ””μ–΄λ₯Ό 더블클릭해도 μΆ”κ°€ν•  수 μžˆμŠ΅λ‹ˆλ‹€.")
590
- file_input = gr.File(label="πŸ“ 파일 μ—…λ‘œλ“œ (μ˜μƒ/이미지/μ˜€λ””μ˜€) - μ—¬λŸ¬ 파일 선택 κ°€λŠ₯", file_count="multiple", file_types=["video", "image", "audio"])
 
 
 
 
 
 
591
  editor_html = gr.HTML(value=create_editor_iframe([]))
 
592
  def on_file_upload(files):
593
  if not files:
594
  return create_editor_iframe([])
595
- media_data = process_file(files)
596
- return create_editor_iframe(media_data)
597
  file_input.change(fn=on_file_upload, inputs=[file_input], outputs=[editor_html])
598
 
599
  if __name__ == "__main__":
 
1
  """
2
  Simple Video Editor - ν—ˆκΉ…νŽ˜μ΄μŠ€ 슀페이슀용
3
+ srcdoc λ°©μ‹μœΌλ‘œ iframe μ‚¬μš© - 파일 μ—…λ‘œλ“œ μ‹œ UI μœ μ§€
 
4
  """
5
 
6
  import gradio as gr
7
  import base64
8
  import os
9
  import json
10
+ import html
11
 
12
  def get_editor_html(media_data="[]"):
13
+ """에디터 HTML 생성"""
14
+ return f'''<!DOCTYPE html>
15
  <html lang="ko">
16
  <head>
17
+ <meta charset="UTF-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
19
+ <style>
20
+ *{{margin:0;padding:0;box-sizing:border-box}}
21
+ body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Noto Sans KR',sans-serif;background:#f5f5f7;color:#1d1d1f;font-size:13px}}
22
+ .editor-container{{display:flex;flex-direction:column;height:100vh;background:#f5f5f7;overflow:hidden}}
23
+ .toolbar{{height:48px;background:#fff;border-bottom:1px solid #e0e0e0;display:flex;align-items:center;justify-content:space-between;padding:0 12px;box-shadow:0 1px 3px rgba(0,0,0,0.05)}}
24
+ .toolbar-title{{font-size:15px;font-weight:600;color:#1d1d1f;display:flex;align-items:center;gap:6px}}
25
+ .toolbar-title svg{{width:20px;height:20px;color:#6366f1}}
26
+ .toolbar-actions{{display:flex;gap:8px}}
27
+ .btn{{padding:6px 12px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500;display:flex;align-items:center;gap:4px;transition:all 0.15s}}
28
+ .btn svg{{width:14px;height:14px}}
29
+ .btn-primary{{background:#6366f1;color:#fff}}
30
+ .btn-primary:hover{{background:#4f46e5}}
31
+ .btn-success{{background:#10b981;color:#fff}}
32
+ .btn-success:hover{{background:#059669}}
33
+ .btn-danger{{background:#ef4444;color:#fff}}
34
+ .btn-danger:hover{{background:#dc2626}}
35
+ .btn-secondary{{background:#f3f4f6;color:#374151;border:1px solid #e5e7eb}}
36
+ .btn-secondary:hover{{background:#e5e7eb}}
37
+ .main-area{{display:flex;flex:1;overflow:hidden;background:#f5f5f7}}
38
+ .media-library{{width:200px;background:#fff;border-right:1px solid #e0e0e0;display:flex;flex-direction:column}}
39
+ .library-header{{padding:10px 12px;border-bottom:1px solid #e0e0e0;font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px}}
40
+ .library-content{{flex:1;overflow-y:auto;padding:8px}}
41
+ .library-hint{{text-align:center;padding:20px 10px;color:#9ca3af;font-size:11px;line-height:1.5}}
42
+ .media-grid{{display:grid;grid-template-columns:repeat(2,1fr);gap:6px}}
43
+ .media-item{{aspect-ratio:16/9;background:#f3f4f6;border-radius:6px;overflow:hidden;cursor:grab;position:relative;transition:all 0.15s;border:1px solid #e5e7eb}}
44
+ .media-item:hover{{transform:scale(1.02);box-shadow:0 2px 8px rgba(0,0,0,0.1);border-color:#6366f1}}
45
+ .media-item img,.media-item video{{width:100%;height:100%;object-fit:cover}}
46
+ .media-item-overlay{{position:absolute;inset:0;background:linear-gradient(transparent 40%,rgba(0,0,0,0.7));opacity:0;transition:opacity 0.15s;display:flex;align-items:flex-end;padding:4px}}
47
+ .media-item:hover .media-item-overlay{{opacity:1}}
48
+ .media-item-name{{font-size:9px;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
49
+ .media-item-duration{{position:absolute;top:3px;right:3px;background:rgba(0,0,0,0.6);padding:1px 4px;border-radius:3px;font-size:9px;color:#fff}}
50
+ .media-item-type{{position:absolute;top:3px;left:3px;width:16px;height:16px;background:rgba(0,0,0,0.6);border-radius:50%;display:flex;align-items:center;justify-content:center}}
51
+ .media-item-type svg{{width:10px;height:10px;color:#fff}}
52
+ .media-item-icon{{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:24px;color:#9ca3af;background:#e5e7eb}}
53
+ .preview-area{{flex:1;display:flex;flex-direction:column;background:#1a1a1a;margin:8px;border-radius:12px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,0.15)}}
54
+ .preview-container{{flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;background:#000}}
55
+ .preview-container video,.preview-container img{{max-width:100%;max-height:100%;object-fit:contain}}
56
+ .preview-placeholder{{text-align:center;color:#666}}
57
+ .preview-placeholder svg{{width:48px;height:48px;margin-bottom:12px;opacity:0.5}}
58
+ .preview-placeholder p{{font-size:12px}}
59
+ .playback-controls{{height:50px;background:linear-gradient(180deg,#2a2a2a,#1a1a1a);display:flex;align-items:center;justify-content:center;gap:8px;padding:0 16px}}
60
+ .control-btn{{width:32px;height:32px;border:none;border-radius:50%;background:rgba(255,255,255,0.1);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.15s}}
61
+ .control-btn:hover{{background:rgba(255,255,255,0.2)}}
62
+ .control-btn svg{{width:14px;height:14px}}
63
+ .control-btn.play-btn{{width:40px;height:40px;background:#6366f1}}
64
+ .control-btn.play-btn:hover{{background:#4f46e5;transform:scale(1.05)}}
65
+ .control-btn.play-btn svg{{width:18px;height:18px}}
66
+ .time-display{{font-family:'SF Mono',Monaco,monospace;font-size:11px;color:#aaa;min-width:110px;text-align:center}}
67
+ .properties-panel{{width:180px;background:#fff;border-left:1px solid #e0e0e0;display:flex;flex-direction:column}}
68
+ .properties-header{{padding:10px 12px;border-bottom:1px solid #e0e0e0;font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px}}
69
+ .properties-content{{flex:1;padding:12px;overflow-y:auto}}
70
+ .property-group{{margin-bottom:14px}}
71
+ .property-label{{font-size:10px;color:#6b7280;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.3px}}
72
+ .property-input{{width:100%;padding:6px 8px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:5px;color:#1d1d1f;font-size:12px}}
73
+ .property-input:focus{{outline:none;border-color:#6366f1;box-shadow:0 0 0 2px rgba(99,102,241,0.1)}}
74
+ .property-row{{display:flex;gap:8px}}
75
+ .property-row>div{{flex:1}}
76
+ .no-selection{{color:#9ca3af;text-align:center;padding:30px 15px;font-size:12px;line-height:1.5}}
77
+ .timeline-area{{height:180px;background:#fff;border-top:1px solid #e0e0e0;display:flex;flex-direction:column}}
78
+ .timeline-toolbar{{height:32px;background:#fafafa;border-bottom:1px solid #e0e0e0;display:flex;align-items:center;padding:0 8px;gap:6px}}
79
+ .timeline-toolbar .btn{{padding:4px 8px;font-size:11px}}
80
+ .timeline-toolbar .btn svg{{width:12px;height:12px}}
81
+ .timeline-zoom{{display:flex;align-items:center;gap:4px;margin-left:auto;font-size:11px;color:#6b7280}}
82
+ .timeline-zoom input{{width:60px;height:4px}}
83
+ .timeline-zoom svg{{width:12px;height:12px}}
84
+ .timeline-container{{flex:1;overflow-x:auto;overflow-y:hidden;position:relative;background:#f9fafb}}
85
+ .timeline-ruler{{height:22px;background:#fff;position:sticky;top:0;z-index:10;border-bottom:1px solid #e5e7eb}}
86
+ .timeline-tracks{{position:relative;min-height:120px}}
87
+ .timeline-track{{height:55px;border-bottom:1px solid #e5e7eb;position:relative;display:flex;align-items:center;background:#fff}}
88
+ .timeline-track:nth-child(2){{background:#fffbeb}}
89
+ .track-label{{width:70px;padding:0 8px;font-size:10px;color:#6b7280;background:#f9fafb;height:100%;display:flex;align-items:center;gap:4px;border-right:1px solid #e5e7eb;position:sticky;left:0;z-index:5}}
90
+ .track-label svg{{width:12px;height:12px}}
91
+ .track-content{{flex:1;height:100%;position:relative;min-width:800px}}
92
+ .timeline-clip{{position:absolute;height:45px;top:5px;border-radius:6px;cursor:grab;display:flex;align-items:center;overflow:hidden;transition:box-shadow 0.15s;min-width:40px;box-shadow:0 1px 3px rgba(0,0,0,0.1)}}
93
+ .timeline-clip:hover{{box-shadow:0 0 0 2px #6366f1}}
94
+ .timeline-clip.selected{{box-shadow:0 0 0 2px #6366f1,0 4px 12px rgba(99,102,241,0.3)}}
95
+ .timeline-clip.video{{background:linear-gradient(135deg,#818cf8,#6366f1)}}
96
+ .timeline-clip.image{{background:linear-gradient(135deg,#34d399,#10b981)}}
97
+ .timeline-clip.audio{{background:linear-gradient(135deg,#fbbf24,#f59e0b)}}
98
+ .clip-thumbnail{{width:45px;height:100%;object-fit:cover;flex-shrink:0}}
99
+ .clip-info{{padding:0 6px;flex:1;overflow:hidden}}
100
+ .clip-name{{font-size:10px;font-weight:500;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
101
+ .clip-duration{{font-size:9px;color:rgba(255,255,255,0.7)}}
102
+ .clip-handles{{position:absolute;top:0;bottom:0;width:6px;background:rgba(255,255,255,0.4);cursor:ew-resize;opacity:0;transition:opacity 0.15s}}
103
+ .timeline-clip:hover .clip-handles{{opacity:1}}
104
+ .clip-handle-left{{left:0;border-radius:6px 0 0 6px}}
105
+ .clip-handle-right{{right:0;border-radius:0 6px 6px 0}}
106
+ .playhead{{position:absolute;top:0;bottom:0;width:2px;background:#ef4444;z-index:20;pointer-events:none}}
107
+ .playhead::before{{content:'';position:absolute;top:0;left:-5px;width:12px;height:12px;background:#ef4444;clip-path:polygon(50% 100%,0 0,100% 0)}}
108
+ .drop-highlight{{background:rgba(99,102,241,0.1)!important;outline:2px dashed #6366f1!important;outline-offset:-2px}}
109
+ .context-menu{{position:fixed;background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:4px 0;min-width:140px;z-index:1000;box-shadow:0 10px 40px rgba(0,0,0,0.15)}}
110
+ .context-menu-item{{padding:6px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px}}
111
+ .context-menu-item:hover{{background:#f3f4f6}}
112
+ .context-menu-item svg{{width:12px;height:12px}}
113
+ .context-menu-item.danger{{color:#ef4444}}
114
+ .context-menu-divider{{height:1px;background:#e5e7eb;margin:4px 0}}
115
+ .status-bar{{height:24px;background:#f0f0f0;border-top:1px solid #e0e0e0;display:flex;align-items:center;padding:0 12px;font-size:11px;color:#666}}
116
+ .modal-overlay{{position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000}}
117
+ .modal{{background:#fff;border-radius:12px;padding:24px;min-width:280px;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,0.3)}}
118
+ .modal h3{{margin-bottom:12px;font-size:16px}}
119
+ .modal p{{font-size:13px;color:#6b7280}}
120
+ .progress-bar{{height:6px;background:#e5e7eb;border-radius:3px;overflow:hidden;margin:16px 0}}
121
+ .progress-fill{{height:100%;background:linear-gradient(90deg,#6366f1,#8b5cf6);transition:width 0.3s}}
122
+ ::-webkit-scrollbar{{width:6px;height:6px}}
123
+ ::-webkit-scrollbar-track{{background:#f1f1f1}}
124
+ ::-webkit-scrollbar-thumb{{background:#c1c1c1;border-radius:3px}}
125
+ </style>
 
 
 
 
 
126
  </head>
127
  <body>
128
  <div class="editor-container">
129
+ <div class="toolbar">
130
+ <div class="toolbar-title">
131
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="2"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/></svg>
132
+ Simple Video Editor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  </div>
134
+ <div class="toolbar-actions">
135
+ <button class="btn btn-secondary" onclick="editorUndo()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6M3 13a9 9 0 1 0 2.5-6.5"/></svg>μ‹€ν–‰μ·¨μ†Œ</button>
136
+ <button class="btn btn-success" onclick="editorExport()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>내보내기</button>
137
+ </div>
138
+ </div>
139
+ <div class="main-area">
140
+ <div class="media-library">
141
+ <div class="library-header">πŸ“ λ―Έλ””μ–΄</div>
142
+ <div class="library-content">
143
+ <div class="library-hint" id="libraryHint">⬆️ μœ„μ˜ 파일 μ—…λ‘œλ“œλ₯Ό<br>μ‚¬μš©ν•˜μ„Έμš”</div>
144
+ <div class="media-grid" id="mediaGrid"></div>
145
+ </div>
146
+ </div>
147
+ <div class="preview-area">
148
+ <div class="preview-container" id="previewContainer">
149
+ <div class="preview-placeholder">
150
+ <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>
151
+ <p>νƒ€μž„λΌμΈμ— λ―Έλ””μ–΄λ₯Ό μΆ”κ°€ν•˜μ„Έμš”</p>
152
+ </div>
153
+ </div>
154
+ <div class="playback-controls">
155
+ <button class="control-btn" onclick="editorSkipStart()"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg></button>
156
+ <button class="control-btn" onclick="editorSkipBack()"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/></svg></button>
157
+ <button class="control-btn play-btn" onclick="editorTogglePlay()"><svg viewBox="0 0 24 24" fill="currentColor" id="playIcon"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
158
+ <button class="control-btn" onclick="editorSkipForward()"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg></button>
159
+ <button class="control-btn" onclick="editorSkipEnd()"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg></button>
160
+ <div class="time-display"><span id="currentTimeDisplay">00:00.00</span> / <span id="durationDisplay">00:00.00</span></div>
161
+ <button class="control-btn" onclick="editorToggleMute()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" id="volumeIcon"><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"/></svg></button>
162
+ </div>
163
+ </div>
164
+ <div class="properties-panel">
165
+ <div class="properties-header">βš™οΈ 속성</div>
166
+ <div class="properties-content" id="propertiesContent"><div class="no-selection">클립을 μ„ νƒν•˜λ©΄<br>속성을 νŽΈμ§‘ν•  수 μžˆμŠ΅λ‹ˆλ‹€</div></div>
167
+ </div>
168
+ </div>
169
+ <div class="timeline-area">
170
+ <div class="timeline-toolbar">
171
+ <button class="btn btn-secondary" onclick="editorSplit()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/></svg>자λ₯΄κΈ°</button>
172
+ <button class="btn btn-secondary" onclick="editorDuplicate()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>볡제</button>
173
+ <button class="btn btn-danger" onclick="editorDelete()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>μ‚­μ œ</button>
174
+ <div class="timeline-zoom">
175
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
176
+ <input type="range" min="0.5" max="3" step="0.1" value="1" oninput="editorSetZoom(this.value)">
177
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
178
+ </div>
179
+ </div>
180
+ <div class="timeline-container" id="timelineContainer" onclick="editorTimelineClick(event)">
181
+ <div class="timeline-ruler" id="timelineRuler"></div>
182
+ <div class="timeline-tracks">
183
+ <div class="timeline-track" data-track="0">
184
+ <div class="track-label"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>μ˜μƒ</div>
185
+ <div class="track-content" id="track0"></div>
186
+ </div>
187
+ <div class="timeline-track" data-track="1">
188
+ <div class="track-label"><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>μ˜€λ””μ˜€</div>
189
+ <div class="track-content" id="track1"></div>
190
+ </div>
191
+ </div>
192
+ <div class="playhead" id="playhead" style="left:70px"></div>
193
+ </div>
194
+ </div>
195
+ <div class="status-bar" id="statusBar">쀀비됨 | λ―Έλ””μ–΄: 0개 | 클립: 0개</div>
196
+ </div>
197
+ <div class="context-menu" id="contextMenu" style="display:none">
198
+ <div class="context-menu-item" onclick="editorSplit()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/></svg>자λ₯΄κΈ°</div>
199
+ <div class="context-menu-item" onclick="editorDuplicate()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>볡제</div>
200
+ <div class="context-menu-divider"></div>
201
+ <div class="context-menu-item danger" onclick="editorDelete()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>μ‚­μ œ</div>
202
+ </div>
203
+ <div class="modal-overlay" id="exportModal" style="display:none">
204
+ <div class="modal">
205
+ <h3>🎬 μ˜μƒ 내보내기</h3>
206
+ <p id="exportStatus">μ˜μƒμ„ μ²˜λ¦¬ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€...</p>
207
+ <div class="progress-bar"><div class="progress-fill" id="exportProgress" style="width:0%"></div></div>
208
+ <button class="btn btn-secondary" onclick="editorCancelExport()" id="cancelExportBtn">μ·¨μ†Œ</button>
209
  </div>
 
 
 
 
 
 
 
210
  </div>
211
  <script>
212
+ var S={{mediaLibrary:[],timelineClips:[],selectedClipId:null,isPlaying:false,isMuted:false,currentTime:0,totalDuration:0,zoom:1,pps:80,undoStack:[],animId:null}};
213
+ function gid(){{return Date.now().toString(36)+Math.random().toString(36).substr(2)}}
214
+ function fmt(s){{if(!s||isNaN(s))s=0;var m=Math.floor(s/60),sec=Math.floor(s%60),ms=Math.floor((s%1)*100);return(m<10?'0':'')+m+':'+(sec<10?'0':'')+sec+'.'+(ms<10?'0':'')+ms}}
215
+ function upStat(msg){{var e=document.getElementById('statusBar');if(e)e.textContent=msg+' | λ―Έλ””μ–΄: '+S.mediaLibrary.length+'개 | 클립: '+S.timelineClips.length+'개'}}
216
+ function save(){{S.undoStack.push(JSON.stringify(S.timelineClips));if(S.undoStack.length>50)S.undoStack.shift()}}
217
+ function addMedia(name,type,url){{
218
+ console.log('Adding:',name,type);
219
+ var m={{id:gid(),name:name,type:type,url:url,duration:type==='image'?5:0,thumb:type==='image'?url:null}};
220
+ S.mediaLibrary.push(m);
221
+ if(type==='video'||type==='audio'){{
222
+ var el=document.createElement(type);el.src=url;el.preload='metadata';
223
+ el.onloadedmetadata=function(){{m.duration=el.duration;renderLib();if(type==='video')el.currentTime=Math.min(1,el.duration/2)}};
224
+ el.onseeked=function(){{if(type==='video'){{try{{var c=document.createElement('canvas');c.width=160;c.height=90;c.getContext('2d').drawImage(el,0,0,160,90);m.thumb=c.toDataURL();renderLib()}}catch(e){{}}}}}}
 
 
 
 
 
 
 
 
225
  }}
226
+ renderLib();upStat('λ―Έλ””μ–΄ 좔가됨: '+name);
227
+ setTimeout(function(){{addToTL(m)}},100);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  }}
229
+ function renderLib(){{
230
+ var g=document.getElementById('mediaGrid'),h=document.getElementById('libraryHint');
231
+ if(!g)return;
232
+ if(h)h.style.display=S.mediaLibrary.length===0?'block':'none';
233
+ g.innerHTML='';
234
+ var icons={{video:'<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21"/></svg>',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>',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>'}};
235
+ S.mediaLibrary.forEach(function(m){{
236
+ var d=document.createElement('div');d.className='media-item';d.draggable=true;
237
+ d.ondblclick=function(){{addToTL(m)}};
238
+ d.ondragstart=function(e){{e.dataTransfer.setData('mid',m.id);this.style.opacity='0.5'}};
239
+ d.ondragend=function(){{this.style.opacity='1'}};
240
+ var th=m.thumb?'<img src="'+m.thumb+'">':'<div class="media-item-icon">'+(m.type==='video'?'🎬':m.type==='audio'?'🎡':'πŸ–ΌοΈ')+'</div>';
241
+ d.innerHTML=th+'<div class="media-item-type">'+icons[m.type]+'</div>'+(m.duration>0?'<div class="media-item-duration">'+fmt(m.duration)+'</div>':'')+'<div class="media-item-overlay"><span class="media-item-name">'+m.name+'</span></div>';
242
+ g.appendChild(d);
243
+ }});
244
  }}
245
+ function addToTL(m,st){{
246
+ save();
247
+ var tr=m.type==='audio'?1:0;
248
+ var start=st!==undefined?st:getTrEnd(tr);
249
+ var c={{id:gid(),mid:m.id,name:m.name,type:m.type,url:m.url,thumb:m.thumb,track:tr,start:start,dur:m.duration,ts:0,te:m.duration,vol:1}};
250
+ S.timelineClips.push(c);
251
+ renderTL();upDur();upStat('클립 좔가됨: '+c.name);
252
  }}
253
+ function getTrEnd(tr){{var mx=0;S.timelineClips.forEach(function(c){{if(c.track===tr){{var e=c.start+(c.te-c.ts);if(e>mx)mx=e}}}});return mx}}
254
+ function renderTL(){{
255
+ var t0=document.getElementById('track0'),t1=document.getElementById('track1');
256
+ if(t0)t0.innerHTML='';if(t1)t1.innerHTML='';
257
+ S.timelineClips.forEach(function(c){{
258
+ var tr=document.getElementById('track'+c.track);if(!tr)return;
259
+ var d=document.createElement('div');
260
+ d.className='timeline-clip '+c.type+(S.selectedClipId===c.id?' selected':'');
261
+ var cd=c.te-c.ts,l=c.start*S.pps*S.zoom,w=Math.max(40,cd*S.pps*S.zoom);
262
+ d.style.left=l+'px';d.style.width=w+'px';d.draggable=true;
263
+ d.onclick=function(e){{e.stopPropagation();selClip(c.id)}};
264
+ d.oncontextmenu=function(e){{e.preventDefault();selClip(c.id);showCtx(e.clientX,e.clientY)}};
265
+ d.ondragstart=function(e){{e.dataTransfer.setData('cid',c.id);e.dataTransfer.setData('ox',e.offsetX.toString())}};
266
+ var th=c.thumb?'<img class="clip-thumbnail" src="'+c.thumb+'">':'';
267
+ d.innerHTML=th+'<div class="clip-info"><div class="clip-name">'+c.name+'</div><div class="clip-duration">'+fmt(cd)+'</div></div><div class="clip-handles clip-handle-left"></div><div class="clip-handles clip-handle-right"></div>';
268
+ tr.appendChild(d);
269
+ }});
270
+ renderRuler();setupDrop();
 
 
 
 
 
 
 
 
 
271
  }}
272
+ function renderRuler(){{
273
+ var r=document.getElementById('timelineRuler');if(!r)return;
274
+ var w=Math.max(S.totalDuration*S.pps*S.zoom+300,1000);r.style.width=w+'px';
275
+ var h='<svg width="100%" height="22" style="position:absolute;left:70px">';
276
+ var st=S.zoom<0.7?5:S.zoom<1.5?2:1;
277
+ for(var i=0;i<=Math.ceil(S.totalDuration)+10;i+=st){{var x=i*S.pps*S.zoom;h+='<line x1="'+x+'" y1="17" x2="'+x+'" y2="22" stroke="#d1d5db"/><text x="'+x+'" y="12" fill="#9ca3af" font-size="9" text-anchor="middle">'+fmt(i)+'</text>'}}
278
+ h+='</svg>';r.innerHTML=h;
 
 
 
 
 
 
 
 
279
  }}
280
+ function setupDrop(){{
281
+ ['track0','track1'].forEach(function(tid,idx){{
282
+ var tr=document.getElementById(tid);if(!tr)return;
283
+ tr.ondragover=function(e){{e.preventDefault();tr.classList.add('drop-highlight')}};
284
+ tr.ondragleave=function(){{tr.classList.remove('drop-highlight')}};
285
+ tr.ondrop=function(e){{
286
+ e.preventDefault();tr.classList.remove('drop-highlight');
287
+ var rect=tr.getBoundingClientRect(),x=e.clientX-rect.left,t=Math.max(0,x/(S.pps*S.zoom));
288
+ var mid=e.dataTransfer.getData('mid'),cid=e.dataTransfer.getData('cid'),ox=parseFloat(e.dataTransfer.getData('ox')||0);
289
+ if(mid){{var m=S.mediaLibrary.find(function(x){{return x.id===mid}});if(m){{addToTL(m,t);var tgt=m.type==='audio'?1:idx;if(tgt!==idx){{S.timelineClips[S.timelineClips.length-1].track=tgt;renderTL()}}}}}}
290
+ else if(cid){{save();var c=S.timelineClips.find(function(x){{return x.id===cid}});if(c){{c.start=Math.max(0,t-ox/(S.pps*S.zoom));c.track=c.type==='audio'?1:idx;renderTL();upDur()}}}}
291
+ }};
292
+ }});
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  }}
294
+ function selClip(id){{S.selectedClipId=id;renderTL();renderProps()}}
295
+ function renderProps(){{
296
+ var ct=document.getElementById('propertiesContent');if(!ct)return;
297
+ var c=S.timelineClips.find(function(x){{return x.id===S.selectedClipId}});
298
+ if(!c){{ct.innerHTML='<div class="no-selection">클립을 μ„ νƒν•˜λ©΄<br>속성을 νŽΈμ§‘ν•  수 μžˆμŠ΅λ‹ˆλ‹€</div>';return}}
299
+ var cd=c.te-c.ts;
300
+ var h='<div class="property-group"><div class="property-label">이름</div><input type="text" class="property-input" value="'+c.name+'" onchange="upProp(\\'name\\',this.value)"></div>'+
301
+ '<div class="property-group"><div class="property-label">μ‹œμž‘</div><input type="number" class="property-input" value="'+c.start.toFixed(2)+'" step="0.1" min="0" onchange="upProp(\\'start\\',parseFloat(this.value))"></div>'+
302
+ '<div class="property-group"><div class="property-row"><div><div class="property-label">νŠΈλ¦Όμ‹œμž‘</div><input type="number" class="property-input" value="'+c.ts.toFixed(2)+'" step="0.1" min="0" max="'+c.dur+'" onchange="upProp(\\'ts\\',parseFloat(this.value))"></div>'+
303
+ '<div><div class="property-label">트림끝</div><input type="number" class="property-input" value="'+c.te.toFixed(2)+'" step="0.1" min="0" max="'+c.dur+'" onchange="upProp(\\'te\\',parseFloat(this.value))"></div></div></div>'+
304
+ '<div class="property-group"><div class="property-label">길이</div><div style="padding:6px 0;font-size:12px;color:#374151">'+fmt(cd)+'</div></div>';
305
+ if(c.type!=='image')h+='<div class="property-group"><div class="property-label">λ³Όλ₯¨ ('+Math.round(c.vol*100)+'%)</div><input type="range" class="property-input" style="padding:0" min="0" max="1" step="0.05" value="'+c.vol+'" oninput="upProp(\\'vol\\',parseFloat(this.value))"></div>';
306
+ ct.innerHTML=h;
 
 
307
  }}
308
+ function upProp(p,v){{save();var c=S.timelineClips.find(function(x){{return x.id===S.selectedClipId}});if(c){{c[p]=v;renderTL();upDur();renderProps()}}}}
309
+ function editorSplit(){{
310
+ if(!S.selectedClipId)return;
311
+ var c=S.timelineClips.find(function(x){{return x.id===S.selectedClipId}});if(!c)return;
312
+ save();var cd=c.te-c.ts,sp=cd/2;
313
+ var c2=JSON.parse(JSON.stringify(c));c2.id=gid();c2.start=c.start+sp;c2.ts=c.ts+sp;
314
+ c.te=c.ts+sp;
315
+ S.timelineClips.push(c2);renderTL();hideCtx();upStat('클립 뢄할됨');
 
 
 
 
 
 
 
 
 
 
 
 
316
  }}
317
+ function editorDuplicate(){{
318
+ if(!S.selectedClipId)return;
319
+ var c=S.timelineClips.find(function(x){{return x.id===S.selectedClipId}});if(!c)return;
320
+ save();var cd=c.te-c.ts;
321
+ var nc=JSON.parse(JSON.stringify(c));nc.id=gid();nc.start=c.start+cd;
322
+ S.timelineClips.push(nc);renderTL();upDur();hideCtx();upStat('클립 볡제됨');
 
 
 
 
 
 
 
 
 
323
  }}
324
+ function editorDelete(){{
325
+ if(!S.selectedClipId)return;save();
326
+ S.timelineClips=S.timelineClips.filter(function(x){{return x.id!==S.selectedClipId}});
327
+ S.selectedClipId=null;renderTL();renderProps();upDur();hideCtx();upStat('클립 μ‚­μ œλ¨');
 
 
 
 
 
 
 
328
  }}
329
+ function editorUndo(){{if(S.undoStack.length>0){{S.timelineClips=JSON.parse(S.undoStack.pop());renderTL();upDur();upStat('μ‹€ν–‰μ·¨μ†Œ')}}}}
330
+ function upDur(){{var mx=0;S.timelineClips.forEach(function(c){{var e=c.start+(c.te-c.ts);if(e>mx)mx=e}});S.totalDuration=mx;document.getElementById('durationDisplay').textContent=fmt(mx)}}
331
+ function editorTogglePlay(){{
332
+ S.isPlaying=!S.isPlaying;var ic=document.getElementById('playIcon');
333
+ if(S.isPlaying){{ic.innerHTML='<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>';startPB()}}
334
+ else{{ic.innerHTML='<polygon points="5 3 19 12 5 21 5 3"/>';stopPB()}}
 
 
335
  }}
336
+ function startPB(){{
337
+ var last=performance.now();
338
+ function anim(now){{
339
+ if(!S.isPlaying)return;
340
+ var d=(now-last)/1000;last=now;S.currentTime+=d;
341
+ if(S.currentTime>=S.totalDuration){{S.currentTime=0;if(S.totalDuration===0){{S.isPlaying=false;document.getElementById('playIcon').innerHTML='<polygon points="5 3 19 12 5 21 5 3"/>';return}}}}
342
+ upPH();upPrev();S.animId=requestAnimationFrame(anim);
343
  }}
344
+ S.animId=requestAnimationFrame(anim);
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  }}
346
+ function stopPB(){{if(S.animId){{cancelAnimationFrame(S.animId);S.animId=null}}var v=document.querySelector('#previewContainer video');if(v&&!v.paused)v.pause()}}
347
+ function upPH(){{var p=document.getElementById('playhead');if(p)p.style.left=(70+S.currentTime*S.pps*S.zoom)+'px';document.getElementById('currentTimeDisplay').textContent=fmt(S.currentTime)}}
348
+ function upPrev(){{
349
+ var ct=document.getElementById('previewContainer');if(!ct)return;
350
+ var cur=S.timelineClips.filter(function(c){{var ce=c.start+(c.te-c.ts);return S.currentTime>=c.start&&S.currentTime<ce}});
351
+ var vc=cur.find(function(c){{return c.type==='video'||c.type==='image'}});
352
+ if(vc){{
353
+ var ct2=S.currentTime-vc.start+vc.ts;
354
+ if(vc.type==='image'){{if(!ct.querySelector('img[data-cid="'+vc.id+'"]'))ct.innerHTML='<img src="'+vc.url+'" data-cid="'+vc.id+'">'}}
355
+ else if(vc.type==='video'){{
356
+ var v=ct.querySelector('video[data-cid="'+vc.id+'"]');
357
+ if(!v){{ct.innerHTML='<video src="'+vc.url+'" data-cid="'+vc.id+'"'+(S.isMuted?' muted':'')+'></video>';v=ct.querySelector('video')}}
358
+ if(Math.abs(v.currentTime-ct2)>0.2)v.currentTime=ct2;
359
+ if(S.isPlaying&&v.paused)v.play().catch(function(){{}});
360
+ else if(!S.isPlaying&&!v.paused)v.pause();
361
+ v.volume=S.isMuted?0:vc.vol;v.muted=S.isMuted;
362
  }}
363
+ }}else{{
364
+ var ha=cur.some(function(c){{return c.type==='audio'}});
365
+ if(!ct.querySelector('.preview-placeholder'))ct.innerHTML='<div class="preview-placeholder"><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><p>'+(ha?'🎡 μ˜€λ””μ˜€ 재�� 쀑':'νƒ€μž„λΌμΈμ— λ―Έλ””μ–΄λ₯Ό μΆ”κ°€ν•˜μ„Έμš”')+'</p></div>';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  }}
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  }}
368
+ function editorSkipStart(){{S.currentTime=0;upPH();upPrev()}}
369
+ function editorSkipEnd(){{S.currentTime=S.totalDuration;upPH();upPrev()}}
370
+ function editorSkipBack(){{S.currentTime=Math.max(0,S.currentTime-5);upPH();upPrev()}}
371
+ function editorSkipForward(){{S.currentTime=Math.min(S.totalDuration,S.currentTime+5);upPH();upPrev()}}
372
+ function editorToggleMute(){{
373
+ S.isMuted=!S.isMuted;var ic=document.getElementById('volumeIcon');
374
+ if(S.isMuted)ic.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"/>';
375
+ else ic.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"/>';
376
+ var v=document.querySelector('#previewContainer video');if(v)v.muted=S.isMuted;
 
 
377
  }}
378
+ function editorSetZoom(v){{S.zoom=parseFloat(v);renderTL();upPH()}}
379
+ function editorTimelineClick(e){{
380
+ if(e.target.closest('.timeline-clip'))return;
381
+ var ct=document.getElementById('timelineContainer'),rect=ct.getBoundingClientRect();
382
+ var x=e.clientX-rect.left-70+ct.scrollLeft;
383
+ S.currentTime=Math.max(0,Math.min(S.totalDuration,x/(S.pps*S.zoom)));
384
+ upPH();upPrev();
 
 
 
 
 
 
 
 
 
 
 
 
385
  }}
386
+ function showCtx(x,y){{var m=document.getElementById('contextMenu');m.style.display='block';m.style.left=x+'px';m.style.top=y+'px'}}
387
+ function hideCtx(){{document.getElementById('contextMenu').style.display='none'}}
388
+ document.addEventListener('click',function(e){{if(!e.target.closest('.context-menu'))hideCtx()}});
389
+ function editorExport(){{
390
+ if(S.timelineClips.length===0){{alert('νƒ€μž„λΌμΈμ— 클립을 μΆ”κ°€ν•΄μ£Όμ„Έμš”.');return}}
391
+ document.getElementById('exportModal').style.display='flex';
392
+ document.getElementById('exportProgress').style.width='0%';
393
+ document.getElementById('exportStatus').textContent='μ˜μƒμ„ μ²˜λ¦¬ν•˜κ³  μžˆμŠ΅λ‹ˆλ‹€...';
394
+ var p=0,iv=setInterval(function(){{
395
+ p+=2;document.getElementById('exportProgress').style.width=p+'%';
396
+ if(p===30)document.getElementById('exportStatus').textContent='클립 병합 쀑...';
397
+ if(p===60)document.getElementById('exportStatus').textContent='μ˜€λ””μ˜€ 처리 쀑...';
398
+ if(p===90)document.getElementById('exportStatus').textContent='μ΅œμ’… λ Œλ”λ§ 쀑...';
399
+ if(p>=100){{clearInterval(iv);document.getElementById('exportStatus').textContent='βœ… μ™„λ£Œ!';document.getElementById('cancelExportBtn').textContent='λ‹«κΈ°'}}
400
+ }},50);
401
+ }}
402
+ function editorCancelExport(){{document.getElementById('exportModal').style.display='none';document.getElementById('cancelExportBtn').textContent='μ·¨μ†Œ'}}
403
+ document.addEventListener('keydown',function(e){{
404
+ if(e.target.tagName==='INPUT')return;
405
+ if(e.code==='Space'){{e.preventDefault();editorTogglePlay()}}
406
+ else if(e.code==='Delete'||e.code==='Backspace'){{e.preventDefault();editorDelete()}}
407
+ else if(e.code==='KeyD'&&(e.ctrlKey||e.metaKey)){{e.preventDefault();editorDuplicate()}}
408
+ else if(e.code==='KeyZ'&&(e.ctrlKey||e.metaKey)){{e.preventDefault();editorUndo()}}
409
+ else if(e.code==='ArrowLeft'){{e.preventDefault();S.currentTime=Math.max(0,S.currentTime-(e.shiftKey?1:0.1));upPH();upPrev()}}
410
+ else if(e.code==='ArrowRight'){{e.preventDefault();S.currentTime=Math.min(S.totalDuration,S.currentTime+(e.shiftKey?1:0.1));upPH();upPrev()}}
411
  }});
412
+ renderTL();upStat('쀀비됨');
413
+ var initMedia={media_data};
414
+ if(initMedia&&initMedia.length>0)initMedia.forEach(function(m){{addMedia(m.name,m.type,m.dataUrl)}});
415
+ console.log('Editor ready');
 
 
 
 
416
  </script>
417
  </body>
418
+ </html>'''
419
 
420
 
421
  def process_file(file):
 
430
  file_name = os.path.basename(file_path)
431
  ext = file_name.lower().split('.')[-1]
432
  if ext in ['mp4', 'webm', 'mov', 'avi', 'mkv']:
433
+ file_type, mime = 'video', f'video/{ext}'
 
434
  elif ext in ['jpg', 'jpeg', 'png', 'gif', 'webp']:
435
+ file_type, mime = 'image', f'image/{ext}'
 
436
  elif ext in ['mp3', 'wav', 'ogg', 'm4a', 'aac']:
437
+ file_type, mime = 'audio', f'audio/{ext}'
 
438
  else:
439
  continue
440
  with open(file_path, 'rb') as fp:
 
444
 
445
 
446
  def create_editor_iframe(media_data):
447
+ """srcdoc λ°©μ‹μœΌλ‘œ iframe 생성"""
448
  media_json = json.dumps(media_data, ensure_ascii=False)
449
  html_content = get_editor_html(media_json)
450
+ # srcdocλ₯Ό μž‘μ€λ”°μ˜΄ν‘œλ‘œ κ°μ‹Έλ―€λ‘œ, λ‚΄λΆ€μ˜ μž‘μ€λ”°μ˜΄ν‘œλ§Œ μ΄μŠ€μΌ€μ΄ν”„
451
+ escaped = html_content.replace("'", "&#39;")
452
+ return f"<iframe srcdoc='{escaped}' style='width:100%;height:800px;border:none;border-radius:12px;'></iframe>"
453
 
454
 
455
+ with gr.Blocks(title="Simple Video Editor", css=".gradio-container{max-width:100%!important}") as demo:
456
  gr.Markdown("## 🎬 Simple Video Editor")
457
+ gr.Markdown("νŒŒμΌμ„ μ—…λ‘œλ“œν•˜λ©΄ μžλ™μœΌλ‘œ νƒ€μž„λΌμΈμ— μΆ”κ°€λ©λ‹ˆλ‹€.")
458
+
459
+ file_input = gr.File(
460
+ label="πŸ“ 파일 μ—…λ‘œλ“œ (μ˜μƒ/이미지/μ˜€λ””μ˜€)",
461
+ file_count="multiple",
462
+ file_types=["video", "image", "audio"]
463
+ )
464
+
465
  editor_html = gr.HTML(value=create_editor_iframe([]))
466
+
467
  def on_file_upload(files):
468
  if not files:
469
  return create_editor_iframe([])
470
+ return create_editor_iframe(process_file(files))
471
+
472
  file_input.change(fn=on_file_upload, inputs=[file_input], outputs=[editor_html])
473
 
474
  if __name__ == "__main__":