import gradio as gr import os import tempfile import base64 import math import traceback import numpy as np from PIL import Image from moviepy.editor import VideoFileClip, vfx from shutil import copyfile from datetime import datetime, timedelta # 별도 CSS 파일 불러오기 함수 def load_css(): """외부 CSS 파일을 불러오거나, 파일이 없을 경우 내장 스타일 반환""" try: with open('styles.css', 'r', encoding='utf-8') as f: return f.read() except: # 파일이 없을 경우 내장 스타일 반환 (다크모드 지원) return """ /* FontAwesome 아이콘 포함 */ @import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"); /* ============================================ 다크모드 자동 변경 템플릿 CSS ============================================ */ /* 1. CSS 변수 정의 (라이트모드 - 기본값) */ :root { /* 메인 컬러 */ --primary-color: #FB7F0D; --secondary-color: #ff9a8b; --accent-color: #FF6B6B; /* 배경 컬러 */ --background-color: #FFFFFF; --card-bg: #ffffff; --input-bg: #ffffff; /* 텍스트 컬러 */ --text-color: #334155; --text-secondary: #64748b; /* 보더 및 구분선 */ --border-color: #dddddd; --border-light: #e5e5e5; /* 테이블 컬러 */ --table-even-bg: #f3f3f3; --table-hover-bg: #f0f0f0; /* 그림자 */ --shadow: 0 8px 30px rgba(251, 127, 13, 0.08); --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1); /* 기타 */ --border-radius: 18px; } /* 2. 다크모드 색상 변수 (자동 감지) */ @media (prefers-color-scheme: dark) { :root { /* 배경 컬러 */ --background-color: #1a1a1a; --card-bg: #2d2d2d; --input-bg: #2d2d2d; /* 텍스트 컬러 */ --text-color: #e5e5e5; --text-secondary: #a1a1aa; /* 보더 및 구분선 */ --border-color: #404040; --border-light: #525252; /* 테이블 컬러 */ --table-even-bg: #333333; --table-hover-bg: #404040; /* 그림자 */ --shadow: 0 8px 30px rgba(0, 0, 0, 0.3); --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2); } } /* 3. 수동 다크모드 클래스 (Gradio 토글용) */ [data-theme="dark"], .dark, .gr-theme-dark { /* 배경 컬러 */ --background-color: #1a1a1a; --card-bg: #2d2d2d; --input-bg: #2d2d2d; /* 텍스트 컬러 */ --text-color: #e5e5e5; --text-secondary: #a1a1aa; /* 보더 및 구분선 */ --border-color: #404040; --border-light: #525252; /* 테이블 컬러 */ --table-even-bg: #333333; --table-hover-bg: #404040; /* 그림자 */ --shadow: 0 8px 30px rgba(0, 0, 0, 0.3); --shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2); } /* 4. 기본 요소 다크모드 적용 */ body { font-family: 'Pretendard', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif; background-color: var(--background-color) !important; color: var(--text-color) !important; line-height: 1.6; transition: background-color 0.3s ease, color 0.3s ease; } footer { visibility: hidden; } /* 5. Gradio 컨테이너 강제 적용 */ .gradio-container, .gradio-container *, .gr-app, .gr-app *, .gr-interface { background-color: var(--background-color) !important; color: var(--text-color) !important; } .container { max-width: 1200px; margin: 0 auto; } .header { background: linear-gradient(135deg, #FB7F0D, #FF9A5B); padding: 2rem; border-radius: 15px; margin-bottom: 20px; box-shadow: var(--shadow); text-align: center; color: white; } .header h1 { margin: 0; font-size: 2.5rem; font-weight: 700; } .header p { margin: 10px 0 0; font-size: 1.2rem; opacity: 0.9; } .card { background-color: var(--card-bg) !important; border-radius: var(--border-radius); padding: 20px; margin: 10px 0; box-shadow: var(--shadow); border: 1px solid var(--border-color); color: var(--text-color) !important; transition: all 0.3s ease; } .button-primary { border-radius: 30px !important; background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)) !important; color: white !important; font-size: 18px !important; padding: 10px 20px !important; border: none; box-shadow: 0 4px 8px rgba(251, 127, 13, 0.25); transition: transform 0.3s ease; text-align: center; font-weight: 600; } .button-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 12px rgba(251, 127, 13, 0.3); } .section-title { display: flex; align-items: center; font-size: 20px; font-weight: 700; color: var(--text-color) !important; margin-bottom: 15px; padding-bottom: 8px; border-bottom: 2px solid var(--primary-color); } .section-title i { margin-right: 10px; color: var(--primary-color); } .guide-container { background-color: var(--card-bg) !important; border-radius: var(--border-radius); padding: 1.5rem; margin-bottom: 1.5rem; border: 1px solid var(--border-color); color: var(--text-color) !important; } .guide-title { font-size: 1.3rem; font-weight: 700; color: var(--primary-color) !important; margin-bottom: 1rem; display: flex; align-items: center; } .guide-title i { margin-right: 0.8rem; font-size: 1.3rem; } .guide-item { display: flex; margin-bottom: 0.8rem; align-items: flex-start; } .guide-number { background-color: var(--primary-color); color: white; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; margin-right: 10px; flex-shrink: 0; font-size: 14px; } .guide-text { flex: 1; line-height: 1.6; color: var(--text-color) !important; } .feature-tag { display: inline-block; background-color: rgba(251, 127, 13, 0.1); color: var(--primary-color); padding: 3px 10px; border-radius: 12px; font-size: 14px; font-weight: 600; margin-right: 8px; margin-bottom: 8px; } .input-label { font-weight: 600; margin-bottom: 8px; color: var(--text-color) !important; } /* 6. 카드 및 패널 스타일 */ .gr-form, .gr-box, .gr-panel, .custom-frame, [class*="frame"], [class*="panel"] { background-color: var(--card-bg) !important; border-color: var(--border-color) !important; color: var(--text-color) !important; box-shadow: var(--shadow) !important; } /* 7. 입력 필드 스타일 */ input[type="text"], input[type="number"], input[type="email"], input[type="password"], textarea, select, .gr-input, .gr-text-input, .gr-textarea, .gr-dropdown { background-color: var(--input-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; border-radius: var(--border-radius) !important; border: 1px solid var(--border-color) !important; padding: 12px !important; transition: all 0.3s ease !important; } input[type="text"]:focus, input[type="number"]:focus, input[type="email"]:focus, input[type="password"]:focus, textarea:focus, select:focus, .gr-input:focus, .gr-text-input:focus, .gr-textarea:focus, .gr-dropdown:focus { border-color: var(--primary-color) !important; outline: none !important; box-shadow: 0 0 0 2px rgba(251, 127, 13, 0.2) !important; } /* 8. 라벨 및 텍스트 요소 */ label, .gr-label, .gr-checkbox label, .gr-radio label, p, span, div { color: var(--text-color) !important; } /* 9. 테이블 스타일 */ table { background-color: var(--card-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } table th { background-color: var(--primary-color) !important; color: white !important; border-color: var(--border-color) !important; } table td { background-color: var(--card-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } table tbody tr:nth-child(even) { background-color: var(--table-even-bg) !important; } table tbody tr:hover { background-color: var(--table-hover-bg) !important; } /* 10. 체크박스 및 라디오 버튼 */ input[type="checkbox"], input[type="radio"] { accent-color: var(--primary-color) !important; } /* 11. 스크롤바 스타일 */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: var(--card-bg); border-radius: 10px; } ::-webkit-scrollbar-thumb { background: var(--primary-color); border-radius: 10px; } ::-webkit-scrollbar-thumb:hover { background: var(--secondary-color); } /* 12. 아코디언 및 드롭다운 */ details { background-color: var(--card-bg) !important; border-color: var(--border-color) !important; color: var(--text-color) !important; } details summary { background-color: var(--card-bg) !important; color: var(--text-color) !important; } /* 13. 추가 Gradio 컴포넌트들 */ .gr-block, .gr-group, .gr-row, .gr-column { background-color: var(--background-color) !important; color: var(--text-color) !important; } /* 14. 버튼은 기존 스타일 유지 (primary-color 사용) */ button:not([class*="custom"]):not([class*="primary"]):not([class*="secondary"]) { background-color: var(--card-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } /* 15. 코드 블록 및 pre 태그 */ code, pre, .code-block { background-color: var(--table-even-bg) !important; color: var(--text-color) !important; border-color: var(--border-color) !important; } /* 16. 전환 애니메이션 */ * { transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease !important; } /* 기존 GIF 변환기 전용 스타일 유지 */ .left-column, .right-column { border: 2px solid var(--primary-color); border-radius: var(--border-radius); padding: 20px; background-color: var(--card-bg) !important; color: var(--text-color) !important; } .left-column { margin-right: 10px; } .right-column { margin-left: 10px; } .section-border { border: 1px solid var(--border-color); border-radius: 6px; padding: 10px; margin-bottom: 15px; background-color: var(--card-bg) !important; color: var(--text-color) !important; } """ ######################################## # 1) PIL ANTIALIAS 에러 대응 (Monkey-patch) ######################################## try: from PIL import Resampling if not hasattr(Image, "ANTIALIAS"): Image.ANTIALIAS = Resampling.LANCZOS except ImportError: pass ######################################## # 2) 내부 디버그 로깅 (UI에서 미출력) ######################################## DEBUG_LOG_LIST = [] def log_debug(msg: str): print("[DEBUG]", msg) DEBUG_LOG_LIST.append(msg) ######################################## # 3) 시간 형식 변환 유틸리티 함수 ######################################## END_EPSILON = 0.01 def round_down_to_one_decimal(value: float) -> float: return math.floor(value * 10) / 10 def safe_end_time(duration: float) -> float: tmp = duration - END_EPSILON if tmp < 0: tmp = 0 return round_down_to_one_decimal(tmp) def coalesce_to_zero(val): """ None이나 NaN, 문자열 오류 등이 들어오면 0.0으로 변환 """ if val is None: return 0.0 try: return float(val) except: return 0.0 def seconds_to_hms(seconds: float) -> str: """초를 HH:MM:SS 형식으로 변환""" try: seconds = max(0, seconds) td = timedelta(seconds=round(seconds)) return str(td) except Exception as e: log_debug(f"[seconds_to_hms] 변환 오류: {e}") return "00:00:00" def hms_to_seconds(time_str: str) -> float: """HH:MM:SS 형식을 초로 변환""" try: parts = time_str.strip().split(':') parts = [int(p) for p in parts] while len(parts) < 3: parts.insert(0, 0) # 부족한 부분은 0으로 채움 hours, minutes, seconds = parts return hours * 3600 + minutes * 60 + seconds except Exception as e: log_debug(f"[hms_to_seconds] 변환 오류: {e}") return -1 # 오류 시 -1 반환 ######################################## # 4) 업로드된 영상 파일 저장 ######################################## def save_uploaded_video(video_input): if not video_input: log_debug("[save_uploaded_video] video_input is None.") return None if isinstance(video_input, str): log_debug(f"[save_uploaded_video] video_input is str: {video_input}") if os.path.exists(video_input): return video_input else: log_debug("[save_uploaded_video] Path does not exist.") return None if isinstance(video_input, dict): log_debug(f"[save_uploaded_video] video_input is dict: {list(video_input.keys())}") if 'data' in video_input: file_data = video_input['data'] if isinstance(file_data, str) and file_data.startswith("data:"): base64_str = file_data.split(';base64,')[-1] try: video_binary = base64.b64decode(base64_str) tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") tmp.write(video_binary) tmp.flush() tmp.close() log_debug(f"[save_uploaded_video] Created temp file: {tmp.name}") return tmp.name except Exception as e: log_debug(f"[save_uploaded_video] base64 디코딩 오류: {e}") return None else: if isinstance(file_data, str) and os.path.exists(file_data): log_debug("[save_uploaded_video] data 필드가 실제 경로") return file_data else: log_debug("[save_uploaded_video] data 필드가 예상치 못한 형태.") return None else: log_debug("[save_uploaded_video] dict이지만 'data' 키가 없음.") return None log_debug("[save_uploaded_video] Unrecognized type.") return None ######################################## # 5) 영상 길이, 해상도, 스크린샷 ######################################## def get_video_duration(video_dict): path = save_uploaded_video(video_dict) if not path: return "00:00:00" try: clip = VideoFileClip(path) dur = clip.duration clip.close() log_debug(f"[get_video_duration] duration={dur}") return seconds_to_hms(dur) except Exception as e: log_debug(f"[get_video_duration] 오류: {e}\n{traceback.format_exc()}") return "00:00:00" def get_resolution(video_dict): path = save_uploaded_video(video_dict) if not path: return "0x0" try: clip = VideoFileClip(path) w, h = clip.size clip.close() log_debug(f"[get_resolution] w={w}, h={h}") return f"{w}x{h}" except Exception as e: log_debug(f"[get_resolution] 오류: {e}\n{traceback.format_exc()}") return "0x0" def get_screenshot_at_time(video_dict, time_in_seconds): path = save_uploaded_video(video_dict) if not path: return None try: clip = VideoFileClip(path) actual_duration = clip.duration # 마지막 프레임 접근 방지 if time_in_seconds >= actual_duration - END_EPSILON: time_in_seconds = safe_end_time(actual_duration) t = max(0, min(time_in_seconds, clip.duration)) log_debug(f"[get_screenshot_at_time] t={t:.3f} / duration={clip.duration:.3f}") frame = clip.get_frame(t) clip.close() return frame # numpy 배열로 반환 except Exception as e: log_debug(f"[get_screenshot_at_time] 오류: {e}\n{traceback.format_exc()}") return None ######################################## # 6) 업로드 이벤트 ######################################## def on_video_upload(video_dict): log_debug("[on_video_upload] Called.") dur_hms = get_video_duration(video_dict) w, h = map(int, get_resolution(video_dict).split('x')) resolution_str = f"{w}x{h}" start_t = 0.0 end_t = safe_end_time(hms_to_seconds(dur_hms)) start_img = get_screenshot_at_time(video_dict, start_t) end_img = None if end_t > 0: end_img = get_screenshot_at_time(video_dict, end_t) # 순서대로: 영상 길이, 해상도(문자열), 시작 시간, 끝 시간, 시작 스크린샷, 끝 스크린샷 return dur_hms, resolution_str, seconds_to_hms(start_t), seconds_to_hms(end_t), start_img, end_img ######################################## # 7) 스크린샷 갱신 ######################################## def update_screenshots(video_dict, start_time_str, end_time_str): start_time = hms_to_seconds(start_time_str) end_time = hms_to_seconds(end_time_str) if start_time < 0 or end_time < 0: return (None, None) log_debug(f"[update_screenshots] start={start_time_str}, end={end_time_str}") end_time = round_down_to_one_decimal(end_time) img_start = get_screenshot_at_time(video_dict, start_time) img_end = get_screenshot_at_time(video_dict, end_time) return (img_start, img_end) ######################################## # 8) GIF 생성 ######################################## def generate_gif(video_dict, start_time_str, end_time_str, fps, resize_factor, speed_factor, duration, resolution_str): # "WxH" 형태 해상도 파싱 parts = resolution_str.split("x") if len(parts) == 2: try: orig_w = float(parts[0]) orig_h = float(parts[1]) except: orig_w = 0 orig_h = 0 else: orig_w = 0 orig_h = 0 start_time = hms_to_seconds(start_time_str) end_time = hms_to_seconds(end_time_str) if start_time < 0 or end_time < 0: return "잘못된 시간 형식입니다. HH:MM:SS 형식으로 입력해주세요." fps = coalesce_to_zero(fps) resize_factor = coalesce_to_zero(resize_factor) speed_factor = coalesce_to_zero(speed_factor) log_debug("[generate_gif] Called.") log_debug(f" start_time={start_time}, end_time={end_time}, fps={fps}, resize_factor={resize_factor}, speed_factor={speed_factor}") path = save_uploaded_video(video_dict) if not path: err_msg = "[generate_gif] 영상이 업로드되지 않았습니다." log_debug(err_msg) return err_msg try: clip = VideoFileClip(path) end_time = round_down_to_one_decimal(end_time) st = max(0, start_time) et = max(0, end_time) if et > clip.duration: et = clip.duration # 마지막 프레임 접근 방지 if et >= clip.duration - END_EPSILON: et = safe_end_time(clip.duration) log_debug(f" subclip range => st={st:.2f}, et={et:.2f}, totalDur={clip.duration:.2f}") if st >= et: clip.close() err_msg = "시작 시간이 끝 시간보다 같거나 큽니다." log_debug(f"[generate_gif] {err_msg}") return err_msg sub_clip = clip.subclip(st, et) # 배속 조정 if speed_factor != 1.0: sub_clip = sub_clip.fx(vfx.speedx, speed_factor) log_debug(f" speed_factor applied: {speed_factor}x") # 리사이즈 if resize_factor < 1.0 and orig_w > 0 and orig_h > 0: new_w = int(orig_w * resize_factor) new_h = int(orig_h * resize_factor) log_debug(f" resizing => {new_w}x{new_h}") sub_clip = sub_clip.resize((new_w, new_h)) # 고유한 파일 이름 생성 gif_fd, gif_path = tempfile.mkstemp(suffix=".gif") os.close(gif_fd) # 파일 디스크립터 닫기 log_debug(f" writing GIF to {gif_path}") sub_clip.write_gif(gif_path, fps=int(fps), program='ffmpeg') # ffmpeg 사용 clip.close() sub_clip.close() if os.path.exists(gif_path): log_debug(f" GIF 생성 완료! size={os.path.getsize(gif_path)} bytes.") return gif_path else: err_msg = "GIF 생성에 실패했습니다." log_debug(f"[generate_gif] {err_msg}") return err_msg except Exception as e: err_msg = f"[generate_gif] 오류 발생: {e}\n{traceback.format_exc()}" log_debug(err_msg) return err_msg ######################################## # 9) GIF 다운로드 파일 이름 변경 함수 ######################################## def prepare_download_gif(gif_path, input_video_dict): """GIF 파일의 다운로드 이름을 변경하고 경로를 반환""" if gif_path is None: return None # 한국 시간 타임스탬프 생성 함수 def get_korean_timestamp(): korea_time = datetime.utcnow() + timedelta(hours=9) return korea_time.strftime('%Y%m%d_%H%M%S') timestamp = get_korean_timestamp() # 입력된 GIF 이름에서 기본 이름 추출 if input_video_dict and isinstance(input_video_dict, dict) and 'data' in input_video_dict: file_data = input_video_dict['data'] if isinstance(file_data, str) and file_data.startswith("data:"): base_name = "GIF" # base64 데이터에서는 원본 파일 이름을 알 수 없으므로 기본 이름 사용 elif isinstance(file_data, str) and os.path.exists(file_data): base_name = os.path.splitext(os.path.basename(file_data))[0] else: base_name = "GIF" else: base_name = "GIF" # 새로운 파일 이름 생성 file_name = f"[끝장AI]끝장GIF_{base_name}_{timestamp}.gif" # 임시 디렉토리에 파일 저장 temp_file_path = os.path.join(tempfile.gettempdir(), file_name) try: # 기존 GIF 파일을 새로운 이름으로 복사 copyfile(gif_path, temp_file_path) except Exception as e: log_debug(f"[prepare_download_gif] 파일 복사 오류: {e}") return gif_path # 복사에 실패하면 원본 경로 반환 return temp_file_path ######################################## # 10) 콜백 함수 ######################################## def on_any_change(video_dict, start_time_str, end_time_str): # 스크린샷만 업데이트 start_img, end_img = update_screenshots(video_dict, start_time_str, end_time_str) return (start_img, end_img) def on_generate_click(video_dict, start_time_str, end_time_str, fps, resize_factor, speed_factor, duration, resolution_str): """GIF 생성 후: - 성공시: (생성된 GIF 경로, 파일 용량 문자열, 파일 다운로드 경로) - 실패시: (None, 에러 메시지, None) """ # Convert duration from hms to seconds for internal use if needed # duration는 현재 사용되지 않으므로 무시하거나 필요 시 처리할 수 있음 result = generate_gif(video_dict, start_time_str, end_time_str, fps, resize_factor, speed_factor, duration, resolution_str) if isinstance(result, str) and os.path.exists(result): # GIF 생성 성공 size_bytes = os.path.getsize(result) size_mb = size_bytes / (1024 * 1024) file_size_str = f"{size_mb:.2f} MB" # 다운로드 파일 이름 변경 download_path = prepare_download_gif(result, video_dict) # Gradio가 자동으로 파일을 처리하도록 `download_path`를 반환 return (result, file_size_str, download_path) else: # GIF 생성 실패, 에러 메시지 반환 err_msg = result if isinstance(result, str) else "GIF 생성에 실패했습니다." return (None, err_msg, None) ######################################## # 11) Gradio UI ######################################## with gr.Blocks( theme=gr.themes.Soft( primary_hue=gr.themes.Color( c50="#FFF7ED", # 가장 밝은 주황 c100="#FFEDD5", c200="#FED7AA", c300="#FDBA74", c400="#FB923C", c500="#F97316", # 기본 주황 c600="#EA580C", c700="#C2410C", c800="#9A3412", c900="#7C2D12", # 가장 어두운 주황 c950="#431407", ), secondary_hue="zinc", # 모던한 느낌의 회색 계열 neutral_hue="zinc", font=("Pretendard", "sans-serif") ), css=load_css() # 외부 CSS 파일 또는 내장 스타일 로드 ) as demo: # FontAwesome 아이콘 헤더 추가 gr.HTML(""" """) with gr.Row(): # 왼쪽 컬럼: 영상 업로드 및 정보 with gr.Column(elem_classes="left-column"): # 영상 업로드 섹션 with gr.Row(elem_classes="card"): gr.HTML('