|
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 |
|
|
|
|
|
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; |
|
} |
|
""" |
|
|
|
|
|
|
|
|
|
try: |
|
from PIL import Resampling |
|
if not hasattr(Image, "ANTIALIAS"): |
|
Image.ANTIALIAS = Resampling.LANCZOS |
|
except ImportError: |
|
pass |
|
|
|
|
|
|
|
|
|
DEBUG_LOG_LIST = [] |
|
|
|
def log_debug(msg: str): |
|
print("[DEBUG]", msg) |
|
DEBUG_LOG_LIST.append(msg) |
|
|
|
|
|
|
|
|
|
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) |
|
hours, minutes, seconds = parts |
|
return hours * 3600 + minutes * 60 + seconds |
|
except Exception as e: |
|
log_debug(f"[hms_to_seconds] ๋ณํ ์ค๋ฅ: {e}") |
|
return -1 |
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
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 |
|
except Exception as e: |
|
log_debug(f"[get_screenshot_at_time] ์ค๋ฅ: {e}\n{traceback.format_exc()}") |
|
return None |
|
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
def generate_gif(video_dict, start_time_str, end_time_str, fps, resize_factor, speed_factor, duration, resolution_str): |
|
|
|
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') |
|
|
|
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 |
|
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
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" |
|
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: |
|
|
|
copyfile(gif_path, temp_file_path) |
|
except Exception as e: |
|
log_debug(f"[prepare_download_gif] ํ์ผ ๋ณต์ฌ ์ค๋ฅ: {e}") |
|
return gif_path |
|
|
|
return temp_file_path |
|
|
|
|
|
|
|
|
|
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) |
|
""" |
|
|
|
|
|
|
|
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): |
|
|
|
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) |
|
|
|
|
|
return (result, file_size_str, download_path) |
|
else: |
|
|
|
err_msg = result if isinstance(result, str) else "GIF ์์ฑ์ ์คํจํ์ต๋๋ค." |
|
return (None, err_msg, None) |
|
|
|
|
|
|
|
|
|
|
|
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() |
|
) as demo: |
|
|
|
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") |
|
|
|
|
|
with gr.Column(elem_classes="right-column"): |
|
|
|
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_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์ ํด์๋๋ฅผ ์กฐ์ ํฉ๋๋ค." |
|
) |
|
|
|
|
|
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, |
|
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] |
|
) |
|
|
|
|
|
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] |
|
) |
|
|
|
|
|
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, |
|
file_size_text, |
|
download_gif_component |
|
] |
|
) |
|
|
|
demo.launch() |