|
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 |
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
css = """ |
|
footer { |
|
visibility: hidden; |
|
} |
|
.left-column, .right-column { |
|
border: 2px solid #ccc; |
|
border-radius: 8px; |
|
padding: 20px; |
|
background-color: #f9f9f9; |
|
} |
|
.left-column { |
|
margin-right: 10px; |
|
} |
|
.right-column { |
|
margin-left: 10px; |
|
} |
|
.section-border { |
|
border: 1px solid #ddd; |
|
border-radius: 6px; |
|
padding: 10px; |
|
margin-bottom: 15px; |
|
background-color: #ffffff; |
|
} |
|
""" |
|
|
|
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=css |
|
) as demo: |
|
with gr.Row(): |
|
|
|
with gr.Column(elem_classes="left-column"): |
|
|
|
with gr.Row(elem_classes="section-border"): |
|
video_input = gr.Video(label="μμ μ
λ‘λ") |
|
|
|
|
|
with gr.Row(elem_classes="section-border"): |
|
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="section-border"): |
|
output_gif = gr.Image(label="κ²°κ³Ό GIF") |
|
|
|
|
|
with gr.Row(elem_classes="section-border"): |
|
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.Row(elem_classes="section-border"): |
|
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="section-border"): |
|
start_screenshot = gr.Image(label="μμ μ§μ μΊ‘μ³λ³Έ") |
|
end_screenshot = gr.Image(label="λ μ§μ μΊ‘μ³λ³Έ") |
|
|
|
|
|
with gr.Row(elem_classes="section-border"): |
|
|
|
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="section-border"): |
|
generate_button = gr.Button("GIF μμ±") |
|
|
|
|
|
|
|
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() |