""" 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'''
🎬 Video Editor
📁 미디어
파일을 업로드하세요
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("""
HOME
""") # 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()