seawolf2357 commited on
Commit
2b1cd23
ยท
verified ยท
1 Parent(s): b2523df

Update app.py

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