"""
Simple Video Editor - Canvas 기반 렌더링
빈 프레임 문제 완전 해결 + 서버 사이드 MP4 내보내기
블러 배경 내보내기 수정: 더블 버퍼링
🎨 Comic Classic Theme 적용
"""
import gradio as gr
import base64
import os
import json
import subprocess
import tempfile
import shutil
import time
# 서버 사이드 MP4 내보내기용
UPLOAD_DIR = tempfile.mkdtemp()
uploaded_files = {} # {filename: filepath}
def get_editor_html(media_data="[]"):
return f'''
⏮
⏪
▶
⏩
⏭
00:00.00 / 00:00.00
🔊
준비됨
'''
def process_file(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
# 서버에 파일 복사 (MP4 내보내기용)
dst_path = os.path.join(UPLOAD_DIR, f"{int(time.time()*1000)}_{name}")
shutil.copy(path, dst_path)
uploaded_files[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):
j = json.dumps(data, ensure_ascii=False)
h = get_editor_html(j).replace("'", "'")
return f""
def export_mp4(export_json):
"""서버 사이드 MP4 내보내기"""
global uploaded_files
if not export_json or len(export_json) < 10:
return None
try:
data = json.loads(export_json)
clips = data.get('clips', [])
if not clips:
return None
video_clips = [c for c in clips if c['type'] in ['video', 'image']]
if not 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):
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]
subprocess.run(cmd, capture_output=True, timeout=300)
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
return output_path
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):
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]
subprocess.run(cmd, capture_output=True, timeout=120)
if os.path.exists(temp_out):
temp_files.append(temp_out)
if not temp_files:
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]
subprocess.run(cmd, capture_output=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:
return output_path
return None
except Exception as e:
print(f"[Export] Error: {e}")
return None
def convert_webm_to_mp4(webm_base64):
"""WebM base64 데이터를 MP4로 변환"""
if not webm_base64 or len(webm_base64) < 100:
return None
try:
# base64 디코딩
webm_data = base64.b64decode(webm_base64)
temp_dir = tempfile.mkdtemp()
webm_path = os.path.join(temp_dir, 'input.webm')
mp4_path = os.path.join(temp_dir, f'output_{int(time.time())}.mp4')
# WebM 파일 저장
with open(webm_path, 'wb') as f:
f.write(webm_data)
# FFmpeg로 MP4 변환
cmd = [
'ffmpeg', '-y',
'-i', webm_path,
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '23',
'-c:a', 'aac',
'-b:a', '128k',
'-movflags', '+faststart',
mp4_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
# WebM 파일 삭제
try:
os.remove(webm_path)
except:
pass
if os.path.exists(mp4_path) and os.path.getsize(mp4_path) > 0:
return mp4_path
return None
except Exception as e:
print(f"[Convert] Error: {e}")
return None
# ============================================
# 🎨 Comic Classic Theme CSS
# ============================================
css = """
/* ===== 🎨 Google Fonts Import ===== */
@import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap');
/* ===== 🎨 Comic Classic Background ===== */
.gradio-container {
background-color: #FEF9C3 !important;
background-image:
radial-gradient(#1F2937 1px, transparent 1px) !important;
background-size: 20px 20px !important;
min-height: 100vh !important;
font-family: 'Comic Neue', cursive, sans-serif !important;
}
/* ===== Hide HuggingFace Elements ===== */
.huggingface-space-header,
#space-header,
.space-header,
[class*="space-header"],
.svelte-1ed2p3z,
.space-header-badge,
.header-badge,
[data-testid="space-header"],
.svelte-kqij2n,
.svelte-1ax1toq,
.embed-container > div:first-child {
display: none !important;
visibility: hidden !important;
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
/* ===== Hide Footer ===== */
footer,
.footer,
.gradio-container footer,
.built-with,
[class*="footer"],
.gradio-footer,
.main-footer,
div[class*="footer"],
.show-api,
.built-with-gradio,
a[href*="gradio.app"],
a[href*="huggingface.co/spaces"] {
display: none !important;
visibility: hidden !important;
height: 0 !important;
padding: 0 !important;
margin: 0 !important;
}
/* ===== Main Container ===== */
#col-container {
max-width: 1200px;
margin: 0 auto;
}
/* ===== 🎨 Header Title - Comic Style ===== */
.header-text h1 {
font-family: 'Bangers', cursive !important;
color: #1F2937 !important;
font-size: 3.5rem !important;
font-weight: 400 !important;
text-align: center !important;
margin-bottom: 0.5rem !important;
text-shadow:
4px 4px 0px #FACC15,
6px 6px 0px #1F2937 !important;
letter-spacing: 3px !important;
-webkit-text-stroke: 2px #1F2937 !important;
}
/* ===== 🎨 Subtitle ===== */
.subtitle {
text-align: center !important;
font-family: 'Comic Neue', cursive !important;
font-size: 1.2rem !important;
color: #1F2937 !important;
margin-bottom: 1.5rem !important;
font-weight: 700 !important;
}
/* ===== 🎨 Cards/Panels - Comic Frame Style ===== */
.gr-panel,
.gr-box,
.gr-form,
.block,
.gr-group {
background: #FFFFFF !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
box-shadow: 6px 6px 0px #1F2937 !important;
transition: all 0.2s ease !important;
}
.gr-panel:hover,
.block:hover {
transform: translate(-2px, -2px) !important;
box-shadow: 8px 8px 0px #1F2937 !important;
}
/* ===== 🎨 Input Fields ===== */
textarea,
input[type="text"],
input[type="number"] {
background: #FFFFFF !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
color: #1F2937 !important;
font-family: 'Comic Neue', cursive !important;
font-size: 1rem !important;
font-weight: 700 !important;
transition: all 0.2s ease !important;
}
textarea:focus,
input[type="text"]:focus,
input[type="number"]:focus {
border-color: #3B82F6 !important;
box-shadow: 4px 4px 0px #3B82F6 !important;
outline: none !important;
}
textarea::placeholder {
color: #9CA3AF !important;
font-weight: 400 !important;
}
/* ===== 🎨 Primary Button - Comic Blue ===== */
.gr-button-primary,
button.primary,
.gr-button.primary {
background: #3B82F6 !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
color: #FFFFFF !important;
font-family: 'Bangers', cursive !important;
font-weight: 400 !important;
font-size: 1.3rem !important;
letter-spacing: 2px !important;
padding: 14px 28px !important;
box-shadow: 5px 5px 0px #1F2937 !important;
transition: all 0.1s ease !important;
text-shadow: 1px 1px 0px #1F2937 !important;
}
.gr-button-primary:hover,
button.primary:hover,
.gr-button.primary:hover {
background: #2563EB !important;
transform: translate(-2px, -2px) !important;
box-shadow: 7px 7px 0px #1F2937 !important;
}
.gr-button-primary:active,
button.primary:active,
.gr-button.primary:active {
transform: translate(3px, 3px) !important;
box-shadow: 2px 2px 0px #1F2937 !important;
}
/* ===== 🎨 Secondary Button - Comic Red ===== */
.gr-button-secondary,
button.secondary {
background: #EF4444 !important;
border: 3px solid #1F2937 !important;
border-radius: 8px !important;
color: #FFFFFF !important;
font-family: 'Bangers', cursive !important;
font-weight: 400 !important;
font-size: 1.1rem !important;
letter-spacing: 1px !important;
box-shadow: 4px 4px 0px #1F2937 !important;
transition: all 0.1s ease !important;
text-shadow: 1px 1px 0px #1F2937 !important;
}
.gr-button-secondary:hover,
button.secondary:hover {
background: #DC2626 !important;
transform: translate(-2px, -2px) !important;
box-shadow: 6px 6px 0px #1F2937 !important;
}
.gr-button-secondary:active,
button.secondary:active {
transform: translate(2px, 2px) !important;
box-shadow: 2px 2px 0px #1F2937 !important;
}
/* ===== 🎨 Labels ===== */
label,
.gr-input-label,
.gr-block-label {
color: #1F2937 !important;
font-family: 'Comic Neue', cursive !important;
font-weight: 700 !important;
font-size: 1rem !important;
}
span.gr-label {
color: #1F2937 !important;
}
/* ===== 🎨 File Upload Area ===== */
.gr-file-upload {
border: 3px dashed #1F2937 !important;
border-radius: 8px !important;
background: #FEF9C3 !important;
}
.gr-file-upload:hover {
border-color: #3B82F6 !important;
background: #EFF6FF !important;
}
/* ===== 🎨 Scrollbar - Comic Style ===== */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: #FEF9C3;
border: 2px solid #1F2937;
}
::-webkit-scrollbar-thumb {
background: #3B82F6;
border: 2px solid #1F2937;
border-radius: 0px;
}
::-webkit-scrollbar-thumb:hover {
background: #EF4444;
}
/* ===== 🎨 Selection Highlight ===== */
::selection {
background: #FACC15;
color: #1F2937;
}
/* ===== 🎨 Links ===== */
a {
color: #3B82F6 !important;
text-decoration: none !important;
font-weight: 700 !important;
}
a:hover {
color: #EF4444 !important;
}
/* ===== 🎨 Row/Column Spacing ===== */
.gr-row {
gap: 1.5rem !important;
}
.gr-column {
gap: 1rem !important;
}
/* ===== Responsive Adjustments ===== */
@media (max-width: 768px) {
.header-text h1 {
font-size: 2.2rem !important;
text-shadow:
3px 3px 0px #FACC15,
4px 4px 0px #1F2937 !important;
}
.gr-button-primary,
button.primary {
padding: 12px 20px !important;
font-size: 1.1rem !important;
}
.gr-panel,
.block {
box-shadow: 4px 4px 0px #1F2937 !important;
}
}
/* ===== 🎨 Disable Dark Mode ===== */
@media (prefers-color-scheme: dark) {
.gradio-container {
background-color: #FEF9C3 !important;
}
}
"""
# ============================================
# Gradio UI
# ============================================
with gr.Blocks(title="Video Editor") as demo:
# Inject CSS via HTML (Audio Extractor와 동일한 방식)
gr.HTML(f"")
# HOME Badge (Audio Extractor와 동일한 방식)
gr.HTML("""
""")
# Header Title
gr.Markdown(
"""
# 🎬 VIDEO EDITOR 🎬
""",
elem_classes="header-text"
)
gr.Markdown(
"""
✂️ Edit your videos with timeline, effects & more! 🎥
""",
)
f = gr.File(label="📁 Upload Files (Video, Image, Audio)", file_count="multiple", file_types=["video", "image", "audio"])
e = gr.HTML(value=make_iframe([]))
gr.Markdown("---")
gr.Markdown("### 📥 MP4 Conversion")
gr.Markdown("In editor: 'Export' → 'Copy for MP4' → Paste below")
with gr.Row():
webm_data = gr.Textbox(label="WebM Data (base64)", placeholder="Paste here (Ctrl+V)", lines=2, scale=4)
convert_btn = gr.Button("🎬 Convert to MP4", variant="primary", scale=1)
mp4_output = gr.File(label="📥 Download MP4 ")
f.change(fn=lambda x: make_iframe(process_file(x)), inputs=[f], outputs=[e])
convert_btn.click(fn=convert_webm_to_mp4, inputs=[webm_data], outputs=[mp4_output])
if __name__ == "__main__":
demo.launch()