#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""웹진 숏츠용 28종 인트로 템플릿.

Google Drive 참고 영상의 원본 프레임·음원·로고는 사용하지 않는다.
참고 영상에서 관찰한 레이아웃, 색감, 등장 순서만 코드로 재구성한다.
"""

from __future__ import annotations

import os
import random
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont

WIDTH, HEIGHT = 1080, 1920


@dataclass(frozen=True)
class IntroTemplate:
    template_id: int
    name: str
    layout: str
    background: tuple[int, int, int]
    accent: tuple[int, int, int]
    foreground: tuple[int, int, int]
    secondary: tuple[int, int, int]
    motion: str
    duration: float = 2.6


_SPECS = [
    (1, '구름 명함', 'badge', (229,244,238), (247,196,74), (31,63,58), (255,255,255), 'fade_zoom'),
    (2, '안전 경고', 'warning', (249,101,48), (25,25,25), (255,255,255), (255,221,64), 'push_up'),
    (3, '블루 키워드', 'keyword', (28,112,219), (255,224,55), (255,255,255), (13,63,131), 'slide_left'),
    (4, '네온 포커스', 'neon', (16,18,23), (126,255,67), (255,255,255), (63,69,77), 'pulse'),
    (5, '클린 지도', 'map', (238,247,240), (78,176,83), (25,74,50), (255,255,255), 'wipe_right'),
    (6, '옐로 카탈로그', 'catalog', (255,210,57), (47,66,72), (35,48,52), (255,255,255), 'slide_up'),
    (7, '팝아트 카드', 'pop', (255,226,0), (228,43,71), (28,28,28), (255,255,255), 'pop'),
    (8, '매물 정보띠', 'ticker', (242,248,243), (53,143,82), (26,50,39), (255,255,255), 'wipe_left'),
    (9, '폴딩 패널', 'fold', (236,247,239), (54,140,79), (21,58,39), (255,255,255), 'zoom_out'),
    (10, '브라운 프로필', 'profile', (65,43,42), (245,203,65), (255,255,255), (240,233,222), 'slide_right'),
    (11, '시네마 상담', 'cinema', (33,31,31), (23,220,128), (255,255,255), (93,91,91), 'fade'),
    (12, '아치 윈도우', 'arch', (225,231,225), (238,194,105), (42,57,51), (255,255,255), 'iris'),
    (13, '모니터 프레임', 'monitor', (246,226,235), (49,150,78), (30,51,40), (255,255,255), 'zoom_in'),
    (14, '다크 브랜드', 'dark_brand', (43,75,69), (246,218,72), (255,255,255), (23,42,39), 'push_down'),
    (15, '뉴스 속보', 'news', (12,12,14), (224,31,42), (255,255,255), (82,82,87), 'flash'),
    (16, '일러스트 환영', 'welcome', (202,239,247), (234,77,85), (35,66,75), (255,255,255), 'bounce'),
    (17, '오렌지 포스터', 'poster', (242,180,45), (19,88,68), (255,255,255), (38,38,38), 'tilt'),
    (18, '블루 테크', 'tech', (238,244,255), (57,92,226), (23,47,105), (255,255,255), 'scan'),
    (19, '필름 스트립', 'film', (8,13,11), (144,245,52), (255,255,255), (50,64,56), 'film'),
    (20, '블랙 카드월', 'card_wall', (8,10,12), (49,226,143), (255,255,255), (40,49,55), 'grid'),
    (21, '핑크 리스트', 'list', (255,231,232), (225,48,77), (74,43,47), (255,255,255), 'cascade'),
    (22, '그린 질문', 'question', (234,246,237), (34,146,70), (28,71,44), (255,255,255), 'typewriter'),
    (23, '블루 앱카드', 'app', (20,101,226), (71,240,178), (255,255,255), (9,60,148), 'stack'),
    (25, '미니멀 명함', 'business', (245,248,255), (46,84,221), (20,45,96), (255,255,255), 'wipe_up'),
    (26, '옐로 임팩트', 'impact', (255,223,50), (23,131,71), (24,45,35), (255,255,255), 'impact'),
    (27, '포토 콜라주', 'collage', (249,246,239), (239,104,45), (54,48,42), (255,255,255), 'collage'),
    (28, '파노라마 매물', 'panorama', (238,246,250), (31,108,186), (23,55,83), (255,255,255), 'pan'),
]

INTRO_TEMPLATES = {row[0]: IntroTemplate(*row) for row in _SPECS}
INTRO_TEMPLATE_IDS = tuple(INTRO_TEMPLATES)


def _enabled_ids() -> list[int]:
    raw = os.getenv('MULTI_SHORTS_APPROVED_INTRO_IDS', '').strip()
    if not raw:
        return list(INTRO_TEMPLATE_IDS)
    values = []
    for token in raw.split(','):
        try:
            value = int(token.strip())
        except ValueError:
            continue
        if value in INTRO_TEMPLATES and value not in values:
            values.append(value)
    return values or list(INTRO_TEMPLATE_IDS)


def select_intro_template(request: dict | None, seed: int) -> tuple[IntroTemplate, str]:
    request = request or {}
    manual = request.get('intro_template_id')
    try:
        manual = int(manual)
    except (TypeError, ValueError):
        manual = 0
    if manual in INTRO_TEMPLATES:
        return INTRO_TEMPLATES[manual], 'manual'
    ids = _enabled_ids()
    return INTRO_TEMPLATES[random.Random(seed * 7919 + 2801).choice(ids)], 'random'


def _font(path: Path, size: int):
    try:
        return ImageFont.truetype(str(path), size)
    except Exception:
        return ImageFont.load_default()


def _wrap(draw: ImageDraw.ImageDraw, text: str, font, width: int, lines: int = 2) -> list[str]:
    result, current = [], ''
    for char in str(text or ''):
        trial = current + char
        if draw.textbbox((0, 0), trial, font=font)[2] <= width:
            current = trial
        else:
            if current:
                result.append(current)
            current = char
            if len(result) >= lines:
                break
    if current and len(result) < lines:
        result.append(current)
    if len(result) == lines and ''.join(result) != str(text or ''):
        result[-1] = result[-1].rstrip(' .') + '…'
    return result


def _cover(path: Path | None, box: tuple[int, int]) -> Image.Image | None:
    if not path:
        return None
    try:
        with Image.open(path) as source:
            image = source.convert('RGB')
            ratio = max(box[0] / image.width, box[1] / image.height)
            image = image.resize((int(image.width * ratio), int(image.height * ratio)), Image.Resampling.LANCZOS)
            left, top = (image.width - box[0]) // 2, (image.height - box[1]) // 2
            return image.crop((left, top, left + box[0], top + box[1]))
    except Exception:
        return None


def _put_photo(canvas: Image.Image, image_path: Path | None, rect: tuple[int, int, int, int], radius: int = 24) -> None:
    x1, y1, x2, y2 = rect
    photo = _cover(image_path, (x2 - x1, y2 - y1))
    if photo is None:
        photo = Image.new('RGB', (x2 - x1, y2 - y1), (195, 211, 205))
    mask = Image.new('L', photo.size, 0)
    ImageDraw.Draw(mask).rounded_rectangle((0, 0, photo.width, photo.height), radius, fill=255)
    canvas.paste(photo, (x1, y1), mask)


def render_intro_card(output: Path, template: IntroTemplate, image_path: Path | None,
                      title: str, brand: str, representative: str, phone: str,
                      font_regular: Path, font_bold: Path) -> Path:
    """개인정보와 매물정보가 치환된 1080x1920 인트로 원본을 만든다."""
    im = Image.new('RGB', (WIDTH, HEIGHT), template.background)
    draw = ImageDraw.Draw(im, 'RGBA')
    fg, ac, sec = template.foreground, template.accent, template.secondary
    f_brand = _font(font_bold, 56)
    f_title = _font(font_bold, 72)
    f_small = _font(font_regular, 34)
    f_phone = _font(font_bold, 58)
    layout = template.layout

    # 28종을 7개 레이아웃 계열과 각 템플릿 고유 색·장식으로 구성한다.
    photo_rect = (80, 550, 1000, 1290)
    if layout in {'warning', 'news', 'impact', 'poster'}:
        draw.polygon([(0,0),(WIDTH,0),(WIDTH,330),(0,440)], fill=(*ac,255))
        draw.rectangle((0, 1720, WIDTH, HEIGHT), fill=(*sec,245))
        photo_rect = (70, 460, 1010, 1320)
    elif layout in {'neon', 'dark_brand', 'film', 'card_wall', 'cinema'}:
        draw.rectangle((45, 45, 1035, 1875), outline=(*ac,220), width=5)
        draw.rectangle((0, 0, WIDTH, 315), fill=(0,0,0,145))
        photo_rect = (90, 470, 990, 1260)
    elif layout in {'map', 'ticker', 'question', 'app', 'business'}:
        draw.rounded_rectangle((45, 45, 1035, 360), 38, fill=(*sec,240))
        draw.rectangle((45, 340, 1035, 352), fill=(*ac,255))
        photo_rect = (80, 505, 1000, 1260)
    elif layout in {'arch', 'door'}:
        draw.rounded_rectangle((120, 370, 960, 1370), 260, fill=(*sec,255), outline=(*ac,230), width=8)
        photo_rect = (145, 400, 935, 1340)
    elif layout in {'collage', 'catalog', 'list'}:
        photo_rect = (95, 500, 735, 1210)
        draw.rounded_rectangle((760, 500, 1010, 1210), 26, fill=(*sec,230))
        for y in (550, 745, 940):
            draw.rounded_rectangle((790, y, 980, y+145), 18, fill=(*ac,90), outline=(*ac,180), width=3)
    elif layout in {'panorama', 'fold'}:
        photo_rect = (0, 430, WIDTH, 1350)
        draw.rectangle((0, 420, WIDTH, 435), fill=(*ac,255))
        draw.rectangle((0, 1345, WIDTH, 1360), fill=(*ac,255))
    else:
        draw.rounded_rectangle((55, 55, 1025, 360), 42, fill=(*sec,235))

    _put_photo(im, image_path, photo_rect, 20 if layout not in {'panorama','fold'} else 0)
    draw = ImageDraw.Draw(im, 'RGBA')

    if layout == 'film':
        for x in range(25, WIDTH, 115):
            draw.rectangle((x, 390, x+62, 440), fill=(245,245,225,220))
            draw.rectangle((x, 1365, x+62, 1415), fill=(245,245,225,220))
    elif layout == 'news':
        draw.text((55, 65), 'PROPERTY  NEWS', font=_font(font_bold, 78), fill='white')
        draw.rectangle((55, 255, 380, 330), fill=(255,255,255,230))
        draw.text((80, 267), 'NEW LISTING', font=_font(font_bold, 34), fill=ac)
    elif layout == 'warning':
        draw.rectangle((0, 0, WIDTH, 45), fill=(25,25,25,255))
        for x in range(-50, WIDTH, 120):
            draw.polygon([(x,0),(x+55,0),(x+10,45),(x-45,45)], fill=(255,209,45,255))
    elif layout == 'door':
        draw.line((WIDTH//2, 390, WIDTH//2, 1350), fill=(*ac,210), width=8)
        draw.ellipse((510, 850, 540, 880), fill=(*ac,255))
    elif layout == 'pop':
        for x,y,r in ((90,400,30),(970,350,22),(80,1390,18),(980,1450,35)):
            draw.ellipse((x-r,y-r,x+r,y+r), fill=(*ac,220))
    elif layout in {'tech','app'}:
        for y in range(430, 1450, 120):
            draw.line((35,y,1045,y), fill=(*ac,45), width=2)
        for x in range(35, 1080, 120):
            draw.line((x,390,x,1450), fill=(*ac,35), width=2)

    # 중개사 정보는 원본 예시 상호 대신 항상 DB 값으로 치환한다.
    brand_y = 95 if layout not in {'warning','news','impact','poster'} else 170
    for line in _wrap(draw, brand or '공인중개사사무소', f_brand, 940, 2):
        draw.text((70, brand_y), line, font=f_brand, fill=fg, stroke_width=2, stroke_fill=(*template.background,210))
        brand_y += 70
    if representative:
        draw.text((72, brand_y+5), f'대표 {representative}', font=f_small, fill=fg)

    title_y = 1435
    draw.rounded_rectangle((45, title_y-35, 1035, 1700), 30, fill=(0,0,0,155))
    y = title_y
    for line in _wrap(draw, title or '추천 매물 안내', f_title, 930, 2):
        draw.text((72, y), line, font=f_title, fill='white', stroke_width=3, stroke_fill=(0,0,0,180))
        y += 88
    draw.text((70, 1760), phone or '상담 전 현재 매물 여부를 확인해 주세요', font=f_phone if phone else f_small,
              fill=ac, stroke_width=2, stroke_fill=(0,0,0,165))
    draw.text((70, 1840), f'INTRO {template.template_id:02d} · {template.name}', font=_font(font_regular, 24), fill=(*fg,170))
    output.parent.mkdir(parents=True, exist_ok=True)
    im.save(output, quality=95)
    return output


def _fit_font(draw, text, path, start, minimum, width, lines=2):
    for size in range(start, minimum - 1, -2):
        candidate = _font(path, size)
        wrapped = _wrap(draw, text, candidate, width, lines)
        if len(wrapped) <= lines and all(draw.textbbox((0,0), x, font=candidate)[2] <= width for x in wrapped):
            return candidate
    return _font(path, minimum)


def _text_block(draw, text, xy, font_path, size, color, width, lines=2, spacing=10,
                stroke=0, stroke_fill=(0,0,0,180), minimum=30):
    f = _fit_font(draw, text, font_path, size, minimum, width, lines)
    x, y = xy
    for line in _wrap(draw, text, f, width, lines):
        draw.text((x,y), line, font=f, fill=color, stroke_width=stroke, stroke_fill=stroke_fill)
        y += getattr(f, 'size', size) + spacing
    return y


def _photo(im, path, rect, radius=0, border=None, border_width=0):
    _put_photo(im, path, rect, radius)
    if border and border_width:
        ImageDraw.Draw(im,'RGBA').rounded_rectangle(rect, radius, outline=border, width=border_width)


def _circle_photo(im, path, rect, fallback_text, font_bold, accent):
    """중개사 프로필을 원형으로 표시하고 사진이 없으면 이름 첫 글자로 대체한다."""
    x1,y1,x2,y2=rect;size=(x2-x1,y2-y1);photo=_cover(path,size)
    mask=Image.new('L',size,0);ImageDraw.Draw(mask).ellipse((0,0,size[0]-1,size[1]-1),fill=255)
    if photo is not None:
        im.paste(photo,(x1,y1),mask)
    else:
        placeholder=Image.new('RGB',size,accent);pd=ImageDraw.Draw(placeholder)
        label=(fallback_text or '중')[0];f=_font(font_bold,max(54,int(size[0]*0.40)))
        box=pd.textbbox((0,0),label,font=f);pd.text(((size[0]-(box[2]-box[0]))//2,(size[1]-(box[3]-box[1]))//2-8),label,font=f,fill='white')
        im.paste(placeholder,(x1,y1),mask)
    ImageDraw.Draw(im,'RGBA').ellipse(rect,outline=(255,255,255,220),width=7)


def render_intro_card(output: Path, template: IntroTemplate, image_path: Path | None,
                      title: str, brand: str, representative: str, phone: str,
                      font_regular: Path, font_bold: Path, profile_path: Path | None = None,
                      property_type: str = '부동산') -> Path:
    """28개가 서로 다른 골격을 갖는 인트로 카드 v2."""
    title = title or '추천 매물 안내'
    brand = brand or '공인중개사사무소'
    representative = representative or '담당 중개사'
    phone = phone or '상담 전 현재 매물 여부를 확인해 주세요'
    t=template; tid=t.template_id; bg=t.background; ac=t.accent; fg=t.foreground; sec=t.secondary
    im=Image.new('RGB',(WIDTH,HEIGHT),bg); d=ImageDraw.Draw(im,'RGBA')
    white=(255,255,255,255); black=(15,18,20,255); muted=(*fg,170)

    if tid==1:  # 구름 명함
        d.ellipse((-180,-220,450,360),fill=(255,255,255,190));d.ellipse((270,-180,930,320),fill=(255,255,255,150));d.ellipse((740,-120,1210,300),fill=(255,255,255,185))
        d.rounded_rectangle((65,110,1015,420),55,fill=(*sec,245));d.ellipse((95,160,285,350),fill=(*ac,255))
        _text_block(d,brand,(325,150),font_bold,66,fg,620,2);_text_block(d,f'대표 {representative}',(330,315),font_regular,36,fg,600,1)
        _photo(im,image_path,(95,540,985,1285),70,border=(*ac,255),border_width=8)
        _text_block(d,title,(95,1390),font_bold,68,fg,890,2);d.rounded_rectangle((95,1680,985,1815),36,fill=(*ac,255));_text_block(d,phone,(145,1712),font_bold,49,fg,790,1)
    elif tid==2:  # 경고 포스터
        for x in range(-100,WIDTH+100,150):d.polygon([(x,0),(x+75,0),(x-20,100),(x-95,100)],fill=(25,25,25,255))
        d.text((60,145),'지금 확인하세요',font=_font(font_bold,78),fill=black);d.rectangle((55,265,1025,285),fill=black)
        _photo(im,image_path,(420,390,1025,1230),20,border=black,border_width=10)
        d.rounded_rectangle((55,420,380,930),30,fill=(255,255,255,235));_text_block(d,'NEW\nPROPERTY',(90,490),font_bold,64,black,260,2);_text_block(d,brand,(75,1010),font_bold,44,black,320,3)
        d.polygon([(0,1320),(1080,1190),(1080,1920),(0,1920)],fill=(25,25,25,245));_text_block(d,title,(65,1395),font_bold,76,white,950,3,stroke=0);_text_block(d,phone,(65,1770),font_bold,54,ac,940,1)
    elif tid==3:  # 블루 키워드
        _text_block(d,'찾으시는 매물 있으세요?',(55,85),font_bold,82,white,970,2,stroke=2);d.rectangle((55,300,1015,315),fill=(*ac,255))
        _text_block(d,brand,(65,385),font_bold,48,white,930,2);_photo(im,image_path,(0,570,1080,1300),0)
        d.rectangle((0,1300,1080,1920),fill=(*sec,245));_text_block(d,title,(65,1370),font_bold,76,white,950,3);d.rectangle((65,1740,1015,1750),fill=(*ac,255));_text_block(d,phone,(65,1780),font_bold,52,ac,940,1)
    elif tid==4:  # 네온
        d.rounded_rectangle((40,40,1040,1880),40,outline=(*ac,255),width=7);d.rounded_rectangle((70,90,1010,310),30,fill=(0,0,0,180),outline=(*ac,200),width=3)
        _text_block(d,brand,(110,135),font_bold,61,white,850,2,stroke=2);_photo(im,image_path,(100,410,980,1220),28,border=(*ac,255),border_width=5)
        d.line((160,1285,920,1285),fill=(*ac,220),width=5);_text_block(d,title,(105,1345),font_bold,75,white,870,3,stroke=2);_text_block(d,phone,(115,1740),font_bold,55,ac,850,1)
    elif tid==5:  # 중개사 프로필
        d.rounded_rectangle((55,65,1025,400),44,fill=white,outline=(*ac,110),width=3)
        _circle_photo(im,profile_path,(95,105,355,365),representative or brand,font_bold,ac)
        _text_block(d,brand,(400,120),font_bold,57,fg,570,2);_text_block(d,f'대표 {representative}',(405,300),font_regular,35,ac,550,1)
        _photo(im,image_path,(90,500,990,1260),38,border=white,border_width=10);_text_block(d,title,(90,1360),font_bold,70,fg,900,3);_text_block(d,phone,(90,1770),font_bold,52,ac,900,1)
    elif tid==6:  # 카탈로그
        d.rectangle((0,0,1080,310),fill=(*ac,255));_text_block(d,brand,(65,70),font_bold,64,fg,950,2);_text_block(d,f'대표 {representative}',(70,225),font_regular,36,fg,600,1)
        _photo(im,image_path,(45,390,625,1190),20);d.rounded_rectangle((650,390,1035,1190),24,fill=white)
        info=((property_type or '부동산','매물 유형'),('가격 조건','상담 확인'),('문의 전화',phone))
        for i,(value,label) in enumerate(info):
            y=435+i*245;d.text((690,y),label,font=_font(font_regular,29),fill=(*fg,170));_text_block(d,value,(690,y+58),font_bold,39,fg,305,2,minimum=27);d.line((685,y+175,1000,y+175),fill=(*ac,210),width=5)
        _text_block(d,title,(65,1295),font_bold,70,fg,950,3);d.rectangle((0,1740,1080,1920),fill=(*fg,255));_text_block(d,phone,(70,1790),font_bold,53,ac,940,1)
    elif tid==7:  # 팝아트
        for x,y,r in ((100,100,55),(940,150,35),(160,1420,28),(970,1360,60)):d.ellipse((x-r,y-r,x+r,y+r),fill=(*ac,255))
        d.polygon([(0,330),(1080,250),(1080,420),(0,500)],fill=(255,255,255,190));_text_block(d,brand,(70,330),font_bold,68,black,930,2)
        _photo(im,image_path,(100,560,980,1260),0,border=black,border_width=12);d.polygon([(45,1330),(1035,1260),(1010,1690),(70,1760)],fill=(*sec,248))
        _text_block(d,title,(115,1370),font_bold,78,black,860,3);_text_block(d,phone,(130,1768),font_bold,54,fg,830,1)
    elif tid==8:  # 정보 티커
        d.rectangle((0,0,1080,210),fill=(*ac,255));_text_block(d,brand,(60,55),font_bold,60,white,950,2)
        d.rectangle((0,240,1080,370),fill=white);_text_block(d,'오늘의 추천 매물  ·  NEW LISTING',(55,280),font_bold,41,fg,970,1)
        _photo(im,image_path,(0,410,1080,1170),0);d.rectangle((0,1170,1080,1245),fill=(*ac,245));_text_block(d,'실시간 매물 정보',(55,1185),font_bold,36,white,800,1)
        _text_block(d,title,(65,1325),font_bold,75,fg,950,3);d.line((65,1715,1015,1715),fill=(*ac,255),width=8);_text_block(d,phone,(65,1760),font_bold,52,ac,940,1)
    elif tid==9:  # 폴딩 패널
        for i,x in enumerate((0,270,540,810)):d.polygon([(x,0),(x+270,70 if i%2 else 0),(x+270,1920),(x,1850 if i%2 else 1920)],fill=((*bg,255) if i%2==0 else (*sec,255)))
        _photo(im,image_path,(135,410,945,1250),0);d.polygon([(135,410),(540,470),(540,1190),(135,1250)],fill=(0,0,0,35))
        _text_block(d,brand,(80,100),font_bold,62,fg,920,2);_text_block(d,title,(85,1360),font_bold,72,fg,910,3);_text_block(d,phone,(85,1760),font_bold,52,ac,900,1)
    elif tid==10:  # 프로필 카드
        d.rectangle((0,0,1080,520),fill=(*bg,255));_circle_photo(im,profile_path,(70,110,340,380),representative or brand,font_bold,ac)
        _text_block(d,brand,(390,125),font_bold,58,white,620,2);_text_block(d,f'대표 {representative}',(395,320),font_regular,38,ac,560,1)
        _photo(im,image_path,(80,570,1000,1260),25,border=(*ac,255),border_width=5);_text_block(d,title,(80,1360),font_bold,70,white,920,3);_text_block(d,phone,(80,1770),font_bold,54,ac,920,1)
    elif tid==11:  # 시네마 원형
        photo=_cover(image_path,(880,880)) or Image.new('RGB',(880,880),(90,90,90));mask=Image.new('L',(880,880),0);ImageDraw.Draw(mask).ellipse((0,0,880,880),fill=255);im.paste(photo,(100,360),mask)
        d.ellipse((85,345,995,1255),outline=(*ac,220),width=8);d.rectangle((0,0,1080,250),fill=(0,0,0,190));_text_block(d,brand,(70,70),font_bold,62,white,940,2)
        d.rectangle((0,1290,1080,1920),fill=(0,0,0,210));_text_block(d,title,(75,1380),font_bold,76,white,930,3);_text_block(d,phone,(75,1770),font_bold,52,ac,930,1)
    elif tid==12:  # 아치
        d.text((80,80),'OPEN HOUSE',font=_font(font_bold,70),fill=(*ac,255));_text_block(d,brand,(80,190),font_bold,52,fg,920,2)
        photo=_cover(image_path,(800,900)) or Image.new('RGB',(800,900),(190,200,190));mask=Image.new('L',(800,900),0);ImageDraw.Draw(mask).rounded_rectangle((0,0,800,1100),300,fill=255);im.paste(photo,(140,400),mask)
        d.rounded_rectangle((125,385,955,1320),315,outline=(*ac,255),width=10);_text_block(d,title,(90,1410),font_bold,68,fg,900,3);_text_block(d,phone,(90,1770),font_bold,50,ac,900,1)
    elif tid==13:  # 모니터
        d.rounded_rectangle((55,250,1025,1260),40,fill=(29,39,43,255));_photo(im,image_path,(95,300,985,1130),12);d.rectangle((455,1260,625,1400),fill=(45,55,58,255));d.rounded_rectangle((300,1385,780,1450),18,fill=(45,55,58,255))
        _text_block(d,brand,(65,80),font_bold,60,fg,950,2);d.rounded_rectangle((70,1510,1010,1815),35,fill=(*sec,245));_text_block(d,title,(110,1550),font_bold,65,fg,860,2);_text_block(d,phone,(110,1740),font_bold,48,ac,850,1)
    elif tid==14:  # 다크 브랜드
        d.text((70,80),brand,font=_fit_font(d,brand,font_bold,82,38,940,2),fill=white);d.text((70,230),phone,font=_font(font_bold,48),fill=(*ac,255))
        _photo(im,image_path,(0,390,1080,1320),0);d.rectangle((0,1120,1080,1320),fill=(0,0,0,120));d.line((70,1380,1010,1380),fill=(*ac,255),width=7)
        _text_block(d,title,(70,1450),font_bold,75,white,940,3);_text_block(d,f'대표 {representative}',(70,1810),font_regular,38,ac,900,1)
    elif tid==15:  # 뉴스
        d.rectangle((55,90,750,245),fill=(*ac,255));d.text((90,122),'HEADLINE NEWS',font=_font(font_bold,67),fill=white);_photo(im,image_path,(0,340,1080,1190),0)
        d.rectangle((0,1190,1080,1290),fill=(*ac,255));_text_block(d,'부동산 매물 속보',(55,1210),font_bold,48,white,950,1)
        _text_block(d,title,(60,1370),font_bold,73,white,960,3);d.rectangle((0,1770,1080,1920),fill=(245,245,245,255));_text_block(d,f'{brand}  |  {phone}',(45,1810),font_bold,42,black,990,2)
    elif tid==16:  # 환영
        d.ellipse((-250,-300,500,480),fill=(255,255,255,150));d.ellipse((650,-220,1250,390),fill=(255,255,255,160));d.text((90,120),'WELCOME',font=_font(font_bold,96),fill=(*ac,255))
        _text_block(d,brand,(95,280),font_bold,58,fg,900,2);_photo(im,image_path,(120,600,960,1250),85,border=white,border_width=12)
        _text_block(d,title,(90,1370),font_bold,70,fg,900,3);d.rounded_rectangle((90,1715,990,1840),55,fill=(*ac,255));_text_block(d,phone,(140,1745),font_bold,48,white,800,1)
    elif tid==17:  # 사선 포스터
        d.polygon([(0,0),(1080,0),(1080,420),(0,610)],fill=(*ac,255));d.polygon([(0,1430),(1080,1230),(1080,1920),(0,1920)],fill=(*sec,255))
        _text_block(d,brand,(70,105),font_bold,68,white,940,2);_photo(im,image_path,(120,510,960,1270),0,border=white,border_width=10)
        _text_block(d,title,(70,1390),font_bold,75,white,950,3);_text_block(d,phone,(70,1780),font_bold,52,ac,930,1)
    elif tid==18:  # 테크
        for x in range(0,1080,90):d.line((x,0,x,1920),fill=(*ac,25),width=2)
        for y in range(0,1920,90):d.line((0,y,1080,y),fill=(*ac,25),width=2)
        d.polygon([(0,0),(800,0),(620,330),(0,330)],fill=(*ac,255));_text_block(d,brand,(55,80),font_bold,57,white,680,2)
        _photo(im,image_path,(75,430,1005,1220),15,border=(*ac,255),border_width=4);d.line((75,1290,1005,1290),fill=(*ac,255),width=5)
        _text_block(d,title,(75,1360),font_bold,72,fg,930,3);_text_block(d,phone,(75,1770),font_bold,52,ac,920,1)
    elif tid==19:  # 필름
        for y in (40,1780):
            for x in range(20,1080,115):d.rectangle((x,y,x+65,y+72),fill=(245,240,215,240))
        d.text((60,150),'PROPERTY FILM',font=_font(font_bold,70),fill=(*ac,255));_photo(im,image_path,(80,350,1000,1220),0,border=(*ac,255),border_width=5)
        _text_block(d,title,(80,1320),font_bold,72,white,920,3);_text_block(d,brand,(80,1645),font_bold,43,ac,920,2);_text_block(d,phone,(80,1715),font_bold,46,white,900,1)
    elif tid==20:  # 카드월
        d.text((55,60),'TODAY',font=_font(font_bold,82),fill=(*ac,255));_text_block(d,brand,(380,80),font_bold,47,white,640,2)
        _photo(im,image_path,(55,300,700,1040),18,border=(*ac,255),border_width=4)
        for i,(label,val) in enumerate((('TYPE',property_type or '부동산'),('AGENT',representative),('CALL',phone))):
            y=300+i*245;d.rounded_rectangle((740,y,1025,y+210),22,fill=(*sec,255),outline=(*ac,190),width=3);d.text((770,y+35),label,font=_font(font_bold,30),fill=(*ac,255));_text_block(d,val,(770,y+90),font_bold,36,white,220,2,minimum=25)
        _text_block(d,title,(60,1170),font_bold,75,white,950,3);d.rectangle((55,1700,1025,1710),fill=(*ac,255))
    elif tid==21:  # 리스트
        d.rectangle((0,0,1080,260),fill=(*ac,255));_text_block(d,brand,(60,65),font_bold,62,white,950,2)
        _photo(im,image_path,(55,330,525,930),18);d.rounded_rectangle((565,330,1025,930),25,fill=white)
        for i,label in enumerate(('01  위치와 교통','02  가격과 조건','03  사진으로 확인')):d.text((600,405+i*145),label,font=_font(font_bold,35),fill=fg);d.line((600,460+i*145,970,460+i*145),fill=(*ac,120),width=3)
        _text_block(d,title,(60,1050),font_bold,75,fg,950,3);d.rounded_rectangle((60,1610,1020,1810),35,fill=(*sec,245));_text_block(d,phone,(100,1670),font_bold,53,ac,880,1)
    elif tid==22:  # 검색 질문
        d.text((65,100),'어떤 매물을',font=_font(font_bold,76),fill=fg);d.text((65,205),'찾으시나요?',font=_font(font_bold,96),fill=(*ac,255))
        d.rounded_rectangle((70,390,1010,530),70,fill=white,outline=(*ac,255),width=5);d.text((125,425),title[:22],font=_fit_font(d,title,font_regular,45,27,730,1),fill=fg);d.ellipse((875,420,950,495),outline=(*ac,255),width=9);d.line((935,485,980,530),fill=(*ac,255),width=9)
        _photo(im,image_path,(90,650,990,1260),42);_text_block(d,brand,(90,1370),font_bold,62,fg,900,2);_text_block(d,phone,(90,1700),font_bold,53,ac,900,1)
    elif tid==23:  # 앱 카드
        d.rounded_rectangle((55,70,1025,1850),65,fill=(*sec,255));d.rounded_rectangle((420,105,660,125),10,fill=(255,255,255,100))
        _text_block(d,brand,(110,210),font_bold,60,white,850,2);_photo(im,image_path,(110,470,970,1150),35)
        d.rounded_rectangle((110,1230,970,1565),35,fill=(255,255,255,245));_text_block(d,title,(155,1280),font_bold,60,fg,770,3)
        d.rounded_rectangle((110,1630,970,1760),60,fill=(*ac,255));_text_block(d,phone,(165,1665),font_bold,48,fg,760,1)
    elif tid==25:  # 명함
        d.rounded_rectangle((70,130,1010,700),45,fill=white,outline=(*ac,150),width=4);d.rectangle((70,130,95,700),fill=(*ac,255));_text_block(d,brand,(145,210),font_bold,68,fg,790,2);_text_block(d,f'대표 {representative}',(150,430),font_regular,40,fg,700,1);_text_block(d,phone,(150,535),font_bold,53,ac,760,1)
        _photo(im,image_path,(70,820,1010,1370),25);_text_block(d,title,(80,1470),font_bold,69,fg,920,3);d.line((80,1770,1000,1770),fill=(*ac,255),width=7)
    elif tid==26:  # 임팩트
        d.polygon([(0,0),(1080,0),(1080,500),(0,730)],fill=(*ac,255));d.polygon([(0,520),(1080,300),(1080,520),(0,750)],fill=(255,255,255,130))
        _text_block(d,brand,(65,110),font_bold,67,fg,950,2);d.text((70,350),'추천 매물',font=_font(font_bold,95),fill=white)
        _photo(im,image_path,(130,660,950,1280),0,border=white,border_width=12);_text_block(d,title,(70,1370),font_bold,74,fg,950,3);d.rounded_rectangle((70,1710,1010,1840),25,fill=(*ac,255));_text_block(d,phone,(120,1745),font_bold,50,white,840,1)
    elif tid==27:  # 콜라주
        _photo(im,image_path,(70,280,620,940),12,border=white,border_width=15);_photo(im,image_path,(520,520,1015,1170),12,border=white,border_width=15)
        d.polygon([(70,1150),(500,1060),(550,1370),(110,1440)],fill=(*ac,230));d.text((135,1195),'PHOTO',font=_font(font_bold,70),fill=white)
        _text_block(d,brand,(65,80),font_bold,59,fg,950,2);_text_block(d,title,(70,1490),font_bold,68,fg,940,2);_text_block(d,phone,(70,1770),font_bold,50,ac,930,1)
    else:  # 28 파노라마
        d.rectangle((0,0,1080,310),fill=white);_text_block(d,brand,(65,65),font_bold,62,fg,950,2);_text_block(d,f'대표 {representative}  |  {phone}',(70,225),font_regular,34,ac,920,1)
        _photo(im,image_path,(0,370,1080,1260),0);d.rectangle((0,1080,1080,1260),fill=(0,0,0,125));d.text((55,1120),'PANORAMA PROPERTY',font=_font(font_bold,55),fill=white)
        _text_block(d,title,(65,1360),font_bold,76,fg,950,3);d.rectangle((65,1770,1015,1780),fill=(*ac,255))

    # 번호는 검수용으로 작게만 표시하며 운영 화면을 방해하지 않는다.
    d.text((900,1870),f'{tid:02d}/28',font=_font(font_regular,24),fill=muted)
    output.parent.mkdir(parents=True,exist_ok=True);im.save(output,quality=95)
    return output


def _video_filter(motion: str, duration: float, fps: int = 30) -> str:
    frames = max(1, int(duration * fps))
    # 정지 카드에 안전한 미세 움직임을 주며 텍스트가 화면 밖으로 잘리지 않게 한다.
    zoom = {
        'zoom_out': "zoompan=z='if(eq(on,1),1.10,max(1.0,zoom-0.0012))':d=1",
        'pulse': "zoompan=z='1.025+0.012*sin(on/7)':d=1",
        'impact': "zoompan=z='if(lt(on,10),1.12-0.012*on,1.0)':d=1",
        'pop': "zoompan=z='if(lt(on,14),1.10-0.007*on,1.01)':d=1",
        'pan': "zoompan=z='1.06':x='(iw-iw/zoom)*on/%d':d=1" % frames,
    }.get(motion, "zoompan=z='min(1.06,1+0.0007*on)':d=1")
    fade_out = max(0.0, duration - 0.28)
    return f"{zoom}:s={WIDTH}x{HEIGHT}:fps={fps},fade=t=in:st=0:d=0.24,fade=t=out:st={fade_out:.2f}:d=0.28,format=yuv420p"


def render_intro_segment(ffmpeg: str, card: Path, output: Path, template: IntroTemplate) -> Path:
    cmd = [ffmpeg, '-y', '-loop', '1', '-i', str(card), '-t', f'{template.duration:.2f}',
           '-vf', _video_filter(template.motion, template.duration), '-an', '-r', '30',
           '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', str(output)]
    subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=180)
    return output
