import os import uuid import moviepy.editor as mp from PIL import Image import gradio as gr # ------------------------------- # 내부 처리 함수들 # ------------------------------- global_logs = [] def add_log(message: str): global_logs.append(message) print(message) def parse_time_to_seconds(time_str: str): try: h, m, s = time_str.split(":") return int(h)*3600 + int(m)*60 + float(s) except Exception: return 0.0 def generate_thumbnail(video_clip, time_point): try: frame = video_clip.get_frame(time_point) return Image.fromarray(frame) except Exception as e: add_log(f"[ERROR] 썸네일 생성 실패: {e}") frame = video_clip.get_frame(0) return Image.fromarray(frame) def adjust_aspect_ratio(clip, option): if option == "원본 유지": return clip if option == "유튜브 (16:9)": target_ratio = 16/9 elif option == "쇼츠/릴스 (9:16)": target_ratio = 9/16 elif option == "정사각형 (1:1)": target_ratio = 1.0 elif option == "인스타그램 (4:5)": target_ratio = 4/5 elif option == "클래식 (4:3)": target_ratio = 4/3 else: return clip width, height = clip.size current_ratio = width/height if current_ratio > target_ratio: new_width = int(height * target_ratio) new_height = height else: new_width = width new_height = int(width / target_ratio) return clip.crop(x_center=width/2, y_center=height/2, width=new_width, height=new_height) def process_video(video, start_time_str, end_time_str, platform_option, frame_rate_factor, speed_factor, repeat_count, resolution_scale): global global_logs global_logs = [] add_log("🎥 [LOG] 영상 처리 시작") video_path = video if isinstance(video, str) else video.name try: input_video = mp.VideoFileClip(video_path) except Exception as e: add_log(f"[ERROR] 비디오 로드 실패: {e}") return None, None, "\n".join(global_logs) duration = input_video.duration add_log(f"[LOG] 영상 재생시간: {duration:.2f}초") start_sec = parse_time_to_seconds(start_time_str) end_sec = parse_time_to_seconds(end_time_str) if start_sec < 0: start_sec = 0 if end_sec <= 0 or end_sec > duration: end_sec = duration if start_sec >= end_sec: start_sec, end_sec = 0, duration add_log(f"[LOG] 시간 설정: {start_sec}초 ~ {end_sec}초") clip = input_video.subclip(start_sec, end_sec) add_log(f"[LOG] 플랫폼 비율 적용: {platform_option}") clip = adjust_aspect_ratio(clip, platform_option) if abs(resolution_scale - 1.0) > 1e-3: add_log(f"[LOG] 해상도 축소: {resolution_scale*100:.0f}%") clip = clip.resize(resolution_scale) if abs(speed_factor - 1.0) > 1e-3: add_log(f"[LOG] 재생속도 {speed_factor}배 적용") clip = clip.fx(mp.vfx.speedx, speed_factor) original_fps = clip.fps target_fps = original_fps * frame_rate_factor add_log(f"[LOG] FPS: {target_fps:.2f} (원본 {original_fps})") clip = clip.set_fps(target_fps) add_log(f"[LOG] GIF 반복 횟수: {repeat_count} (0: 무한)") output_filename = f"temp_{uuid.uuid4().hex}.gif" try: loop_param = 0 if int(repeat_count) == 0 else int(repeat_count) clip.write_gif(output_filename, fps=target_fps, loop=loop_param) add_log(f"[LOG] GIF 생성 완료: {output_filename}, loop={loop_param}") except Exception as e: add_log(f"[ERROR] GIF 생성 실패: {e}") return None, None, "\n".join(global_logs) return output_filename, output_filename, "\n".join(global_logs) def update_thumbnails(video, start_time_str, end_time_str): video_path = video if isinstance(video, str) else video.name try: input_video = mp.VideoFileClip(video_path) except Exception as e: add_log(f"[ERROR] 비디오 로드 실패: {e}") return None, None duration = input_video.duration start_sec = parse_time_to_seconds(start_time_str) end_sec = parse_time_to_seconds(end_time_str) if start_sec < 0: start_sec = 0 if end_sec <= 0 or end_sec > duration: end_sec = duration if start_sec >= end_sec: start_sec, end_sec = 0, duration start_thumb = generate_thumbnail(input_video, start_sec) end_thumb = generate_thumbnail(input_video, end_sec) return start_thumb, end_thumb # ------------------------------- # Custom CSS (글자 크기 크게, 회색 제거, 포인트 버튼 등) # ------------------------------- custom_css = """ body { font-size: 1.6em; /* 글자 크기 증가 */ background-color: #ffffff; font-family: 'Arial', sans-serif; } .gradio-container { width: 80% !important; margin: 0 auto; background-color: #ffffff; /* 회색 제거 */ } /* 제목 및 사용가이드 (왼쪽 정렬, 크게) */ .custom-title { font-size: 3.5em; /* 글자 크기 증가 */ font-weight: bold; margin: 20px 0; color: #2c3e50; text-align: left; } .custom-user-guide { font-size: 1.8em; /* 글자 크기 증가 */ margin-bottom: 20px; color: #2c3e50; text-align: left; } /* 사용가이드 박스 스타일 (회색 제거, 흰색 배경) */ .guide-box { border: 3px solid #3498db; /* 테두리 두께 증가 */ border-radius: 10px; padding: 20px; /* 패딩 증가 */ background-color: #ffffff; margin: 20px 0; text-align: left; font-size: 1.2em; /* 글자 크기 증가 */ } /* 프레임 스타일 */ .frame { border: 3px solid #3498db; /* 테두리 두께 증가 */ border-radius: 20px; padding: 25px; /* 패딩 증가 */ background-color: #ffffff; margin: 15px; box-shadow: 3px 3px 10px rgba(0,0,0,0.1); } /* 각 프레임 제목 크게 */ .frame h3 { font-size: 2.2em; /* 글자 크기 증가 */ margin-bottom: 20px; text-align: left; } /* 행 및 컬럼 레이아웃 */ .row-container { display: flex; justify-content: space-between; } .column { flex: 1; margin: 10px; } /* 포인트 버튼 스타일 */ .gif-button { margin-top: 35px; padding: 20px 35px; /* 패딩 증가 */ font-size: 1.8em; /* 글자 크기 증가 */ background-color: #e67e22; color: #fff; border: none; border-radius: 12px; cursor: pointer; } /* 슬라이더 스타일 (글자와 바 크게) */ input[type=range] { height: 30px; /* 높이 증가 */ } input[type=range]::-webkit-slider-thumb { height: 30px; /* 높이 증가 */ width: 30px; /* 너비 증가 */ } .slider-label { font-size: 1.6em; /* 글자 크기 증가 */ margin-bottom: 8px; } /* 모든 input, select, button 글자 크기 키움 */ input, button, select, textarea { font-size: 1.5em; /* 글자 크기 증가 */ } /* 라벨 글자 크기 키움 */ label, .label-wrap span { font-size: 1.3em !important; /* 글자 크기 증가 */ font-weight: bold; } /* 회색 배경 제거 */ .gradio-container .prose, .gradio-container .gr-box, .gradio-container .gr-form, .gradio-container .gr-panel { background-color: #ffffff !important; } .gradio-container .gr-form, .gradio-container .gr-group { border-color: #3498db; background-color: #ffffff !important; } .gradio-container .gr-input, .gradio-container .gr-checkbox, .gradio-container .gr-radio, .gradio-container .gr-dropdown { font-size: 1.4em; /* 글자 크기 증가 */ } """ # ------------------------------- # Gradio UI 구성 (HTML/CSS 커스터마이징) # ------------------------------- with gr.Blocks(css=custom_css, title="영상 -> GIF 변환 서비스") as demo: # 제목 및 사용가이드 (왼쪽 정렬) gr.HTML("
🎬 영상을 GIF변환하기
") gr.HTML("
사용가이드:
" "1. 좌측 입력부에서 영상을 업로드하고 옵션을 선택하세요.
" "2. 하단 작업 썸네일미리보기에서 시작/종료 시간을 입력해 썸네일을 확인하세요.
" "3. ✨ GIF 생성하기 버튼을 눌러 GIF를 생성하고, 우측 출력부에서 결과를 확인 및 다운로드하세요. 😊
") # 첫번째 행: 입력부 (좌측) / 출력부 (우측) with gr.Row(elem_classes="row-container"): with gr.Column(elem_classes="column"): with gr.Group(elem_classes="frame"): gr.Markdown("### 📥 입력부") video_input = gr.Video(label="영상 업로드", show_label=True) platform_option = gr.Radio( label="해상도/비율 선택", choices=["원본 유지", "유튜브 (16:9)", "쇼츠/릴스 (9:16)", "정사각형 (1:1)", "인스타그램 (4:5)", "클래식 (4:3)"], value="원본 유지" ) resolution_scale_slider = gr.Slider(label="출력 해상도 축소 비율 (0.1 ~ 1.0)", minimum=0.1, maximum=1.0, step=0.1, value=1.0) frame_rate_slider = gr.Slider(label="프레임 레이트 배율 조절 (0.1 ~ 1.0)", minimum=0.1, maximum=1.0, step=0.1, value=1.0) speed_slider = gr.Slider(label="재생 속도 조절 (0.5 ~ 5.0)", minimum=0.5, maximum=5.0, step=0.1, value=1.0) repeat_slider = gr.Slider(label="GIF 반복 횟수 (0: 무한, 1~10)", minimum=0, maximum=10, step=1, value=0) with gr.Column(elem_classes="column"): with gr.Group(elem_classes="frame"): gr.Markdown("### 📤 출력부") gif_preview_output = gr.Image(label="GIF 결과 미리보기") download_output = gr.File(label="GIF 다운로드") # 두번째 행: 작업 썸네일미리보기 (전체 너비) with gr.Group(elem_classes="frame"): gr.Markdown("### 🖼️ 작업 썸네일미리보기") with gr.Row(): start_time_tb = gr.Textbox(label="시작 시간 (예: 00:00:05)", value="00:00:00") end_time_tb = gr.Textbox(label="종료 시간 (예: 00:00:10)", value="00:00:05") with gr.Row(): start_thumb_output = gr.Image(label="시작 썸네일") end_thumb_output = gr.Image(label="종료 썸네일") generate_button = gr.Button("✨ GIF 생성하기", elem_classes="gif-button") # 이벤트 바인딩 start_time_tb.change(fn=update_thumbnails, inputs=[video_input, start_time_tb, end_time_tb], outputs=[start_thumb_output, end_thumb_output]) end_time_tb.change(fn=update_thumbnails, inputs=[video_input, start_time_tb, end_time_tb], outputs=[start_thumb_output, end_thumb_output]) video_input.change(fn=update_thumbnails, inputs=[video_input, start_time_tb, end_time_tb], outputs=[start_thumb_output, end_thumb_output]) generate_button.click(fn=process_video, inputs=[video_input, start_time_tb, end_time_tb, platform_option, frame_rate_slider, speed_slider, repeat_slider, resolution_scale_slider], outputs=[gif_preview_output, download_output]) demo.launch()