| """ |
| 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 = {} |
|
|
| 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}") |
| |
| |
| 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) |
| |
| 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 |
| |
| |
| 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 |
| |
| |
| 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 |
|
|
|
|
| |
| 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() |
| |