gif-1 / app.py
ssboost's picture
Update app.py
9a3b674 verified
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("""
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
""")
with gr.Row():
# ์™ผ์ชฝ ์ปฌ๋Ÿผ: ์˜์ƒ ์—…๋กœ๋“œ ๋ฐ ์ •๋ณด
with gr.Column(elem_classes="left-column"):
# ์˜์ƒ ์—…๋กœ๋“œ ์„น์…˜
with gr.Row(elem_classes="card"):
gr.HTML('<div class="section-title"><i class="fas fa-cloud-upload-alt"></i> ์˜์ƒ ์—…๋กœ๋“œ</div>')
video_input = gr.Video(label="")
# ์˜์ƒ ๊ธธ์ด ๋ฐ ํ•ด์ƒ๋„ ์„น์…˜
with gr.Row(elem_classes="card"):
gr.HTML('<div class="section-title"><i class="fas fa-info-circle"></i> ์˜์ƒ ์ •๋ณด</div>')
duration_box = gr.Textbox(label="์˜์ƒ ๊ธธ์ด", interactive=False, value="00:00:00")
resolution_box = gr.Textbox(label="ํ•ด์ƒ๋„", interactive=False, value="0x0")
# ์˜ค๋ฅธ์ชฝ ์ปฌ๋Ÿผ: ๊ฒฐ๊ณผ GIF ๋ฐ ๋‹ค์šด๋กœ๋“œ
with gr.Column(elem_classes="right-column"):
# ๊ฒฐ๊ณผ GIF ์„น์…˜
with gr.Row(elem_classes="card"):
gr.HTML('<div class="section-title"><i class="fas fa-image"></i> ๊ฒฐ๊ณผ GIF</div>')
output_gif = gr.Image(label="")
# ํŒŒ์ผ ์šฉ๋Ÿ‰ ๋ฐ ๋‹ค์šด๋กœ๋“œ ์„น์…˜
with gr.Row(elem_classes="card"):
gr.HTML('<div class="section-title"><i class="fas fa-download"></i> ๋‹ค์šด๋กœ๋“œ</div>')
file_size_text = gr.Textbox(label="ํŒŒ์ผ ์šฉ๋Ÿ‰", interactive=False, value="0 MB")
download_gif_component = gr.File(label="GIF ๋‹ค์šด๋กœ๋“œ")
# ์ถ”๊ฐ€ ์„ค์ • ์„น์…˜
with gr.Row():
with gr.Column():
# ์‹œ๊ฐ„ ์„ค์ • ์„น์…˜
with gr.Column(elem_classes="card"):
gr.HTML('<div class="section-title"><i class="fas fa-clock"></i> ์‹œ๊ฐ„ ์„ค์ •</div>')
with gr.Row():
start_time_input = gr.Textbox(label="์‹œ์ž‘ ์‹œ๊ฐ„ (HH:MM:SS)", value="00:00:00")
end_time_input = gr.Textbox(label="๋ ์‹œ๊ฐ„ (HH:MM:SS)", value="00:00:00")
# ์Šคํฌ๋ฆฐ์ƒท ์„น์…˜
with gr.Row(elem_classes="card"):
gr.HTML('<div class="section-title"><i class="fas fa-camera"></i> ๋ฏธ๋ฆฌ๋ณด๊ธฐ</div>')
start_screenshot = gr.Image(label="์‹œ์ž‘ ์ง€์  ์บก์ณ๋ณธ")
end_screenshot = gr.Image(label="๋ ์ง€์  ์บก์ณ๋ณธ")
# ์„ค์ • ์Šฌ๋ผ์ด๋” ์„น์…˜
with gr.Column(elem_classes="card"):
gr.HTML('<div class="section-title"><i class="fas fa-sliders-h"></i> ๋ณ€ํ™˜ ์„ค์ •</div>')
# ๋ฐฐ์† ์กฐ์ ˆ ์Šฌ๋ผ์ด๋”
speed_slider = gr.Slider(
label="๋ฐฐ์†",
minimum=0.5,
maximum=2.0,
step=0.1,
value=1.0,
info="0.5x: ์ ˆ๋ฐ˜ ์†๋„, 1.0x: ์›๋ž˜ ์†๋„, 2.0x: ๋‘ ๋ฐฐ ์†๋„"
)
# FPS ์Šฌ๋ผ์ด๋”
fps_slider = gr.Slider(
label="FPS",
minimum=1,
maximum=30,
step=1,
value=10,
info="ํ”„๋ ˆ์ž„ ์ˆ˜๋ฅผ ์กฐ์ ˆํ•˜์—ฌ ์• ๋‹ˆ๋ฉ”์ด์…˜์˜ ๋ถ€๋“œ๋Ÿฌ์›€์„ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค."
)
# ํ•ด์ƒ๋„ ๋ฐฐ์œจ ์Šฌ๋ผ์ด๋”
resize_slider = gr.Slider(
label="ํ•ด์ƒ๋„ ๋ฐฐ์œจ",
minimum=0.1,
maximum=1.0,
step=0.05,
value=1.0,
info="GIF์˜ ํ•ด์ƒ๋„๋ฅผ ์กฐ์ ˆํ•ฉ๋‹ˆ๋‹ค."
)
# GIF ์ƒ์„ฑ ๋ฒ„ํŠผ ์„น์…˜
with gr.Row(elem_classes="card"):
generate_button = gr.Button("โœจ GIF ์ƒ์„ฑํ•˜๊ธฐ", elem_classes="button-primary")
# ์ด๋ฒคํŠธ ์ฝœ๋ฐฑ ์„ค์ •
# ์—…๋กœ๋“œ ์‹œ โ†’ ์˜์ƒ ๊ธธ์ด, ํ•ด์ƒ๋„ ์—…๋ฐ์ดํŠธ
video_input.change(
fn=on_video_upload,
inputs=[video_input],
outputs=[
duration_box, # ์˜์ƒ ๊ธธ์ด
resolution_box, # ํ•ด์ƒ๋„("WxH")
start_time_input,
end_time_input,
start_screenshot,
end_screenshot
]
)
# ์‹œ์ž‘/๋ ์‹œ๊ฐ„ ๋ณ€๊ฒฝ ์‹œ โ†’ ์Šคํฌ๋ฆฐ์ƒท ์—…๋ฐ์ดํŠธ
for c in [start_time_input, end_time_input]:
c.change(
fn=on_any_change,
inputs=[video_input, start_time_input, end_time_input],
outputs=[start_screenshot, end_screenshot]
)
# ๋ฐฐ์†, FPS, ํ•ด์ƒ๋„ ๋ฐฐ์œจ ๋ณ€๊ฒฝ ์‹œ โ†’ ์Šคํฌ๋ฆฐ์ƒท ์—…๋ฐ์ดํŠธ
for c in [speed_slider, fps_slider, resize_slider]:
c.change(
fn=on_any_change,
inputs=[video_input, start_time_input, end_time_input],
outputs=[start_screenshot, end_screenshot]
)
# GIF ์ƒ์„ฑ ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ โ†’ GIF ์ƒ์„ฑ ๋ฐ ๊ฒฐ๊ณผ ์—…๋ฐ์ดํŠธ
generate_button.click(
fn=on_generate_click,
inputs=[
video_input,
start_time_input,
end_time_input,
fps_slider,
resize_slider,
speed_slider, # ๋ฐฐ์† ์Šฌ๋ผ์ด๋” ์ถ”๊ฐ€
duration_box,
resolution_box
],
outputs=[
output_gif, # ์ƒ์„ฑ๋œ GIF ๋ฏธ๋ฆฌ๋ณด๊ธฐ
file_size_text, # ํŒŒ์ผ ์šฉ๋Ÿ‰ ํ‘œ์‹œ
download_gif_component # ๋‹ค์šด๋กœ๋“œ ๋งํฌ
]
)
demo.launch()