studio / app.py
seawolf2357's picture
Update app.py
29554a2 verified
raw
history blame
44.1 kB
"""
Simple Video Editor - Canvas ๊ธฐ๋ฐ˜ ๋ Œ๋”๋ง
์›๋ณธ ํŒŒ์ผ ์„œ๋ฒ„ ์ €์žฅ + FFmpeg MP4 ๋‚ด๋ณด๋‚ด๊ธฐ
"""
import gradio as gr
import base64
import os
import json
import subprocess
import tempfile
import shutil
import time
# ์—…๋กœ๋“œ๋œ ํŒŒ์ผ ์ €์žฅ ๊ฒฝ๋กœ
UPLOAD_DIR = tempfile.mkdtemp()
uploaded_files = {} # {filename: filepath}
def get_editor_html(media_data="[]"):
return f'''<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f7;
font-size: 13px;
}}
.editor {{
display: flex;
flex-direction: column;
height: 100vh;
}}
.toolbar {{
height: 44px;
background: #fff;
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
}}
.toolbar-title {{
font-size: 15px;
font-weight: 600;
}}
.toolbar-actions {{
display: flex;
gap: 6px;
}}
.btn {{
padding: 6px 12px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
transition: all 0.2s;
}}
.btn-secondary {{
background: #f0f0f0;
color: #333;
}}
.btn-secondary:hover {{
background: #e0e0e0;
}}
.btn-primary {{
background: #6366f1;
color: #fff;
}}
.btn-primary:hover {{
background: #4f46e5;
}}
.btn-success {{
background: #10b981;
color: #fff;
}}
.btn-success:hover {{
background: #059669;
}}
.btn-danger {{
background: #ef4444;
color: #fff;
}}
.btn-danger:hover {{
background: #dc2626;
}}
.main {{
display: flex;
flex: 1;
overflow: hidden;
}}
.library {{
width: 180px;
background: #fff;
border-right: 1px solid #ddd;
display: flex;
flex-direction: column;
}}
.lib-header {{
padding: 10px 12px;
border-bottom: 1px solid #eee;
font-size: 11px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
.lib-content {{
flex: 1;
overflow-y: auto;
padding: 8px;
}}
.lib-hint {{
text-align: center;
padding: 20px;
color: #999;
font-size: 11px;
}}
.media-grid {{
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}}
.media-item {{
aspect-ratio: 16/9;
background: #f0f0f0;
border-radius: 6px;
overflow: hidden;
cursor: grab;
position: relative;
border: 2px solid transparent;
transition: all 0.2s;
}}
.media-item:hover {{
border-color: #6366f1;
transform: scale(1.02);
}}
.media-item img {{
width: 100%;
height: 100%;
object-fit: cover;
}}
.media-item-icon {{
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}}
.media-item-dur {{
position: absolute;
bottom: 3px;
right: 3px;
background: rgba(0,0,0,0.75);
padding: 2px 5px;
border-radius: 3px;
font-size: 9px;
color: #fff;
font-weight: 500;
}}
.media-item-name {{
position: absolute;
bottom: 3px;
left: 3px;
right: 35px;
background: rgba(0,0,0,0.75);
padding: 2px 5px;
border-radius: 3px;
font-size: 8px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}}
.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.3);
}}
.preview-box {{
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
position: relative;
}}
#previewCanvas {{
max-width: 100%;
max-height: 100%;
background: #000;
}}
.controls {{
height: 50px;
background: linear-gradient(to top, #1a1a1a, #222);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 15px;
}}
.ctrl-btn {{
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: rgba(255,255,255,0.1);
color: #fff;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}}
.ctrl-btn:hover {{
background: rgba(255,255,255,0.2);
transform: scale(1.1);
}}
.ctrl-btn.play {{
width: 40px;
height: 40px;
background: #6366f1;
font-size: 14px;
}}
.ctrl-btn.play:hover {{
background: #4f46e5;
}}
.time-display {{
font-family: 'SF Mono', Monaco, monospace;
font-size: 11px;
color: #aaa;
min-width: 100px;
text-align: center;
background: rgba(0,0,0,0.3);
padding: 4px 10px;
border-radius: 4px;
}}
.props {{
width: 160px;
background: #fff;
border-left: 1px solid #ddd;
display: flex;
flex-direction: column;
}}
.props-header {{
padding: 10px 12px;
border-bottom: 1px solid #eee;
font-size: 11px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}}
.props-content {{
flex: 1;
padding: 12px;
overflow-y: auto;
}}
.no-sel {{
color: #999;
text-align: center;
padding: 20px;
font-size: 11px;
}}
.prop-group {{
margin-bottom: 12px;
}}
.prop-label {{
font-size: 10px;
color: #666;
margin-bottom: 4px;
font-weight: 500;
}}
.prop-input {{
width: 100%;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 11px;
}}
.prop-input:focus {{
outline: none;
border-color: #6366f1;
}}
.timeline {{
height: 150px;
background: #fff;
border-top: 1px solid #ddd;
display: flex;
flex-direction: column;
}}
.tl-toolbar {{
height: 32px;
background: #fafafa;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
padding: 0 8px;
gap: 6px;
}}
.tl-toolbar .btn {{
padding: 4px 8px;
font-size: 10px;
}}
.tl-zoom {{
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 10px;
color: #666;
}}
.tl-zoom input {{
width: 60px;
}}
.tl-container {{
flex: 1;
overflow-x: auto;
position: relative;
}}
.tl-ruler {{
height: 20px;
background: #fff;
border-bottom: 1px solid #eee;
position: sticky;
top: 0;
}}
.tl-tracks {{
position: relative;
}}
.tl-track {{
height: 50px;
border-bottom: 1px solid #eee;
display: flex;
}}
.tl-track:nth-child(2) {{
background: #fffbeb;
}}
.track-label {{
width: 50px;
padding: 0 6px;
font-size: 9px;
color: #666;
background: #fafafa;
display: flex;
align-items: center;
border-right: 1px solid #eee;
font-weight: 500;
}}
.track-content {{
flex: 1;
position: relative;
min-width: 800px;
}}
.clip {{
position: absolute;
height: 40px;
top: 5px;
border-radius: 6px;
cursor: grab;
display: flex;
align-items: center;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.2s;
}}
.clip:hover {{
box-shadow: 0 0 0 2px #6366f1;
}}
.clip.selected {{
box-shadow: 0 0 0 2px #6366f1;
}}
.clip.video {{
background: linear-gradient(135deg, #818cf8, #6366f1);
}}
.clip.image {{
background: linear-gradient(135deg, #34d399, #10b981);
}}
.clip.audio {{
background: linear-gradient(135deg, #fbbf24, #f59e0b);
}}
.clip-thumb {{
width: 40px;
height: 100%;
object-fit: cover;
}}
.clip-info {{
padding: 0 6px;
flex: 1;
overflow: hidden;
}}
.clip-name {{
font-size: 9px;
color: #fff;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}}
.clip-dur {{
font-size: 8px;
color: rgba(255,255,255,0.8);
margin-top: 2px;
}}
.clip-handle {{
position: absolute;
top: 0;
bottom: 0;
width: 8px;
background: rgba(255,255,255,0.5);
cursor: ew-resize;
opacity: 0;
transition: opacity 0.2s;
}}
.clip:hover .clip-handle {{
opacity: 1;
}}
.clip-handle-l {{
left: 0;
border-radius: 6px 0 0 6px;
}}
.clip-handle-r {{
right: 0;
border-radius: 0 6px 6px 0;
}}
.playhead {{
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #ef4444;
z-index: 10;
pointer-events: none;
}}
.playhead::before {{
content: "";
position: absolute;
top: 0;
left: -5px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid #ef4444;
}}
.drop-zone {{
background: rgba(99, 102, 241, 0.1) !important;
outline: 2px dashed #6366f1 !important;
}}
.status {{
height: 24px;
background: #f5f5f5;
border-top: 1px solid #ddd;
display: flex;
align-items: center;
padding: 0 12px;
font-size: 10px;
color: #666;
}}
.ctx-menu {{
position: fixed;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 4px 0;
min-width: 120px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: none;
}}
.ctx-item {{
padding: 8px 12px;
cursor: pointer;
font-size: 11px;
transition: background 0.2s;
}}
.ctx-item:hover {{
background: #f5f5f5;
}}
.ctx-item.danger {{
color: #ef4444;
}}
.hidden-media {{
position: absolute;
left: -9999px;
top: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}}
</style>
</head>
<body>
<div class="editor">
<div class="toolbar">
<div class="toolbar-title">๐ŸŽฌ Video Editor</div>
<div class="toolbar-actions">
<button class="btn btn-secondary" onclick="undo()">โ†ฉ ์‹คํ–‰์ทจ์†Œ</button>
<button class="btn btn-success" onclick="copyExportData()">๐Ÿ“‹ ํƒ€์ž„๋ผ์ธ ๋ณต์‚ฌ</button>
</div>
</div>
<div class="main">
<div class="library">
<div class="lib-header">๐Ÿ“ ๋ฏธ๋””์–ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ</div>
<div class="lib-content">
<div class="lib-hint" id="hint">ํŒŒ์ผ์„ ์—…๋กœ๋“œํ•˜์„ธ์š”</div>
<div class="media-grid" id="mediaGrid"></div>
</div>
</div>
<div class="preview-area">
<div class="preview-box">
<canvas id="previewCanvas" width="640" height="360"></canvas>
</div>
<div class="controls">
<button class="ctrl-btn" onclick="seek(0)">โฎ</button>
<button class="ctrl-btn" onclick="seek(S.time-5)">โช</button>
<button class="ctrl-btn play" onclick="togglePlay()" id="playBtn">โ–ถ</button>
<button class="ctrl-btn" onclick="seek(S.time+5)">โฉ</button>
<button class="ctrl-btn" onclick="seek(S.dur)">โญ</button>
<div class="time-display">
<span id="curT">00:00.00</span> / <span id="totT">00:00.00</span>
</div>
<button class="ctrl-btn" onclick="toggleMute()" id="muteBtn">๐Ÿ”Š</button>
</div>
</div>
<div class="props">
<div class="props-header">โš™๏ธ ์†์„ฑ</div>
<div class="props-content" id="propsBox">
<div class="no-sel">ํด๋ฆฝ์„ ์„ ํƒํ•˜์„ธ์š”</div>
</div>
</div>
</div>
<div class="timeline">
<div class="tl-toolbar">
<button class="btn btn-secondary" onclick="splitClip()">โœ‚ ์ž๋ฅด๊ธฐ</button>
<button class="btn btn-secondary" onclick="dupeClip()">๐Ÿ“‹ ๋ณต์ œ</button>
<button class="btn btn-danger" onclick="delClip()">๐Ÿ—‘ ์‚ญ์ œ</button>
<div class="tl-zoom">
๐Ÿ” <input type="range" min="0.5" max="3" step="0.1" value="1" oninput="setZoom(this.value)">
</div>
</div>
<div class="tl-container" id="tlBox" onclick="tlClick(event)">
<div class="tl-ruler" id="ruler"></div>
<div class="tl-tracks">
<div class="tl-track">
<div class="track-label">๐ŸŽฌ ์˜์ƒ</div>
<div class="track-content" id="t0"></div>
</div>
<div class="tl-track">
<div class="track-label">๐ŸŽต ์˜ค๋””์˜ค</div>
<div class="track-content" id="t1"></div>
</div>
</div>
<div class="playhead" id="playhead" style="left:50px"></div>
</div>
</div>
<div class="status" id="status">์ค€๋น„๋จ</div>
</div>
<div class="ctx-menu" id="ctx">
<div class="ctx-item" onclick="splitClip()">โœ‚ ์ž๋ฅด๊ธฐ</div>
<div class="ctx-item" onclick="dupeClip()">๐Ÿ“‹ ๋ณต์ œ</div>
<div class="ctx-item danger" onclick="delClip()">๐Ÿ—‘ ์‚ญ์ œ</div>
</div>
<div id="hiddenMedia" class="hidden-media"></div>
<script>
// ์ƒํƒœ ๊ฐ์ฒด
var S = {{
media: [],
clips: [],
sel: null,
playing: false,
muted: false,
time: 0,
dur: 0,
zoom: 1,
pps: 80,
history: [],
animId: null,
els: {{}},
canvas: null,
ctx: null
}};
// ์ดˆ๊ธฐํ™”
function init() {{
S.canvas = document.getElementById('previewCanvas');
S.ctx = S.canvas.getContext('2d');
drawPlaceholder();
}}
// ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜๋“ค
function id() {{
return Math.random().toString(36).substr(2, 9);
}}
function fmt(t) {{
if (!t || isNaN(t)) t = 0;
var m = Math.floor(t / 60);
var s = Math.floor(t % 60);
var ms = Math.floor((t % 1) * 100);
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0') + '.' + String(ms).padStart(2, '0');
}}
function r(n) {{
return Math.round(n * 1000) / 1000;
}}
function stat(m) {{
document.getElementById('status').textContent = m;
}}
function save() {{
S.history.push(JSON.stringify(S.clips));
if (S.history.length > 30) S.history.shift();
}}
// ํ”Œ๋ ˆ์ด์Šคํ™€๋” ๊ทธ๋ฆฌ๊ธฐ
function drawPlaceholder() {{
S.ctx.fillStyle = '#000';
S.ctx.fillRect(0, 0, 640, 360);
S.ctx.fillStyle = '#444';
S.ctx.font = '14px sans-serif';
S.ctx.textAlign = 'center';
S.ctx.fillText('ํƒ€์ž„๋ผ์ธ์— ๋ฏธ๋””์–ด๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”', 320, 180);
}}
// ๋ฏธ๋””์–ด ์ถ”๊ฐ€
function addMedia(name, type, url, filePath) {{
var m = {{
id: id(),
name: name,
type: type,
url: url,
filePath: filePath || name,
dur: type === 'image' ? 5 : 0,
thumb: type === 'image' ? url : null,
loaded: type === 'image' ? true : false
}};
S.media.push(m);
var container = document.getElementById('hiddenMedia');
if (type === 'video') {{
var v = document.createElement('video');
v.src = url;
v.muted = true;
v.playsInline = true;
v.preload = 'auto';
v.crossOrigin = 'anonymous';
container.appendChild(v);
S.els[m.id] = v;
v.onloadedmetadata = function() {{
m.dur = r(v.duration);
m.loaded = true;
renderLib();
v.currentTime = 0.5;
// ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋กœ๋“œ ํ›„ ํด๋ฆฝ ์ถ”๊ฐ€
addClip(m);
console.log("[VideoEditor] Video loaded:", m.name, m.dur);
}};
v.onseeked = function() {{
if (!m.thumb) {{
try {{
var c = document.createElement('canvas');
c.width = 160;
c.height = 90;
c.getContext('2d').drawImage(v, 0, 0, 160, 90);
m.thumb = c.toDataURL();
renderLib();
}} catch(e) {{}}
}}
}};
v.onerror = function() {{
console.error("[VideoEditor] Video load error:", m.name);
m.loaded = true;
m.dur = 5;
renderLib();
addClip(m);
}};
}} else if (type === 'audio') {{
var a = document.createElement('audio');
a.src = url;
a.preload = 'auto';
container.appendChild(a);
S.els[m.id] = a;
a.onloadedmetadata = function() {{
m.dur = r(a.duration);
m.loaded = true;
renderLib();
// ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ๋กœ๋“œ ํ›„ ํด๋ฆฝ ์ถ”๊ฐ€
addClip(m);
console.log("[VideoEditor] Audio loaded:", m.name, m.dur);
}};
a.onerror = function() {{
console.error("[VideoEditor] Audio load error:", m.name);
m.loaded = true;
m.dur = 5;
renderLib();
addClip(m);
}};
}} else if (type === 'image') {{
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {{
m.loaded = true;
renderLib();
// ์ด๋ฏธ์ง€ ๋กœ๋“œ ํ›„ ํด๋ฆฝ ์ถ”๊ฐ€
addClip(m);
console.log("[VideoEditor] Image loaded:", m.name);
}};
img.onerror = function() {{
console.error("[VideoEditor] Image load error:", m.name);
m.loaded = true;
addClip(m);
}};
img.src = url;
S.els[m.id] = img;
}}
renderLib();
stat('๋ฏธ๋””์–ด ๋กœ๋”ฉ: ' + name);
}}
// ๋ฏธ๋””์–ด ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๋ Œ๋”๋ง
function renderLib() {{
var g = document.getElementById('mediaGrid');
var h = document.getElementById('hint');
h.style.display = S.media.length ? 'none' : 'block';
g.innerHTML = '';
S.media.forEach(function(m) {{
var d = document.createElement('div');
d.className = 'media-item';
d.draggable = true;
d.ondblclick = function() {{ addClip(m); }};
d.ondragstart = function(e) {{ e.dataTransfer.setData('mid', m.id); }};
var th = m.thumb ? '<img src="' + m.thumb + '">' : '<div class="media-item-icon">' + (m.type === 'video' ? '๐ŸŽฌ' : m.type === 'audio' ? '๐ŸŽต' : '๐Ÿ–ผ') + '</div>';
d.innerHTML = th + (m.dur ? '<div class="media-item-dur">' + fmt(m.dur) + '</div>' : '');
g.appendChild(d);
}});
}}
// ํŠธ๋ž™ ๋ ์œ„์น˜ ๊ณ„์‚ฐ
function trackEnd(tr) {{
var end = 0;
for (var i = 0; i < S.clips.length; i++) {{
var c = S.clips[i];
if (c.track === tr) {{
var e = r(c.start + (c.te - c.ts));
if (e > end) end = e;
}}
}}
return end;
}}
// ํด๋ฆฝ ์ถ”๊ฐ€
function addClip(m, at) {{
save();
var tr = m.type === 'audio' ? 1 : 0;
var st = at !== undefined ? r(at) : trackEnd(tr);
S.clips.push({{
id: id(),
mid: m.id,
name: m.name,
type: m.type,
track: tr,
start: st,
dur: m.dur,
ts: 0,
te: m.dur,
vol: 1,
filePath: m.filePath
}});
renderTL();
updateDur();
stat('ํด๋ฆฝ ์ถ”๊ฐ€: ' + m.name);
drawFrame();
}}
// ํƒ€์ž„๋ผ์ธ ๋ Œ๋”๋ง
function renderTL() {{
['t0', 't1'].forEach(function(tid) {{
document.getElementById(tid).innerHTML = '';
}});
S.clips.forEach(function(c) {{
var tr = document.getElementById('t' + c.track);
var el = document.createElement('div');
el.className = 'clip ' + c.type + (S.sel === c.id ? ' selected' : '');
var len = r(c.te - c.ts);
el.style.left = r(c.start * S.pps * S.zoom) + 'px';
el.style.width = Math.max(30, r(len * S.pps * S.zoom)) + 'px';
el.draggable = true;
el.onclick = function(e) {{ e.stopPropagation(); selClip(c.id); }};
el.oncontextmenu = function(e) {{ e.preventDefault(); selClip(c.id); showCtx(e.clientX, e.clientY); }};
el.ondragstart = function(e) {{ e.dataTransfer.setData('cid', c.id); e.dataTransfer.setData('ox', e.offsetX); }};
var m = S.media.find(function(x) {{ return x.id === c.mid; }});
var th = m && m.thumb ? '<img class="clip-thumb" src="' + m.thumb + '">' : '';
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>';
el.querySelector('.clip-handle-l').onmousedown = function(e) {{ e.stopPropagation(); startTrim(c.id, 'l', e); }};
el.querySelector('.clip-handle-r').onmousedown = function(e) {{ e.stopPropagation(); startTrim(c.id, 'r', e); }};
tr.appendChild(el);
}});
renderRuler();
setupDrop();
}}
// ๋ฃฐ๋Ÿฌ ๋ Œ๋”๋ง
function renderRuler() {{
var ru = document.getElementById('ruler');
var w = Math.max(r(S.dur * S.pps * S.zoom) + 200, 800);
ru.style.width = w + 'px';
var h = '<svg width="100%" height="20" style="position:absolute;left:50px">';
var step = S.zoom < 0.7 ? 5 : S.zoom < 1.5 ? 2 : 1;
for (var i = 0; i <= S.dur + 10; i += step) {{
var x = r(i * S.pps * S.zoom);
h += '<line x1="' + x + '" y1="14" x2="' + x + '" y2="20" stroke="#ccc"/>';
h += '<text x="' + x + '" y="11" fill="#999" font-size="9" text-anchor="middle">' + fmt(i) + '</text>';
}}
ru.innerHTML = h + '</svg>';
}}
// ๋“œ๋กญ์กด ์„ค์ •
function setupDrop() {{
['t0', 't1'].forEach(function(tid, idx) {{
var tr = document.getElementById(tid);
tr.ondragover = function(e) {{
e.preventDefault();
tr.classList.add('drop-zone');
}};
tr.ondragleave = function() {{
tr.classList.remove('drop-zone');
}};
tr.ondrop = function(e) {{
e.preventDefault();
tr.classList.remove('drop-zone');
var rect = tr.getBoundingClientRect();
var t = r(Math.max(0, (e.clientX - rect.left) / (S.pps * S.zoom)));
var mid = e.dataTransfer.getData('mid');
var cid = e.dataTransfer.getData('cid');
var ox = parseFloat(e.dataTransfer.getData('ox') || 0);
if (mid) {{
var m = S.media.find(function(x) {{ return x.id === mid; }});
if (m) addClip(m, t);
}} else if (cid) {{
save();
var c = S.clips.find(function(x) {{ return x.id === cid; }});
if (c) {{
c.start = r(Math.max(0, t - ox / (S.pps * S.zoom)));
c.track = c.type === 'audio' ? 1 : idx;
renderTL();
updateDur();
drawFrame();
}}
}}
}};
}});
}}
// ํŠธ๋ฆผ ๊ด€๋ จ
var trimData = null;
function startTrim(cid, side, e) {{
e.preventDefault();
var c = S.clips.find(function(x) {{ return x.id === cid; }});
if (!c) return;
save();
trimData = {{ cid: cid, side: side, sx: e.clientX, ots: c.ts, ote: c.te, ost: c.start }};
document.addEventListener('mousemove', doTrim);
document.addEventListener('mouseup', endTrim);
}}
function doTrim(e) {{
if (!trimData) return;
var c = S.clips.find(function(x) {{ return x.id === trimData.cid; }});
if (!c) return;
var dx = e.clientX - trimData.sx;
var dt = r(dx / (S.pps * S.zoom));
if (trimData.side === 'l') {{
var newTs = Math.max(0, Math.min(c.te - 0.1, trimData.ots + dt));
c.ts = r(newTs);
c.start = r(trimData.ost + (newTs - trimData.ots));
}} else {{
c.te = r(Math.max(c.ts + 0.1, Math.min(c.dur, trimData.ote + dt)));
}}
renderTL();
updateDur();
}}
function endTrim() {{
trimData = null;
document.removeEventListener('mousemove', doTrim);
document.removeEventListener('mouseup', endTrim);
}}
// ํด๋ฆฝ ์„ ํƒ
function selClip(cid) {{
S.sel = cid;
renderTL();
renderProps();
}}
// ์†์„ฑ ํŒจ๋„ ๋ Œ๋”๋ง
function renderProps() {{
var box = document.getElementById('propsBox');
var c = S.clips.find(function(x) {{ return x.id === S.sel; }});
if (!c) {{
box.innerHTML = '<div class="no-sel">ํด๋ฆฝ์„ ์„ ํƒํ•˜์„ธ์š”</div>';
return;
}}
var len = r(c.te - c.ts);
box.innerHTML = '<div class="prop-group"><div class="prop-label">์ด๋ฆ„</div><input class="prop-input" value="' + c.name + '" onchange="setProp(\'name\',this.value)"></div>' +
'<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>' +
'<div class="prop-group"><div class="prop-label">๊ธธ์ด: ' + fmt(len) + '</div></div>' +
(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>' : '');
}}
function setProp(p, v) {{
save();
var c = S.clips.find(function(x) {{ return x.id === S.sel; }});
if (c) {{
c[p] = p === 'start' ? r(v) : v;
renderTL();
updateDur();
renderProps();
drawFrame();
}}
}}
// ํด๋ฆฝ ๋ถ„ํ• 
function splitClip() {{
if (!S.sel) {{
alert('ํด๋ฆฝ์„ ์„ ํƒํ•˜์„ธ์š”');
return;
}}
var c = S.clips.find(function(x) {{ return x.id === S.sel; }});
if (!c) return;
var cEnd = r(c.start + c.te - c.ts);
if (S.time <= c.start || S.time >= cEnd) {{
alert('ํ”Œ๋ ˆ์ดํ—ค๋“œ๊ฐ€ ํด๋ฆฝ ์•ˆ์— ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค');
return;
}}
save();
var splitAt = r(S.time - c.start);
var c2 = JSON.parse(JSON.stringify(c));
c2.id = id();
c2.start = r(S.time);
c2.ts = r(c.ts + splitAt);
c.te = r(c.ts + splitAt);
S.clips.push(c2);
renderTL();
updateDur();
hideCtx();
stat('ํด๋ฆฝ ๋ถ„ํ• ๋จ');
}}
// ํด๋ฆฝ ๋ณต์ œ
function dupeClip() {{
if (!S.sel) return;
var c = S.clips.find(function(x) {{ return x.id === S.sel; }});
if (!c) return;
save();
var len = r(c.te - c.ts);
var nc = JSON.parse(JSON.stringify(c));
nc.id = id();
nc.start = r(c.start + len);
S.clips.push(nc);
renderTL();
updateDur();
hideCtx();
stat('ํด๋ฆฝ ๋ณต์ œ๋จ');
}}
// ํด๋ฆฝ ์‚ญ์ œ
function delClip() {{
if (!S.sel) return;
save();
S.clips = S.clips.filter(function(x) {{ return x.id !== S.sel; }});
S.sel = null;
renderTL();
renderProps();
updateDur();
hideCtx();
stat('ํด๋ฆฝ ์‚ญ์ œ๋จ');
drawFrame();
}}
// ์‹คํ–‰ ์ทจ์†Œ
function undo() {{
if (S.history.length) {{
S.clips = JSON.parse(S.history.pop());
renderTL();
updateDur();
stat('์‹คํ–‰์ทจ์†Œ');
drawFrame();
}}
}}
// ์ด ๊ธธ์ด ์—…๋ฐ์ดํŠธ
function updateDur() {{
var mx = 0;
for (var i = 0; i < S.clips.length; i++) {{
var c = S.clips[i];
var e = r(c.start + c.te - c.ts);
if (e > mx) mx = e;
}}
S.dur = mx;
document.getElementById('totT').textContent = fmt(mx);
}}
// ์žฌ์ƒ ํ† ๊ธ€
function togglePlay() {{
S.playing = !S.playing;
document.getElementById('playBtn').textContent = S.playing ? 'โธ' : 'โ–ถ';
if (S.playing) play();
else stop();
}}
// ์žฌ์ƒ
function play() {{
var last = performance.now();
function loop(now) {{
if (!S.playing) return;
var dt = (now - last) / 1000;
last = now;
S.time = S.time + dt;
if (S.time >= S.dur) {{
S.time = 0;
if (S.dur === 0) {{
S.playing = false;
document.getElementById('playBtn').textContent = 'โ–ถ';
return;
}}
}}
updateHead();
drawFrame();
S.animId = requestAnimationFrame(loop);
}}
S.animId = requestAnimationFrame(loop);
}}
// ์ •์ง€
function stop() {{
if (S.animId) {{
cancelAnimationFrame(S.animId);
S.animId = null;
}}
Object.keys(S.els).forEach(function(k) {{
var el = S.els[k];
if (el && el.pause) el.pause();
}});
}}
// ์‹œ๊ฐ„ ์ด๋™
function seek(t) {{
S.time = Math.max(0, Math.min(S.dur || 0, t));
updateHead();
drawFrame();
}}
// ํ”Œ๋ ˆ์ดํ—ค๋“œ ์—…๋ฐ์ดํŠธ
function updateHead() {{
document.getElementById('playhead').style.left = (50 + S.time * S.pps * S.zoom) + 'px';
document.getElementById('curT').textContent = fmt(S.time);
}}
// ํŠน์ • ์‹œ๊ฐ„์˜ ํด๋ฆฝ ๊ฐ€์ ธ์˜ค๊ธฐ
function getClipAt(t, type) {{
var sorted = S.clips.filter(function(c) {{
if (type === 'visual') return c.type === 'video' || c.type === 'image';
if (type === 'audio') return c.type === 'audio';
return true;
}}).sort(function(a, b) {{ return a.start - b.start; }});
for (var i = 0; i < sorted.length; i++) {{
var c = sorted[i];
var cEnd = c.start + (c.te - c.ts);
if (t >= c.start && t < cEnd) return c;
}}
return null;
}}
// ํ”„๋ ˆ์ž„ ๊ทธ๋ฆฌ๊ธฐ
function drawFrame() {{
var t = S.time;
var vc = getClipAt(t, 'visual');
S.ctx.fillStyle = '#000';
S.ctx.fillRect(0, 0, 640, 360);
if (vc) {{
var el = S.els[vc.mid];
if (el) {{
if (vc.type === 'video') {{
var clipT = t - vc.start + vc.ts;
if (Math.abs(el.currentTime - clipT) > 0.05) {{
el.currentTime = clipT;
}}
if (S.playing && el.paused) el.play().catch(function() {{}});
if (!S.playing && !el.paused) el.pause();
el.volume = S.muted ? 0 : vc.vol;
}}
try {{
var sw = el.videoWidth || el.naturalWidth || el.width || 640;
var sh = el.videoHeight || el.naturalHeight || el.height || 360;
var scale = Math.min(640 / sw, 360 / sh);
var dw = sw * scale, dh = sh * scale;
var dx = (640 - dw) / 2, dy = (360 - dh) / 2;
S.ctx.drawImage(el, dx, dy, dw, dh);
}} catch(e) {{}}
}}
}} else if (S.clips.length === 0) {{
S.ctx.fillStyle = '#444';
S.ctx.font = '14px sans-serif';
S.ctx.textAlign = 'center';
S.ctx.fillText('ํƒ€์ž„๋ผ์ธ์— ๋ฏธ๋””์–ด๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”', 320, 180);
}}
// ์˜ค๋””์˜ค ํด๋ฆฝ ์ฒ˜๋ฆฌ
var audioClips = S.clips.filter(function(c) {{
if (c.type !== 'audio') return false;
var cEnd = c.start + (c.te - c.ts);
return t >= c.start && t < cEnd;
}});
audioClips.forEach(function(ac) {{
var el = S.els[ac.mid];
if (el) {{
var clipT = t - ac.start + ac.ts;
if (Math.abs(el.currentTime - clipT) > 0.1) el.currentTime = clipT;
el.volume = S.muted ? 0 : ac.vol;
if (S.playing && el.paused) el.play().catch(function() {{}});
if (!S.playing && !el.paused) el.pause();
}}
}});
// ๋ฒ”์œ„ ๋ฐ– ์˜ค๋””์˜ค ์ •์ง€
S.clips.forEach(function(c) {{
if (c.type !== 'audio') return;
var cEnd = c.start + (c.te - c.ts);
if (t < c.start || t >= cEnd) {{
var el = S.els[c.mid];
if (el && !el.paused) el.pause();
}}
}});
// ๋นˆ ํ”„๋ ˆ์ž„ ํ‘œ์‹œ
if (!vc && !audioClips.length && S.clips.length > 0) {{
S.ctx.fillStyle = '#333';
S.ctx.font = '12px sans-serif';
S.ctx.textAlign = 'center';
S.ctx.fillText('์žฌ์ƒ ์œ„์น˜์— ๋ฏธ๋””์–ด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค', 320, 180);
}}
}}
// ์Œ์†Œ๊ฑฐ ํ† ๊ธ€
function toggleMute() {{
S.muted = !S.muted;
document.getElementById('muteBtn').textContent = S.muted ? '๐Ÿ”‡' : '๐Ÿ”Š';
}}
// ์คŒ ์„ค์ •
function setZoom(v) {{
S.zoom = parseFloat(v);
renderTL();
updateHead();
}}
// ํƒ€์ž„๋ผ์ธ ํด๋ฆญ
function tlClick(e) {{
if (e.target.closest('.clip')) return;
var rect = document.getElementById('tlBox').getBoundingClientRect();
var scrollL = document.getElementById('tlBox').scrollLeft;
S.time = Math.max(0, Math.min(S.dur || 0, (e.clientX - rect.left - 50 + scrollL) / (S.pps * S.zoom)));
updateHead();
drawFrame();
}}
// ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด
function showCtx(x, y) {{
var m = document.getElementById('ctx');
m.style.display = 'block';
m.style.left = x + 'px';
m.style.top = y + 'px';
}}
function hideCtx() {{
document.getElementById('ctx').style.display = 'none';
}}
document.addEventListener('click', function(e) {{
if (!e.target.closest('.ctx-menu')) hideCtx();
}});
// ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ
function getExportData() {{
var clipsData = [];
for (var i = 0; i < S.clips.length; i++) {{
var c = S.clips[i];
var m = S.media.find(function(x) {{ return x.id === c.mid; }});
clipsData.push({{
filePath: m ? m.filePath : c.name,
type: c.type,
start: c.start,
ts: c.ts,
te: c.te,
vol: c.vol
}});
}}
return JSON.stringify({{ clips: clipsData }});
}}
// ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฐ์ดํ„ฐ ๋ณต์‚ฌ
function copyExportData() {{
var data = getExportData();
navigator.clipboard.writeText(data).then(function() {{
stat('ํƒ€์ž„๋ผ์ธ ๋ฐ์ดํ„ฐ ๋ณต์‚ฌ๋จ! ์œ„์˜ ํ…์ŠคํŠธ๋ฐ•์Šค์— ๋ถ™์—ฌ๋„ฃ๊ธฐ ํ›„ MP4 ๋‚ด๋ณด๋‚ด๊ธฐ ํด๋ฆญ');
alert('ํƒ€์ž„๋ผ์ธ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ณต์‚ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!\\n\\n1. ์œ„์˜ ํ…์ŠคํŠธ๋ฐ•์Šค์— Ctrl+V๋กœ ๋ถ™์—ฌ๋„ฃ๊ธฐ\\n2. MP4 ๋‚ด๋ณด๋‚ด๊ธฐ ๋ฒ„ํŠผ ํด๋ฆญ');
}}).catch(function() {{
// fallback
var ta = document.createElement('textarea');
ta.value = data;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
stat('ํƒ€์ž„๋ผ์ธ ๋ฐ์ดํ„ฐ ๋ณต์‚ฌ๋จ!');
alert('ํƒ€์ž„๋ผ์ธ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ณต์‚ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!');
}});
}}
// ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค
document.addEventListener('keydown', function(e) {{
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.code === 'Space') {{
e.preventDefault();
togglePlay();
}} else if (e.code === 'Delete') {{
e.preventDefault();
delClip();
}} else if (e.code === 'ArrowLeft') {{
seek(S.time - 0.1);
}} else if (e.code === 'ArrowRight') {{
seek(S.time + 0.1);
}}
}});
// ์ดˆ๊ธฐํ™” ์‹คํ–‰
init();
renderTL();
stat('์ค€๋น„๋จ | ๋‹จ์ถ•ํ‚ค: Space(์žฌ์ƒ), Delete(์‚ญ์ œ), โ†โ†’(์ด๋™)');
// ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ๋กœ๋“œ
var initData = {media_data};
if (initData && initData.length) {{
initData.forEach(function(m) {{
addMedia(m.name, m.type, m.dataUrl, m.filePath);
}});
}}
</script>
</body>
</html>'''
def process_files(files):
"""ํŒŒ์ผ ์ฒ˜๋ฆฌ ๋ฐ ์„œ๋ฒ„์— ์ €์žฅ"""
global uploaded_files
if not files:
return []
results = []
file_list = files if isinstance(files, list) else [files]
for f in file_list:
if not f:
continue
path = f.name if hasattr(f, 'name') else f
name = os.path.basename(path)
ext = name.lower().split('.')[-1]
if ext in ['mp4', 'webm', 'mov', 'avi', 'mkv']:
t, m = 'video', f'video/{ext}'
elif ext in ['jpg', 'jpeg', 'png', 'gif', 'webp']:
t, m = 'image', f'image/{ext}'
elif ext in ['mp3', 'wav', 'ogg', 'm4a', 'aac']:
t, m = 'audio', f'audio/{ext}'
else:
continue
# ์„œ๋ฒ„์— ํŒŒ์ผ ๋ณต์‚ฌ
dst_path = os.path.join(UPLOAD_DIR, f"{int(time.time()*1000)}_{name}")
shutil.copy(path, dst_path)
uploaded_files[name] = dst_path
print(f"[Upload] {name} -> {dst_path}")
# base64 ์ธ์ฝ”๋”ฉ (๋ฏธ๋ฆฌ๋ณด๊ธฐ์šฉ)
with open(path, 'rb') as fp:
d = base64.b64encode(fp.read()).decode()
results.append({
'name': name,
'type': t,
'dataUrl': f'data:{m};base64,{d}',
'filePath': name
})
return results
def make_iframe(data):
"""์—๋””ํ„ฐ iframe ์ƒ์„ฑ - data URL๋กœ ์ด์Šค์ผ€์ดํ”„ ๋ฌธ์ œ ์™„์ „ ํšŒํ”ผ"""
j = json.dumps(data, ensure_ascii=False)
html_content = get_editor_html(j)
# base64 ์ธ์ฝ”๋”ฉ ํ›„ data URL๋กœ ์‚ฌ์šฉ
b64 = base64.b64encode(html_content.encode('utf-8')).decode('utf-8')
return f'<iframe src="data:text/html;base64,{b64}" style="width:100%;height:700px;border:none;border-radius:10px"></iframe>'
def export_mp4(export_json):
"""ํƒ€์ž„๋ผ์ธ ๋ฐ์ดํ„ฐ๋กœ MP4 ์ƒ์„ฑ"""
global uploaded_files
if not export_json or len(export_json) < 10:
print("[Export] No data")
return None
try:
data = json.loads(export_json)
clips = data.get('clips', [])
if not clips:
print("[Export] No clips")
return None
# ์˜์ƒ ํด๋ฆฝ๋งŒ ํ•„ํ„ฐ๋ง
video_clips = [c for c in clips if c['type'] in ['video', 'image']]
if not video_clips:
print("[Export] No video clips")
return None
temp_dir = tempfile.mkdtemp()
output_path = os.path.join(temp_dir, f'output_{int(time.time())}.mp4')
# ๋‹จ์ผ ํด๋ฆฝ์ธ ๊ฒฝ์šฐ
if len(video_clips) == 1:
clip = video_clips[0]
file_path = uploaded_files.get(clip['filePath'])
if not file_path or not os.path.exists(file_path):
print(f"[Export] File not found: {clip['filePath']}")
print(f"[Export] Available files: {list(uploaded_files.keys())}")
return None
duration = clip['te'] - clip['ts']
if clip['type'] == 'image':
cmd = [
'ffmpeg', '-y',
'-loop', '1',
'-i', file_path,
'-c:v', 'libx264',
'-t', str(duration),
'-pix_fmt', 'yuv420p',
'-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2',
output_path
]
else:
cmd = [
'ffmpeg', '-y',
'-i', file_path,
'-ss', str(clip['ts']),
'-t', str(duration),
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '23',
'-c:a', 'aac',
'-b:a', '128k',
'-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2',
'-movflags', '+faststart',
output_path
]
print(f"[Export] Running: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
print(f"[Export] Success: {output_path}, size: {os.path.getsize(output_path)}")
return output_path
else:
print(f"[Export] FFmpeg error: {result.stderr[:500]}")
return None
# ์—ฌ๋Ÿฌ ํด๋ฆฝ - concat ์‚ฌ์šฉ
temp_files = []
concat_file = os.path.join(temp_dir, 'concat.txt')
for i, clip in enumerate(sorted(video_clips, key=lambda x: x['start'])):
file_path = uploaded_files.get(clip['filePath'])
if not file_path or not os.path.exists(file_path):
print(f"[Export] Skip clip, file not found: {clip['filePath']}")
continue
temp_out = os.path.join(temp_dir, f'temp_{i}.mp4')
duration = clip['te'] - clip['ts']
if clip['type'] == 'image':
cmd = [
'ffmpeg', '-y',
'-loop', '1',
'-i', file_path,
'-c:v', 'libx264',
'-t', str(duration),
'-pix_fmt', 'yuv420p',
'-r', '30',
'-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2',
temp_out
]
else:
cmd = [
'ffmpeg', '-y',
'-i', file_path,
'-ss', str(clip['ts']),
'-t', str(duration),
'-c:v', 'libx264',
'-preset', 'fast',
'-c:a', 'aac',
'-r', '30',
'-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2',
temp_out
]
print(f"[Export] Processing clip {i}: {' '.join(cmd)}")
subprocess.run(cmd, capture_output=True, timeout=120)
if os.path.exists(temp_out) and os.path.getsize(temp_out) > 0:
temp_files.append(temp_out)
print(f"[Export] Clip {i} done: {temp_out}")
if not temp_files:
print("[Export] No temp files created")
return None
# concat ํŒŒ์ผ ์ƒ์„ฑ
with open(concat_file, 'w') as f:
for tf in temp_files:
f.write(f"file '{tf}'\n")
# ํ•ฉ์น˜๊ธฐ
cmd = [
'ffmpeg', '-y',
'-f', 'concat',
'-safe', '0',
'-i', concat_file,
'-c:v', 'libx264',
'-preset', 'fast',
'-c:a', 'aac',
'-movflags', '+faststart',
output_path
]
print(f"[Export] Concat: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
# ์ž„์‹œ ํŒŒ์ผ ์ •๋ฆฌ
for tf in temp_files:
try:
os.remove(tf)
except:
pass
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
print(f"[Export] Final success: {output_path}, size: {os.path.getsize(output_path)}")
return output_path
else:
print(f"[Export] Concat error: {result.stderr[:500]}")
return None
except Exception as e:
print(f"[Export] Error: {e}")
import traceback
traceback.print_exc()
return None
# Gradio ์ธํ„ฐํŽ˜์ด์Šค
with gr.Blocks(title="Video Editor") as demo:
gr.Markdown("## ๐ŸŽฌ Video Editor - MP4 ๋‚ด๋ณด๋‚ด๊ธฐ")
gr.Markdown("**์‚ฌ์šฉ๋ฒ•**: 1๏ธโƒฃ ํŒŒ์ผ ์—…๋กœ๋“œ โ†’ 2๏ธโƒฃ ์—๋””ํ„ฐ์—์„œ ํŽธ์ง‘ โ†’ 3๏ธโƒฃ 'ํƒ€์ž„๋ผ์ธ ๋ณต์‚ฌ' ํด๋ฆญ โ†’ 4๏ธโƒฃ ์•„๋ž˜ ํ…์ŠคํŠธ๋ฐ•์Šค์— ๋ถ™์—ฌ๋„ฃ๊ธฐ โ†’ 5๏ธโƒฃ 'MP4 ๋‚ด๋ณด๋‚ด๊ธฐ' ํด๋ฆญ")
file_input = gr.File(
label="๐Ÿ“ ํŒŒ์ผ ์—…๋กœ๋“œ (๋™์˜์ƒ, ์ด๋ฏธ์ง€, ์˜ค๋””์˜ค)",
file_count="multiple",
file_types=["video", "image", "audio"]
)
with gr.Row():
export_data = gr.Textbox(
label="๐Ÿ“‹ ํƒ€์ž„๋ผ์ธ ๋ฐ์ดํ„ฐ",
placeholder="์—๋””ํ„ฐ์—์„œ 'ํƒ€์ž„๋ผ์ธ ๋ณต์‚ฌ' ํด๋ฆญ ํ›„ ์—ฌ๊ธฐ์— Ctrl+V๋กœ ๋ถ™์—ฌ๋„ฃ๊ธฐ",
lines=2,
scale=4
)
export_btn = gr.Button("๐ŸŽฌ MP4 ๋‚ด๋ณด๋‚ด๊ธฐ", variant="primary", scale=1)
mp4_output = gr.File(label="๐Ÿ“ฅ MP4 ๋‹ค์šด๋กœ๋“œ")
editor = gr.HTML(value=make_iframe([]))
# ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ
file_input.change(
fn=lambda x: make_iframe(process_files(x)),
inputs=[file_input],
outputs=[editor]
)
export_btn.click(
fn=export_mp4,
inputs=[export_data],
outputs=[mp4_output]
)
if __name__ == "__main__":
demo.launch()