"""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 import contextlib 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("[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): with contextlib.suppress(tk.TclError): splash.attributes("-alpha", max(0.0, min(1.0, a))) 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 with contextlib.suppress(Exception): cap.release() with contextlib.suppress(Exception): splash.destroy() with contextlib.suppress(Exception): root.quit() 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: with contextlib.suppress(Exception): root.destroy() if __name__ == "__main__": # 단독 테스트 show_intro_splash(Path(__file__).resolve().parent / "Design" / "logo_intro.mp4")