188 lines
5.8 KiB
Python
188 lines
5.8 KiB
Python
"""S-CANVAS 인트로 로딩 스플래시.
|
|
|
|
Design/logo_intro.mp4 를 frameless Toplevel 중앙에 재생한 뒤 자동 종료.
|
|
메인 앱(`scanvas_maker.SCanvasApp`) 기동 직전에 호출한다.
|
|
|
|
기술 스택:
|
|
- cv2 (VideoCapture) : MP4 프레임 디코드 (harness/quality_validator 가
|
|
이미 의존하는 프로젝트 공통 dep).
|
|
- PIL + tkinter.PhotoImage : 프레임 렌더.
|
|
- tk.Tk + attributes("-alpha", ...) : frameless + 페이드 인/아웃 효과.
|
|
|
|
dynamic effects(사용자 요구 "역동적으로"):
|
|
1. 시작 시 알파 0 → 1 페이드인 (400ms)
|
|
2. 비디오 자체 애니메이션(logo_intro.mp4 는 고유 모션 포함)
|
|
3. 종료 전 알파 1 → 0 페이드아웃 (400ms)
|
|
4. 비디오 하단에 브랜드 tagline bar (오렌지 italic)
|
|
5. max_duration_s 초과 시 강제 종료(safety)
|
|
|
|
실패 조건(조용히 skip):
|
|
- logo_intro.mp4 없음
|
|
- cv2/PIL import 실패
|
|
- VideoCapture.open 실패
|
|
메인 앱 기동은 항상 보장된다.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import tkinter as tk
|
|
from pathlib import Path
|
|
|
|
|
|
def show_intro_splash(
|
|
video_path,
|
|
max_duration_s: float = 12.0,
|
|
fade_ms: int = 400,
|
|
tagline: str = "S-CANVAS — Generative Design & Visualization Engine",
|
|
max_display_w: int = 1000,
|
|
) -> None:
|
|
"""video_path 의 MP4 를 스플래시로 재생 후 블로킹 반환.
|
|
|
|
Args:
|
|
video_path: Path-like, .mp4 파일.
|
|
max_duration_s: 비디오가 이 시간을 넘기면 강제 종료.
|
|
fade_ms: 페이드인/아웃 각 구간 길이(ms).
|
|
tagline: 비디오 아래에 표시할 문구.
|
|
max_display_w: 화면상 최대 가로(px). 이보다 크면 aspect 유지 축소.
|
|
|
|
동작:
|
|
- 임시 tk.Tk 루트를 만들고 mainloop → 종료 시 완전 destroy.
|
|
- 이후 `SCanvasApp()` 이 새 ctk.CTk 인스턴스를 만들어도 충돌 없음
|
|
(Tk 루트가 이미 파괴됐으므로).
|
|
"""
|
|
try:
|
|
import cv2
|
|
from PIL import Image, ImageTk
|
|
except ImportError as e:
|
|
print(f"[Intro] cv2/PIL 미설치 — 스플래시 skip ({e})")
|
|
return
|
|
|
|
vp = Path(video_path)
|
|
if not vp.exists():
|
|
print(f"[Intro] 비디오 없음 ({vp}) — 스플래시 skip")
|
|
return
|
|
|
|
cap = cv2.VideoCapture(str(vp))
|
|
if not cap.isOpened():
|
|
print(f"[Intro] VideoCapture 열기 실패 — 스플래시 skip")
|
|
return
|
|
|
|
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
|
frame_ms = max(int(1000.0 / max(fps, 1.0)), 8)
|
|
vw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 800)
|
|
vh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 450)
|
|
|
|
if vw > max_display_w:
|
|
disp_w = max_display_w
|
|
disp_h = max(1, int(vh * max_display_w / vw))
|
|
else:
|
|
disp_w, disp_h = vw, vh
|
|
|
|
root = tk.Tk()
|
|
root.withdraw()
|
|
screen_w = root.winfo_screenwidth()
|
|
screen_h = root.winfo_screenheight()
|
|
|
|
TAG_H = 44
|
|
total_h = disp_h + TAG_H
|
|
x = max(0, (screen_w - disp_w) // 2)
|
|
y = max(0, (screen_h - total_h) // 2)
|
|
|
|
splash = tk.Toplevel(root)
|
|
splash.overrideredirect(True)
|
|
try:
|
|
splash.attributes("-topmost", True)
|
|
splash.attributes("-alpha", 0.0)
|
|
except tk.TclError:
|
|
pass
|
|
splash.configure(bg="#0A0F1C")
|
|
splash.geometry(f"{disp_w}x{total_h}+{x}+{y}")
|
|
|
|
vid_label = tk.Label(splash, bg="#0A0F1C", borderwidth=0, highlightthickness=0)
|
|
vid_label.place(x=0, y=0, width=disp_w, height=disp_h)
|
|
|
|
tag_label = tk.Label(
|
|
splash, text=tagline,
|
|
bg="#0A0F1C", fg="#E67E22",
|
|
font=("Segoe UI", 10, "italic"),
|
|
borderwidth=0, highlightthickness=0,
|
|
)
|
|
tag_label.place(x=0, y=disp_h, width=disp_w, height=TAG_H)
|
|
|
|
state = {"closed": False, "t_start": time.time()}
|
|
|
|
def _safe_alpha(a):
|
|
try:
|
|
splash.attributes("-alpha", max(0.0, min(1.0, a)))
|
|
except tk.TclError:
|
|
pass
|
|
|
|
def _fade_to(target, duration_ms, done_cb=None):
|
|
steps = max(int(duration_ms / 16), 1)
|
|
start_alpha = float(splash.attributes("-alpha") or 0.0)
|
|
|
|
def _step(i):
|
|
if state["closed"]:
|
|
return
|
|
ratio = i / steps
|
|
_safe_alpha(start_alpha + (target - start_alpha) * ratio)
|
|
if i < steps:
|
|
splash.after(16, lambda: _step(i + 1))
|
|
elif done_cb:
|
|
done_cb()
|
|
|
|
_step(0)
|
|
|
|
def _close():
|
|
if state["closed"]:
|
|
return
|
|
state["closed"] = True
|
|
try:
|
|
cap.release()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
splash.destroy()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
root.quit()
|
|
except Exception:
|
|
pass
|
|
|
|
def _play_next_frame():
|
|
if state["closed"]:
|
|
return
|
|
if (time.time() - state["t_start"]) > max_duration_s:
|
|
_fade_to(0.0, fade_ms, done_cb=_close)
|
|
return
|
|
ret, frame = cap.read()
|
|
if not ret:
|
|
# 비디오 끝 — 페이드아웃 후 종료
|
|
_fade_to(0.0, fade_ms, done_cb=_close)
|
|
return
|
|
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
if (vw, vh) != (disp_w, disp_h):
|
|
rgb = cv2.resize(rgb, (disp_w, disp_h), interpolation=cv2.INTER_AREA)
|
|
img = Image.fromarray(rgb)
|
|
photo = ImageTk.PhotoImage(img)
|
|
vid_label.configure(image=photo)
|
|
vid_label.image = photo # GC 방지
|
|
splash.after(frame_ms, _play_next_frame)
|
|
|
|
# 페이드인 → 첫 프레임 재생 시작
|
|
_fade_to(1.0, fade_ms, done_cb=_play_next_frame)
|
|
|
|
try:
|
|
root.mainloop()
|
|
finally:
|
|
try:
|
|
root.destroy()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# 단독 테스트
|
|
show_intro_splash(Path(__file__).resolve().parent / "Design" / "logo_intro.mp4")
|