#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""웹진 전용 숏츠 렌더 Worker.

매물HOME v1.0 Worker/Queue는 사용하거나 변경하지 않는다.
실사진이 6장 미만이면 mybox/v1_images/{매물종류}에서 보충하고,
고정 UI 위의 사진 영역에만 모션을 적용한다. 평면도는 별도 정적 장면이다.
"""

from __future__ import annotations

import argparse
import asyncio
import hashlib
import html
import json
import logging
import os
import random
import re
import shutil
import math
import struct
import subprocess
import sys
import urllib.request
import unicodedata
import wave
from pathlib import Path
from typing import Any

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

BASE_DIR=Path(__file__).resolve().parents[1]
if str(BASE_DIR) not in sys.path: sys.path.insert(0,str(BASE_DIR))
from db import get_conn
from multi_shorts_templates import select_template
from multi_shorts_intro_templates import (
    INTRO_TEMPLATE_IDS,
    render_intro_card,
    render_intro_segment,
    select_intro_template,
)

LOCK_NAME='multi_webzine_shorts_render_worker'
ROOT=Path(os.getenv('MULTI_SHORTS_ROOT',str(BASE_DIR)))
MYBOX_ROOT=Path(os.getenv('MULTI_SHORTS_MYBOX_V1_IMAGES',str(ROOT/'mybox'/'v1_images')))
OUTPUT_ROOT=Path(os.getenv('MULTI_SHORTS_OUTPUT_DIR',str(ROOT/'outputs')))
TEMP_ROOT=Path(os.getenv('MULTI_SHORTS_TEMP_DIR',str(ROOT/'temp'/'multi_shorts')))
FFMPEG=os.getenv('FFMPEG_PATH','D:/ffmpeg/bin/ffmpeg.exe')
FFPROBE=os.getenv('FFPROBE_PATH','D:/ffmpeg/bin/ffprobe.exe')
FONT=Path(os.getenv('MULTI_SHORTS_FONT','C:/Windows/Fonts/malgun.ttf'))
FONT_BOLD=Path(os.getenv('MULTI_SHORTS_FONT_BOLD','C:/Windows/Fonts/malgunbd.ttf'))
FONT_DISPLAY=Path(os.getenv('MULTI_SHORTS_FONT_DISPLAY',str(FONT_BOLD)))
VOICE=os.getenv('MULTI_SHORTS_VOICE','')
VOICE_FALLBACKS=['ko-KR-SunHiNeural','ko-KR-InJoonNeural','ko-KR-HyunsuMultilingualNeural']
BGM_ROOT=Path(os.getenv('MULTI_SHORTS_BGM_DIR',str(ROOT/'bgm')))
BGM_VOLUME=max(0.08,min(0.45,float(os.getenv('MULTI_SHORTS_BGM_VOLUME','0.30'))))
WIDTH,HEIGHT=1080,1920
PHOTO_BOX=(70,300,1010,1220)
TARGET_SHORTS_SECONDS=max(35.0,min(58.0,float(os.getenv('MULTI_SHORTS_TARGET_SECONDS','58'))))
TARGET_PHOTOS=max(6,min(10,int(os.getenv('MULTI_SHORTS_TARGET_PHOTOS','9'))))
MIN_PHOTOS=TARGET_PHOTOS
LOGGER=logging.getLogger('multi_shorts_render_worker')

FOLDER_MAP={
    '아파트':['아파트','apt','apartment'],'아파트분양권':['아파트','apt','apartment'],
    '오피스텔':['오피스텔','officetel'],'빌라':['빌라','villa','주택'],
    '단독주택':['단독다가구','주택','house','villa'],'다가구':['단독다가구','주택','house','villa'],
    '상가':['상가','store','retail'],'사무실':['사무실','office'],'토지':['토지','land'],
    '공장':['공장','factory'],'창고':['창고','warehouse','공장'],'건물':['건물','building'],'기타':['기타','etc'],
}

def clean(value:Any,limit=20000)->str:
    text=html.unescape(re.sub(r'<[^>]+>',' ',str(value or '')))
    return re.sub(r'\s+',' ',text).strip()[:limit]

NAVER_CODE_LABELS={
    # 거래유형
    'A1':'매매','B1':'전세','B2':'월세','B3':'단기임대',
    # 부동산 유형
    'APT':'아파트','ABYG':'아파트분양권','JGC':'재건축','OPST':'오피스텔',
    'OBYG':'오피스텔분양권','VL':'빌라','YR':'연립','DDDGG':'단독·다가구',
    'JWJT':'전원주택','SGJT':'상가주택','HOJT':'한옥','OR':'원룸','GS':'상가',
    'SMS':'사무실','GJCG':'공장·창고','APTHGJ':'지식산업센터','GM':'건물','TJ':'토지',
    # articleTypeCode로만 전달되는 주요 유형
    'A01':'아파트','A05':'연립','B01':'아파트분양권','B02':'오피스텔분양권',
    'D01':'사무실','E03':'토지',
}

def humanize_codes(value:Any,limit=20000)->str:
    """네이버 내부 코드를 화면·자막·TTS에 노출하지 않고 사람이 읽는 명칭으로 변환한다."""
    text=clean(value,limit*2)
    if not text:return ''
    # 긴 코드부터 처리하고 영문·숫자 토큰 경계에서만 바꿔 단지명 일부를 훼손하지 않는다.
    for code,label in sorted(NAVER_CODE_LABELS.items(),key=lambda item:len(item[0]),reverse=True):
        text=re.sub(rf'(?<![A-Za-z0-9]){re.escape(code)}(?![A-Za-z0-9])',label,text,flags=re.I)
    return clean(text,limit)

def speech_text(value:Any,limit=20000)->str:
    """화면의 □와 TTS의 '별' 발음을 만드는 이모지·장식기호를 제거한다."""
    text=unicodedata.normalize('NFC',humanize_codes(value,limit*2)).replace('\ufffd',' ')
    text=re.sub(r'https?://\S+|www\.\S+',' ',text,flags=re.I)
    text=re.sub(r'(?i)(?<![A-Za-z0-9])2\s*in\s*1(?![A-Za-z0-9])','투인원 ',text)
    # 부동산 문장에 필요한 문자만 허용한다. 이모지의 변형 선택자와 사설영역
    # 문자까지 제거되어 화면의 네모 글리프 및 TTS의 '별' 발음이 함께 사라진다.
    text=re.sub(r'[^가-힣ㄱ-ㅎㅏ-ㅣA-Za-z0-9\s.,!?%/㎡²~+\-·:()]+',' ',text)
    text=re.sub(r'([!?.,])\1+',r'\1',text)
    return re.sub(r'\s+',' ',text).strip(' -·|,')[:limit]

def json_obj(value:Any)->dict:
    if isinstance(value,dict): return value
    try:
        data=json.loads(str(value or ''))
        return data if isinstance(data,dict) else {}
    except Exception: return {}

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

def fitted_font(draw,text,path,start_size,min_size,max_width,max_lines=2):
    """긴 중개사명도 지정된 줄 수 안에서 잘리지 않도록 글자 크기를 맞춘다."""
    for size in range(start_size,min_size-1,-2):
        candidate=font(path,size)
        natural_lines=wrap(draw,text,candidate,max_width,100)
        if len(natural_lines)<=max_lines and all(
            draw.textbbox((0,0),line,font=candidate)[2]<=max_width
            for line in natural_lines
        ):return candidate
    return font(path,min_size)

def wrap(draw,text,font_obj,max_width,max_lines):
    words=list(str(text or '')); lines=[]; current=''
    for char in words:
        trial=current+char
        if draw.textbbox((0,0),trial,font=font_obj)[2]<=max_width: current=trial
        else:
            if current: lines.append(current)
            current=char
            if len(lines)>=max_lines: break
    if current and len(lines)<max_lines: lines.append(current)
    if len(lines)==max_lines and ''.join(lines)!=str(text or ''): lines[-1]=lines[-1].rstrip(' .')+'…'
    return lines

def split_script(script:str)->list[str]:
    text=speech_text(script)
    # Python 3.13에서도 동작하도록 look-behind는 항상 한 글자 폭으로 유지한다.
    # 한국어 종결형 "다." 역시 마침표 뒤 공백에서 자연스럽게 분리된다.
    chunks=[speech_text(x,150) for x in re.split(r'(?<=[.!?。])\s+|\n+',text) if speech_text(x)]
    if not chunks:return ['매물의 주요 조건을 안내해 드립니다.']
    result=[]
    for chunk in chunks:
        if result and len(result[-1])+len(chunk)<75: result[-1]+=' '+chunk
        else: result.append(chunk)
    return result[:12]

def claim(conn,job_id=0,rerender=False):
    conn.begin()
    try:
        statuses="'script_ready','render_retry_wait','rendered'" if rerender and job_id else "'script_ready','render_retry_wait'"
        sql=f"SELECT * FROM multi_shorts_jobs WHERE queue_status IN ({statuses})"; params=[]
        if job_id:sql+=' AND id=%s';params.append(job_id)
        sql+=' ORDER BY script_generated_at,id LIMIT 1 FOR UPDATE'
        with conn.cursor() as cur:
            cur.execute(sql,params);job=cur.fetchone()
            if not job:conn.rollback();return None
            cur.execute("UPDATE multi_shorts_jobs SET queue_status='rendering',started_at=COALESCE(started_at,NOW()),error_message=NULL,updated_at=NOW() WHERE id=%s",(job['id'],))
        conn.commit();return job
    except Exception:conn.rollback();raise

def load_local_preview_job(conn,job_id):
    """DB 상태를 바꾸지 않고 업로드 완료 작업도 로컬 비교 렌더에 사용한다."""
    if not job_id:return None
    with conn.cursor() as cur:
        cur.execute("SELECT * FROM multi_shorts_jobs WHERE id=%s LIMIT 1",(job_id,))
        return cur.fetchone()

def source_bundle(conn,job):
    request=json_obj(job.get('source_payload_json'))
    with conn.cursor() as cur:
        cur.execute("""SELECT ma.title,ma.category_code,ma.source_article_no,a.real_estate_type,a.trade_type,
                              a.price_text,a.area_info,a.floor_info,a.direction_code,a.article_feature_desc,
                              r.office_name,r.representative_name,
                              COALESCE(NULLIF(r.mobile_phone,''),NULLIF(r.office_phone,''),r.phone) contact_phone
                         FROM multi_articles ma
                         LEFT JOIN blog_realtor_articles a ON a.realtor_id=ma.realtor_id AND BINARY a.article_no=BINARY ma.source_article_no
                         INNER JOIN blog_realtors r ON r.id=ma.realtor_id
                        WHERE ma.id=%s LIMIT 1""",(job['article_id'],))
        row=cur.fetchone() or {}
        cur.execute("SELECT image_url,image_role,alt_text FROM multi_article_images WHERE article_id=%s ORDER BY sort_order,id",(job['article_id'],))
        image_rows=cur.fetchall() or []
        profile_value=''
        # 운영 DB마다 프로필 필드명이 다를 수 있으므로 존재하는 컬럼만 안전하게 읽는다.
        for column in ('profile_image_url','profile_image','profile_photo','representative_image','naver_profile_image','profile_image_path'):
            cur.execute("SHOW COLUMNS FROM blog_realtors LIKE %s",(column,))
            if cur.fetchone():
                cur.execute(f"SELECT `{column}` profile_value FROM blog_realtors WHERE id=%s LIMIT 1",(job['realtor_id'],))
                profile_value=str((cur.fetchone() or {}).get('profile_value') or '').strip()
                if profile_value:break
    photos=[];floorplans=[]
    for item in image_rows:
        url=str(item.get('image_url') or '').strip();role=(str(item.get('image_role') or '')+' '+str(item.get('alt_text') or '')).lower()
        if not url.startswith(('http://','https://')):continue
        target=floorplans if any(x in role for x in ('floorplan','floor_plan','평면','도면')) else photos
        if url not in photos and url not in floorplans:target.append(url)
    for url in request.get('image_urls') or []:
        if str(url).startswith(('http://','https://')) and url not in photos and url not in floorplans:photos.append(url)
    for url in request.get('floorplan_urls') or []:
        if str(url).startswith(('http://','https://')) and url not in floorplans:
            if url in photos:photos.remove(url)
            floorplans.append(url)
    detected_type=property_key(row.get('real_estate_type') or request.get('property_type') or row.get('category_code') or row.get('title') or job.get('shorts_title'))
    profile_value=(request.get('profile_image_url') or request.get('realtor_profile_image') or request.get('profile_image') or profile_value)
    return {**row,'real_estate_type':detected_type,'trade_type':humanize_codes(row.get('trade_type'),40),'profile_image':profile_value,'photos':photos,'floorplans':floorplans,'request':request}

def property_key(value):
    text=clean(value,100)
    code_map={'apt':'아파트','apartment':'아파트','a01':'아파트','abyg':'아파트','jgc':'아파트',
              'officetel':'오피스텔','villa':'빌라','yr':'빌라','house':'단독주택',
              'store':'상가','retail':'상가','b01':'상가','office':'사무실','sms':'사무실',
              'land':'토지','tj':'토지','e03':'토지','factory':'공장','warehouse':'창고','building':'건물'}
    if text.lower() in code_map:return code_map[text.lower()]
    for key in FOLDER_MAP:
        if key in text:return key
    if any(x in text for x in ('단독','다가구','다세대','연립','주택')):return '단독주택'
    return '기타'

def mybox_roots():
    candidates=[MYBOX_ROOT,ROOT/'mybox'/'v1_images',BASE_DIR/'mybox'/'v1_images',
                BASE_DIR.parent/'mybox'/'v1_images',Path('D:/honghee/blog_api/mybox/v1_images')]
    result=[]
    for path in candidates:
        try:path=path.resolve()
        except Exception:pass
        if path not in result and path.is_dir():result.append(path)
    return result

def fallback_files(property_type,count,seed):
    if count<=0:return []
    key=property_key(property_type);files=[];allowed={'.jpg','.jpeg','.png','.webp','.bmp'}
    roots=mybox_roots()
    for root in roots:
        for name in FOLDER_MAP.get(key,FOLDER_MAP['기타']):
            folder=root/name
            if folder.is_dir():files.extend(p for p in folder.rglob('*') if p.is_file() and p.suffix.lower() in allowed)
    # 매물종류를 판별하지 못하거나 해당 폴더가 비어도 회색 박스를 만들지 않는다.
    # 마지막 수단으로 v1_images 전체에서 실제 이미지를 보충한다.
    if not files:
        for root in roots:files.extend(p for p in root.rglob('*') if p.is_file() and p.suffix.lower() in allowed)
    files=list(dict.fromkeys(files));random.Random(seed).shuffle(files)
    LOGGER.info('MYBOX 보충 type=%s roots=%s candidates=%s selected=%s',key,len(roots),len(files),min(count,len(files)))
    return files[:count]

def download(url,dest):
    req=urllib.request.Request(url,headers={'User-Agent':'Mozilla/5.0','Referer':'https://m.land.naver.com/'})
    with urllib.request.urlopen(req,timeout=30) as response:dest.write_bytes(response.read())
    with Image.open(dest) as image:image.verify()
    return dest

def prepare_visuals(source,job_dir,job_id):
    photos=[];floorplans=[]
    for idx,url in enumerate(source['photos']):
        try:photos.append({'path':download(url,job_dir/f'real_{idx:02d}.jpg'),'kind':'real'})
        except Exception as exc:LOGGER.warning('실사진 다운로드 제외 url=%s error=%s',url,exc)
    photos=dedupe_visuals(photos)
    needed=max(0,MIN_PHOTOS-len(photos))
    for path in fallback_files(source.get('real_estate_type'),needed,job_id):photos.append({'path':path,'kind':'reference'})
    for idx,url in enumerate(source['floorplans']):
        try:floorplans.append({'path':download(url,job_dir/f'floorplan_{idx:02d}.jpg'),'kind':'floorplan'})
        except Exception as exc:LOGGER.warning('평면도 다운로드 제외 url=%s error=%s',url,exc)
    floorplans=dedupe_visuals(floorplans)[:1]
    if not photos:photos=[{'path':None,'kind':'graphic'}]
    return photos,floorplans,needed

def prepare_profile(source,job_dir):
    value=str(source.get('profile_image') or '').strip()
    if not value:return None
    if value.startswith(('http://','https://')):
        try:return download(value,job_dir/'realtor_profile.jpg')
        except Exception as exc:LOGGER.warning('중개사 프로필 다운로드 제외 url=%s error=%s',value,exc);return None
    path=Path(value)
    if not path.is_absolute():path=BASE_DIR/path
    return path if path.is_file() else None

def dedupe_visuals(items):
    """URL만 다른 동일 사진·동일 평면도를 픽셀 지문으로 한 번만 사용한다."""
    result=[];seen=set()
    for item in items:
        path=item.get('path')
        if not path:result.append(item);continue
        try:
            with Image.open(path) as im:
                raw=im.convert('RGB').resize((64,64),Image.Resampling.LANCZOS).tobytes()
            signature=hashlib.sha256(raw).hexdigest()
        except Exception:signature=str(path)
        if signature in seen:continue
        seen.add(signature);result.append(item)
    return result

def contained(path,size):
    with Image.open(path) as im:
        im=im.convert('RGB');im.thumbnail(size,Image.Resampling.LANCZOS);return im.copy()

def background(source_path=None,template=None,scene_index=0):
    mode=getattr(template,'layout_mode','core')
    palettes={
        'core':((10,29,52),(34,63,82),0.24,0.60,1.02),'tour':((24,13,17),(80,42,30),0.42,0.82,0.91),
        'analysis':((23,34,29),(57,75,61),0.18,0.43,1.13),'editorial':((17,8,25),(58,10,38),0.30,0.92,0.88),
        'top_contact':((5,30,30),(12,70,62),0.24,0.78,0.92),'flash':((3,8,28),(9,25,70),0.20,1.08,0.82),
        'petals':((50,28,38),(106,65,76),0.34,0.72,1.10),'sunlight':((46,28,7),(104,66,14),0.28,1.08,1.08),
        'mascot':((13,38,28),(33,82,56),0.25,0.90,1.00),'split':((21,13,38),(52,31,82),0.30,0.88,0.90),
    }
    dark,tint,blend_ratio,saturation,brightness=palettes.get(mode,palettes['core'])
    if source_path:
        try:
            with Image.open(source_path) as im:
                im=im.convert('RGB');ratio=max(WIDTH/im.width,HEIGHT/im.height);im=im.resize((int(im.width*ratio),int(im.height*ratio)),Image.Resampling.LANCZOS);left=(im.width-WIDTH)//2;top=(im.height-HEIGHT)//2
                im=im.crop((left,top,left+WIDTH,top+HEIGHT)).filter(ImageFilter.GaussianBlur(30 if mode=='tour' else 42))
                im=ImageEnhance.Color(im).enhance(saturation);im=ImageEnhance.Brightness(im).enhance(brightness)
                result=Image.blend(im,Image.new('RGB',(WIDTH,HEIGHT),tint),blend_ratio)
                if scene_index%2:result=ImageEnhance.Brightness(result).enhance(0.96 if mode!='analysis' else 1.025)
                return result
        except Exception:pass
    return Image.new('RGB',(WIDTH,HEIGHT),dark)

def make_layers(job_dir,index,title,caption,brand,phone,image_path,kind,total,template,fixed_background_path=None):
    background_source=image_path if template.layout_mode in ('tour','split') and image_path else fixed_background_path
    base=background(background_source,template,index).convert('RGBA')
    # 화면 전체를 회색으로 덮지 않고 글자가 놓이는 위·아래에만 점진 음영을 준다.
    shade=Image.new('RGBA',(WIDTH,HEIGHT),(0,0,0,0));shade_draw=ImageDraw.Draw(shade,'RGBA')
    for y in range(0,300):shade_draw.line((0,y,WIDTH,y),fill=(5,17,29,max(32,132-int(y*0.33))))
    for y in range(1215,HEIGHT):shade_draw.line((0,y,WIDTH,y),fill=(5,17,29,min(150,55+int((y-1215)*0.14))))
    base.alpha_composite(shade)
    draw=ImageDraw.Draw(base,'RGBA');accent=(*template.accent,255);photo_box=template.photo_box;mode=template.layout_mode

    # 1~3은 정보형, 4~10은 기존 유튜브 숏츠의 색상·캐릭터·비대칭 구성을 사용한다.
    if mode=='top_contact':
        draw.rectangle((0,0,WIDTH,225),fill=(4,35,32,238));draw.rectangle((0,220,WIDTH,232),fill=accent)
        title_font=fitted_font(draw,title,FONT_DISPLAY,template.title_size,44,970,2);y=270
        for line in wrap(draw,title,title_font,970,2):draw.text((55,y),line,font=title_font,fill='white');y+=getattr(title_font,'size',58)+5
        draw.rounded_rectangle((45,template.caption_y,1035,template.caption_y+template.caption_height),28,fill=(8,45,42,228));cap_font=font(FONT_BOLD,template.caption_size);y=template.caption_y+42
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,900,template.caption_lines):draw.text((82,y),line,font=cap_font,fill=(235,255,248));y+=template.caption_size+14
    elif mode=='flash':
        draw.polygon([(0,0),(WIDTH,0),(900,285),(0,285)],fill=(3,7,28,242));draw.line((0,285,WIDTH,285),fill=accent,width=10);title_font=fitted_font(draw,title,FONT_DISPLAY,template.title_size,48,960,2);y=62
        for line in wrap(draw,title,title_font,960,2):draw.text((55,y),line,font=title_font,fill='white',stroke_width=3,stroke_fill=(*template.accent,180));y+=getattr(title_font,'size',66)+4
        draw.rectangle((45,template.caption_y,1035,template.caption_y+template.caption_height),fill=(3,8,28,225),outline=accent,width=4);cap_font=font(FONT_BOLD,template.caption_size);y=template.caption_y+38
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,900,3):draw.text((82,y),line,font=cap_font,fill='white');y+=template.caption_size+13
    elif mode=='petals':
        draw.rounded_rectangle((35,35,1045,285),42,fill=(255,239,244,238));title_font=fitted_font(draw,title,FONT_DISPLAY,template.title_size,46,920,2);y=72
        for line in wrap(draw,title,title_font,920,2):draw.text((80,y),line,font=title_font,fill=(73,34,48));y+=getattr(title_font,'size',60)+5
        draw.rounded_rectangle((70,template.caption_y,1010,template.caption_y+template.caption_height),38,fill=(255,244,247,236));cap_font=font(FONT_BOLD,template.caption_size);y=template.caption_y+46
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,850,3):draw.text((110,y),line,font=cap_font,fill=(73,34,48));y+=template.caption_size+14
    elif mode=='sunlight':
        draw.rectangle((0,0,WIDTH,300),fill=(43,24,3,215));draw.rectangle((0,292,WIDTH,306),fill=accent);title_font=fitted_font(draw,title,FONT_DISPLAY,template.title_size,48,970,2);y=62
        for line in wrap(draw,title,title_font,970,2):draw.text((55,y),line,font=title_font,fill=(255,248,220),stroke_width=2,stroke_fill=(65,33,2));y+=getattr(title_font,'size',64)+5
        draw.rectangle((0,template.caption_y,WIDTH,template.caption_y+template.caption_height),fill=(55,31,4,220));cap_font=font(FONT_BOLD,template.caption_size);y=template.caption_y+40
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,930,3):draw.text((60,y),line,font=cap_font,fill=(255,243,203));y+=template.caption_size+14
    elif mode=='mascot':
        draw.rounded_rectangle((38,38,1042,290),36,fill=(225,250,229,238));title_font=fitted_font(draw,title,FONT_DISPLAY,template.title_size,46,910,2);y=75
        for line in wrap(draw,title,title_font,910,2):draw.text((78,y),line,font=title_font,fill=(24,70,43));y+=getattr(title_font,'size',60)+5
        draw.rounded_rectangle((55,template.caption_y,1025,template.caption_y+template.caption_height),34,fill=(13,49,35,232));cap_font=font(FONT_BOLD,template.caption_size);y=template.caption_y+42
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,820,3):draw.text((82,y),line,font=cap_font,fill='white');y+=template.caption_size+14
    elif mode=='split':
        draw.rectangle((0,0,WIDTH,285),fill=(19,10,36,240));draw.rectangle((690,285,WIDTH,1470),fill=(45,25,72,238));title_font=fitted_font(draw,title,FONT_DISPLAY,template.title_size,45,970,2);y=58
        for line in wrap(draw,title,title_font,970,2):draw.text((55,y),line,font=title_font,fill='white');y+=getattr(title_font,'size',62)+4
        cap_font=font(FONT_BOLD,template.caption_size);y=template.caption_y+55
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,300,template.caption_lines):draw.text((735,y),line,font=cap_font,fill=(242,230,255));y+=template.caption_size+18
    elif mode=='editorial':
        draw.rectangle((0,0,WIDTH,360),fill=(18,8,27,242));draw.rectangle((0,330,WIDTH,360),fill=accent);title_font=fitted_font(draw,title,FONT_DISPLAY,template.title_size,56,970,template.title_lines);y=62
        for line in wrap(draw,title,title_font,970,template.title_lines):draw.text((55,y),line,font=title_font,fill='white',stroke_width=2,stroke_fill=(25,4,20,210));y+=getattr(title_font,'size',template.title_size)+5
        draw.polygon([(0,template.caption_y+54),(110,template.caption_y),(WIDTH,template.caption_y),(WIDTH,template.caption_y+template.caption_height),(0,template.caption_y+template.caption_height)],fill=(255,244,230,245));cap_font=font(FONT_BOLD,template.caption_size);y=template.caption_y+75
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,900,template.caption_lines):draw.text((72,y),line,font=cap_font,fill=(34,12,29));y+=template.caption_size+15
    elif mode=='tour':
        draw.rectangle((45,68,57,205),fill=accent)
        title_font=font(FONT_BOLD,template.title_size);y=73
        for line in wrap(draw,title,title_font,940,template.title_lines):
            draw.text((82,y+2),line,font=title_font,fill=(0,0,0,155));draw.text((80,y),line,font=title_font,fill='white');y+=template.title_size+9
        # 풀블리드 사진형: 테두리와 카드 없이 화면 폭 전체를 사용한다.
        draw.line((0,photo_box[1]-2,WIDTH,photo_box[1]-2),fill=(*template.accent,175),width=3)
        draw.line((0,photo_box[3]+2,WIDTH,photo_box[3]+2),fill=(*template.accent,175),width=3)
        cap_top=template.caption_y+18
        cap_font=font(FONT_BOLD,template.caption_size);y=cap_top
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,930,template.caption_lines):
            draw.text((57,y),line,font=cap_font,fill='white',stroke_width=2,stroke_fill=(4,18,30,210));y+=template.caption_size+14
    elif mode=='analysis':
        draw.line((70,105,1010,105),fill=(*template.accent,165),width=2)
        title_font=font(FONT_BOLD,template.title_size);y=132
        for line in wrap(draw,title,title_font,940,template.title_lines):
            draw.text((72,y+2),line,font=title_font,fill=(0,0,0,150));draw.text((70,y),line,font=title_font,fill='white');y+=template.title_size+11
        draw.rectangle((photo_box[0]-8,photo_box[1]-8,photo_box[2]+8,photo_box[3]+8),outline=(*template.accent,205),width=4)
        draw.rectangle((photo_box[0]-18,photo_box[1]-18,photo_box[2]+18,photo_box[3]+18),outline=(255,255,255,65),width=2)
        # 분석형은 밝은 종이 느낌의 정보 영역으로 다른 템플릿과 확실히 구분한다.
        draw.rounded_rectangle((55,template.caption_y,1025,template.caption_y+template.caption_height),18,fill=(244,247,239,232))
        draw.text((82,template.caption_y+25),'이 매물의 핵심 구조',font=font(FONT_BOLD,24),fill=(46,105,65))
        cap_font=font(FONT_BOLD,template.caption_size);y=template.caption_y+72
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,860,template.caption_lines):
            draw.text((82,y),line,font=cap_font,fill=(22,42,35));y+=template.caption_size+15
    else:
        draw.rectangle((68,72,185,80),fill=accent)
        title_font=font(FONT_BOLD,template.title_size);y=105
        for line in wrap(draw,title,title_font,940,template.title_lines):
            draw.text((70,y+2),line,font=title_font,fill=(0,0,0,145));draw.text((68,y),line,font=title_font,fill='white');y+=template.title_size+11
        draw.rounded_rectangle((photo_box[0]-3,photo_box[1]-3,photo_box[2]+3,photo_box[3]+3),22,outline=(255,255,255,145),width=2)
        band=Image.new('RGBA',(WIDTH,template.caption_height),(5,23,39,178));base.alpha_composite(band,(0,template.caption_y));draw=ImageDraw.Draw(base,'RGBA')
        cap_top=template.caption_y+35;cap_bottom=template.caption_y+template.caption_height-35
        draw.rounded_rectangle((68,cap_top,78,cap_bottom),5,fill=accent)
        cap_font=font(FONT_BOLD,template.caption_size);y=cap_top+15
        for line in wrap(draw,speech_text(caption,template.caption_limit),cap_font,850,template.caption_lines):draw.text((105,y),line,font=cap_font,fill='white');y+=template.caption_size+16
    # 템플릿 계열별 하단 상담 영역도 색상과 크기를 다르게 유지한다.
    phone_text=phone or '상담 전 현재 매물 여부를 확인해 주세요'
    bold_modes={'editorial','top_contact','flash','petals','sunlight','mascot','split'}
    contact_top=max(1630 if mode in bold_modes else (1535 if mode=='analysis' else 1560),template.caption_y+template.caption_height+25)
    if mode=='top_contact':
        brand_font=fitted_font(draw,brand,FONT_DISPLAY,52,38,960,1);draw.text((52,35),brand,font=brand_font,fill='white',stroke_width=2,stroke_fill=(3,24,22,230));draw.text((52,112),phone_text[:32],font=font(FONT_BOLD,64 if phone else 34),fill=(255,224,104),stroke_width=2,stroke_fill=(3,24,22,230))
    elif mode in bold_modes:
        contact_top=1505 if mode=='split' else max(1630,template.caption_y+template.caption_height+20);draw.rectangle((0,contact_top-5,WIDTH,1850),fill=(17,7,25,225));draw.rectangle((55,contact_top+4,165,contact_top+14),fill=accent)
        brand_font=fitted_font(draw,brand,FONT_DISPLAY,58,40,950,2);by=contact_top+35
        for line in wrap(draw,brand,brand_font,950,2):draw.text((55,by),line,font=brand_font,fill='white',stroke_width=2,stroke_fill=(22,3,17,230));by+=getattr(brand_font,'size',58)+5
        phone_y=min(1780,max(contact_top+130,by+14));draw.text((55,phone_y),phone_text[:32],font=font(FONT_BOLD,74 if phone else 38),fill=(255,211,82),stroke_width=3,stroke_fill=(22,3,17,235))
    else:
        draw.line((68,contact_top+10,245,contact_top+10),fill=(*template.accent,245),width=6);brand_font=fitted_font(draw,brand,FONT_BOLD,40,30,820,2);by=contact_top+34
        for line in wrap(draw,brand,brand_font,820,2):draw.text((70,by),line,font=brand_font,fill=(250,250,246),stroke_width=2,stroke_fill=(4,18,30,210));by+=48
        phone_y=max(contact_top+175,by+12);draw.text((70,phone_y),phone_text[:32],font=font(FONT_BOLD,54 if phone else 30),fill=(255,211,82),stroke_width=2,stroke_fill=(4,18,30,220))
    draw.text((930,1860),f'{index+1}/{total}',font=font(FONT,27),fill=(225,235,245))
    base_path=job_dir/f'base_{index:02d}.png';base.convert('RGB').save(base_path)
    return base_path

def make_reference_badge(out_path):
    """실매물 사진이 아닌 보충 이미지에만 쓰는 작고 옅은 고지 배지."""
    badge=Image.new('RGBA',(224,54),(0,0,0,0));draw=ImageDraw.Draw(badge,'RGBA')
    draw.rounded_rectangle((0,0,223,53),15,fill=(4,13,22,108),outline=(255,255,255,30),width=1)
    label='매물 유형 참고';label_font=font(FONT_BOLD,24)
    box=draw.textbbox((0,0),label,font=label_font);tw=box[2]-box[0];th=box[3]-box[1]
    draw.text(((224-tw)//2,(54-th)//2-2),label,font=label_font,fill=(255,255,255,190))
    badge.save(out_path)
    return out_path

def decor_asset(job_dir,mode,index):
    """꽃잎·빛살·캐릭터형 본문에 사용하는 투명 장식 레이어."""
    if mode=='petals':
        path=job_dir/f'decor_petals_{index:02d}.png';im=Image.new('RGBA',(WIDTH,HEIGHT),(0,0,0,0));d=ImageDraw.Draw(im,'RGBA');rng=random.Random(7001+index)
        for _ in range(28):
            x=rng.randint(0,WIDTH);y=rng.randint(0,HEIGHT);w=rng.randint(12,34);h=rng.randint(20,48);color=rng.choice(((255,180,204,155),(255,224,232,185),(246,126,168,135),(255,255,255,145)));d.ellipse((x-w,y-h,x+w,y+h),fill=color)
        im.save(path);return path
    if mode=='sunlight':
        path=job_dir/f'decor_sunlight_{index:02d}.png';im=Image.new('RGBA',(430,HEIGHT),(0,0,0,0));d=ImageDraw.Draw(im,'RGBA')
        for x in range(430):
            alpha=int(92*(1-abs(x-215)/215));d.line((x,0,x,HEIGHT),fill=(255,238,160,max(0,alpha)))
        d.polygon([(40,0),(210,0),(390,HEIGHT),(225,HEIGHT)],fill=(255,247,195,46));im.save(path);return path
    if mode=='mascot':
        path=job_dir/f'decor_mascot_{index:02d}.png';im=Image.new('RGBA',(230,245),(0,0,0,0));d=ImageDraw.Draw(im,'RGBA')
        d.polygon([(25,88),(115,18),(205,88)],fill=(255,127,94,255),outline=(255,255,255,235));d.rounded_rectangle((43,82,187,220),24,fill=(255,244,198,255),outline=(255,255,255,240),width=4)
        d.ellipse((76,116,92,134),fill=(35,55,45,255));d.ellipse((138,116,154,134),fill=(35,55,45,255));d.arc((91,123,141,166),0,180,fill=(35,55,45,255),width=5)
        if index%3==0:d.line((44,145,5,112),fill=(255,244,198,255),width=13);d.ellipse((0,99,18,117),fill=(255,244,198,255))
        elif index%3==1:d.line((186,145,224,108),fill=(255,244,198,255),width=13);d.ellipse((214,96,230,114),fill=(255,244,198,255))
        else:d.ellipse((103,174,126,197),fill=(255,127,94,255))
        im.save(path);return path
    return None

def render_segment(base_path,image_path,kind,out_path,duration,index,transition='dissolve',template=None):
    photo_box=getattr(template,'photo_box',PHOTO_BOX);mode=getattr(template,'layout_mode','core')
    duration=max(2.2,float(duration));frames=max(1,int(duration*30));x1,y1,x2,y2=photo_box;bw=x2-x1;bh=y2-y1
    if image_path:
        fade_duration={'dissolve':0.58,'soft':0.82,'quick':0.34,'zoom_in':0.48,'zoom_out':0.52,'pan_left':0.50,'pan_right':0.50,'pan_up':0.50,'pan_down':0.50,'punch_in':0.22,'whip_left':0.24,'whip_right':0.24,'wipe_up':0.30,'wipe_down':0.30}.get(transition,0.55)
        fade_out=max(0.1,duration-fade_duration)
        if kind!='floorplan':
            # 4K 중간 캔버스와 짝수 정수 좌표를 사용해 zoompan의 서브픽셀 떨림을 막는다.
            zoom_frames=max(1,min(frames,90))
            center_x='trunc((iw-iw/zoom)/4)*2';center_y='trunc((ih-ih/zoom)/4)*2'
            if transition=='punch_in':motion=f"1.18-0.16*min(on,{max(1,min(frames,18))})/{max(1,min(frames,18))}";x_expr=center_x;y_expr=center_y
            elif transition=='zoom_in':motion=f"1+0.10*min(on,{zoom_frames})/{zoom_frames}";x_expr=center_x;y_expr=center_y
            elif transition=='zoom_out':motion=f"1.10-0.10*min(on,{zoom_frames})/{zoom_frames}";x_expr=center_x;y_expr=center_y
            elif transition=='pan_left':motion='1.08';x_expr=f"trunc(((iw-iw/zoom)*(1-min(on,{frames})/{frames}))/2)*2";y_expr=center_y
            elif transition=='pan_right':motion='1.08';x_expr=f"trunc(((iw-iw/zoom)*min(on,{frames})/{frames})/2)*2";y_expr=center_y
            elif transition=='pan_up':motion='1.08';x_expr=center_x;y_expr=f"trunc(((ih-ih/zoom)*(1-min(on,{frames})/{frames}))/2)*2"
            elif transition=='pan_down':motion='1.08';x_expr=center_x;y_expr=f"trunc(((ih-ih/zoom)*min(on,{frames})/{frames})/2)*2"
            elif transition=='quick':motion=f"1+0.06*min(on,{zoom_frames})/{zoom_frames}";x_expr=center_x;y_expr=center_y
            elif transition=='soft':motion=f"1+0.035*min(on,{frames})/{frames}";x_expr=center_x;y_expr=center_y
            else:motion='1.025';x_expr=center_x;y_expr=center_y
            photo_filter=f"scale=3840:3840:force_original_aspect_ratio=increase,crop=3840:3840,zoompan=z='{motion}':x='{x_expr}':y='{y_expr}':d={frames}:s={bw}x{bh}:fps=30"
            if mode=='flash':photo_filter+=",eq=brightness=0.13:enable='lt(mod(t\\,1.6)\\,0.11)'"
        else:
            photo_filter=f"scale={bw-30}:{bh-30}:force_original_aspect_ratio=decrease,pad={bw}:{bh}:(ow-iw)/2:(oh-ih)/2:color=white,fps=30"
        # 슬라이드는 사진 크기의 투명 무대 안에서만 이동시켜 제목·자막 영역을 침범하지 않는다.
        slide_positions={
            'slide_left':(f"if(lt(t,{fade_duration}),{bw}-{bw}*t/{fade_duration},if(gt(t,{fade_out:.3f}),-{bw}*(t-{fade_out:.3f})/{fade_duration},0))",'0'),
            'slide_right':(f"if(lt(t,{fade_duration}),-{bw}+{bw}*t/{fade_duration},if(gt(t,{fade_out:.3f}),{bw}*(t-{fade_out:.3f})/{fade_duration},0))",'0'),
            'slide_up':('0',f"if(lt(t,{fade_duration}),{bh}-{bh}*t/{fade_duration},if(gt(t,{fade_out:.3f}),-{bh}*(t-{fade_out:.3f})/{fade_duration},0))"),
            'slide_down':('0',f"if(lt(t,{fade_duration}),-{bh}+{bh}*t/{fade_duration},if(gt(t,{fade_out:.3f}),{bh}*(t-{fade_out:.3f})/{fade_duration},0))"),
            'whip_left':(f"if(lt(t,{fade_duration}),{bw}-{bw}*t/{fade_duration},0)",'0'),
            'whip_right':(f"if(lt(t,{fade_duration}),-{bw}+{bw}*t/{fade_duration},0)",'0'),
            'wipe_up':('0',f"if(lt(t,{fade_duration}),{bh}-{bh}*t/{fade_duration},0)"),
            'wipe_down':('0',f"if(lt(t,{fade_duration}),-{bh}+{bh}*t/{fade_duration},0)"),
        }
        if kind!='floorplan' and transition in slide_positions:
            slide_x,slide_y=slide_positions[transition]
            fc=(f"[0:v]{photo_filter},format=rgba[raw_photo];"
                f"color=c=black@0.0:s={bw}x{bh}:d={duration:.3f}:r=30,format=rgba[stage];"
                f"[stage][raw_photo]overlay=x='{slide_x}':y='{slide_y}':shortest=1[photo];"
                f"[1:v][photo]overlay={x1}:{y1}:shortest=1[composite]")
        else:
            # 알파 페이드 역시 중앙 사진에만 적용한다. 배경·제목·상담 정보는 고정된다.
            fc=f"[0:v]{photo_filter},format=rgba,fade=t=in:st=0:d={fade_duration}:alpha=1,fade=t=out:st={fade_out:.2f}:d={fade_duration}:alpha=1[photo];[1:v][photo]overlay={x1}:{y1}:shortest=1[composite]"
        cmd=[FFMPEG,'-y','-loop','1','-t',f'{duration:.2f}','-i',str(image_path),'-loop','1','-t',f'{duration:.2f}','-i',str(base_path)]
        current='composite';input_index=2
        decor=decor_asset(out_path.parent,mode,index)
        if decor:
            cmd+=['-loop','1','-t',f'{duration:.2f}','-i',str(decor)]
            if mode=='petals':overlay="x='18*sin(t*1.7)':y='mod(t*105\\,1920)-300'"
            elif mode=='sunlight':overlay=f"x='-430+1510*t/{duration:.3f}':y=0"
            else:overlay="x='810+12*sin(t*4)':y='1450+16*sin(t*6)'"
            fc+=f";[{input_index}:v]format=rgba[decor];[{current}][decor]overlay={overlay}:shortest=1[decorated]";current='decorated';input_index+=1
        if kind=='reference':
            badge_path=make_reference_badge(out_path.parent/f'reference_badge_{index:02d}.png');badge_x=x2-224-18;badge_y=y2-54-18
            cmd+=['-loop','1','-t',f'{duration:.2f}','-i',str(badge_path)]
            fc+=f";[{current}][{input_index}:v]overlay={badge_x}:{badge_y}:shortest=1,format=yuv420p[v]"
        else:fc+=f';[{current}]format=yuv420p[v]'
        cmd+=['-filter_complex',fc,'-map','[v]','-r','30','-an','-c:v','libx264','-pix_fmt','yuv420p',str(out_path)]
    else:
        cmd=[FFMPEG,'-y','-loop','1','-t',f'{duration:.2f}','-i',str(base_path),'-vf','format=yuv420p','-r','30','-an','-c:v','libx264','-pix_fmt','yuv420p',str(out_path)]
    subprocess.run(cmd,check=True,capture_output=True,text=True,timeout=240)

async def tts_save(text,path,voice):
    import edge_tts
    await edge_tts.Communicate(speech_text(text),voice,rate='+8%').save(str(path))

async def available_korean_voices():
    try:
        import edge_tts
        voices=await edge_tts.list_voices()
        return [v for v in voices if str(v.get('Locale') or '').lower()=='ko-kr' and v.get('ShortName')]
    except Exception:return []

def choose_voice(seed,intro_template_id=0):
    if VOICE:return VOICE,'configured'
    voices=asyncio.run(available_korean_voices())
    preferred='female' if int(intro_template_id or 0)%2 else 'male'
    preferred_names=[str(v['ShortName']) for v in voices if str(v.get('Gender') or '').lower()==preferred]
    names=preferred_names or [str(v['ShortName']) for v in voices]
    if not names:names=[VOICE_FALLBACKS[0]] if preferred=='female' else VOICE_FALLBACKS[1:]
    selected=random.Random(seed).choice(names)
    gender=next((str(v.get('Gender') or '').lower() for v in voices if v.get('ShortName')==selected),'unknown')
    return selected,gender

def choose_bgm(seed):
    allowed={'.mp3','.m4a','.aac','.wav','.flac','.ogg'}
    roots=[BGM_ROOT,ROOT/'bgm',BASE_DIR/'bgm']
    files=[]
    for root in roots:
        if root.is_dir():files.extend(p for p in root.rglob('*') if p.is_file() and p.suffix.lower() in allowed)
    files=list(dict.fromkeys(files))
    return random.Random(seed).choice(files) if files else None

def make_builtin_bgm(path,duration,seed):
    """bgm 폴더가 비어 있을 때 무음 대신 쓰는 저작권 없는 간단한 코드 생성 BGM."""
    rate=22050;duration=max(8.0,float(duration));rng=random.Random(seed)
    progressions=[(220.0,277.18,329.63,392.0),(196.0,246.94,293.66,369.99),(261.63,329.63,392.0,493.88)]
    roots=rng.choice(progressions);frames=int(rate*duration)
    path.parent.mkdir(parents=True,exist_ok=True)
    with wave.open(str(path),'wb') as wav:
        wav.setnchannels(1);wav.setsampwidth(2);wav.setframerate(rate)
        data=bytearray()
        for i in range(frames):
            t=i/rate;step=int(t/2.0)%len(roots);root=roots[step]
            phase=t%2.0;env=min(1.0,phase/0.18,max(0.0,(2.0-phase)/0.35))
            pad=(math.sin(2*math.pi*root*t)+0.55*math.sin(2*math.pi*root*1.25*t)+0.35*math.sin(2*math.pi*root*1.5*t))
            pulse=math.sin(2*math.pi*(root*2)*t)*(0.28 if int(t*2)%4==0 else 0.12)
            sample=max(-1.0,min(1.0,(pad*0.12+pulse*0.06)*env))
            data.extend(struct.pack('<h',int(sample*32767)))
        wav.writeframes(data)
    return path

def media_duration(path):
    result=subprocess.run([FFPROBE,'-v','error','-show_entries','format=duration','-of','default=nw=1:nk=1',str(path)],check=True,capture_output=True,text=True,timeout=30)
    return float(result.stdout.strip() or 0)

def scene_captions(chunks,count,source,limit=110):
    facts=[]
    if source.get('price_text') or source.get('trade_type'):facts.append(f"{clean(source.get('trade_type'))} 조건은 {clean(source.get('price_text'))}입니다.")
    if source.get('area_info'):facts.append(f"면적은 {clean(source.get('area_info'))}입니다.")
    if source.get('floor_info') or source.get('direction_code'):facts.append(f"{clean(source.get('floor_info'))}이며 방향은 {clean(source.get('direction_code'))}입니다.")
    desc=clean(source.get('article_feature_desc'),180)
    if desc:facts.append(desc)
    facts.extend([
        '사진으로 매물의 공간과 주변 모습을 차례로 확인해 보세요.',
        '실내 구조와 공간 활용도를 사진을 통해 살펴볼 수 있습니다.',
        '채광과 동선, 생활 편의성을 함께 비교해 보시기 바랍니다.',
        '표시된 가격과 세부 조건은 상담 시 다시 확인해 주세요.',
        '방문 전 현재 거래 가능 여부와 일정을 확인해 주세요.',
    ])
    office=clean(source.get('office_name') or source.get('representative_name') or '등록 중개사무소',60)
    closing=speech_text(f'자세한 내용과 현재 거래 가능 여부는 {office}로 문의해 주세요.',limit)
    result=[]
    for value in list(chunks)+facts:
        value=speech_text(value,limit)
        if value and value not in result:result.append(value)
        # 마지막 한 장면은 상담 안내와 자연스러운 마무리를 위해 항상 남겨 둔다.
        if len(result)>=max(0,count-1):break
    if count>0:
        result=result[:max(0,count-1)]
        result.append(closing)
    return result

def render(job,source,photos,floorplans,job_dir):
    script=humanize_codes(job.get('shorts_script'))
    local_preview=bool(source.get('request',{}).get('_local_preview'))
    if not script and local_preview:
        # 삭제·취소된 과거 작업도 YouTube/DB 상태 변경 없이 템플릿 비교에 사용할 수 있다.
        preview_parts=[
            clean(job.get('shorts_title') or source.get('title') or '추천 매물 안내',120),
            clean(source.get('trade_type'),40),
            clean(source.get('price_text'),60),
            clean(source.get('area_info'),80),
            clean(source.get('article_feature_desc'),180),
            '상세 조건은 등록 중개사무소로 문의해 주세요.',
        ]
        script='. '.join(x for x in preview_parts if x)
    if not script:raise RuntimeError('생성된 숏츠 대본이 없습니다.')
    chunks=split_script(script)
    intro_template,intro_template_source=select_intro_template(
        source.get('request'),int(job['id'])+int(job.get('render_version') or 1)
    )
    real_photo_count=sum(x.get('kind')=='real' for x in photos)
    # 레이아웃 선택에서는 실매물 사진뿐 아니라 MYBOX 보충 사진도 정상 사진으로 인정한다.
    # 그렇지 않으면 사진이 부족한 작업이 기본형·평면도형 두 종류로만 제한된다.
    usable_photo_count=sum(bool(x.get('path')) for x in photos)
    body_seed=int(job['id'])+intro_template.template_id*7919+int(job.get('render_version') or 1)*101
    template,template_source=select_template(source.get('request'),usable_photo_count,len(floorplans),body_seed)
    # 일반 사진을 먼저 보여주고 평면도는 최대 1회만 뒤쪽에 배치한다.
    # 같은 평면도를 자막 개수만큼 반복하지 않는다.
    visuals=list(photos[:TARGET_PHOTOS])
    if floorplans:
        insert_at=min(6,len(visuals));visuals.insert(insert_at,floorplans[0])
    if not visuals:visuals=[{'path':None,'kind':'graphic'}]
    scene_count=min(10,len(visuals));captions=scene_captions(chunks,scene_count,source,min(72,template.caption_limit));scene_count=min(scene_count,len(captions))
    visuals=visuals[:scene_count];captions=captions[:scene_count]
    narration=' '.join(captions);audio=job_dir/'narration.mp3';tts_ok=False
    voice_seed=int(job['id'])+int(job.get('render_version') or 1)*101+intro_template.template_id*1009
    selected_voice,voice_gender=choose_voice(voice_seed,intro_template.template_id)
    try:asyncio.run(tts_save(narration,audio,selected_voice));tts_ok=audio.exists() and audio.stat().st_size>1000
    except Exception as exc:LOGGER.warning('TTS 생성 실패, 무음 영상으로 계속합니다: %s',exc)
    # 60초 경계의 인코딩 오차를 피하면서 화면을 충분히 활용한다. 기본 목표는
    # 58초이며, 내레이션이 더 길면 문장을 자르지 않고 필요한 만큼 확장한다.
    narration_duration=media_duration(audio) if tts_ok else 0.0
    ending_hold=2.2
    target_body=max(12.0,TARGET_SHORTS_SECONDS-intro_template.duration)
    required_body=(narration_duration+ending_hold) if tts_ok else target_body
    duration=max(3.4,target_body/scene_count,required_body/scene_count)
    brand=clean(source.get('office_name') or source.get('representative_name') or '공인중개사 매물 안내',100)
    intro_image=next((x.get('path') for x in photos if x.get('path')),None)
    profile_image=prepare_profile(source,job_dir)
    intro_card=render_intro_card(
        job_dir/f'intro_{intro_template.template_id:02d}.jpg',intro_template,intro_image,
        clean(job.get('shorts_title') or source.get('title'),120),brand,
        clean(source.get('representative_name'),60),clean(source.get('contact_phone'),60),
        FONT,FONT_BOLD,profile_image,clean(source.get('real_estate_type') or '부동산',40),
    )
    # 업로드 워커가 파일명을 추측하지 않도록 운영용 대표 썸네일을 고정 이름으로 둔다.
    # YouTube 등록이 끝나면 job_dir 전체와 함께 삭제된다.
    youtube_thumbnail=job_dir/'youtube_thumbnail.jpg'
    shutil.copy2(intro_card,youtube_thumbnail)
    intro_segment=render_intro_segment(
        FFMPEG,intro_card,job_dir/f'intro_{intro_template.template_id:02d}.mp4',intro_template
    )
    segments=[intro_segment]
    fixed_background=next((x['path'] for x in photos if x.get('path')),None)
    transition_pool=list(template.transition_pool);rng=random.Random(int(job['id'])+int(job.get('render_version') or 1))
    transitions=[]
    for idx,(caption,visual) in enumerate(zip(captions,visuals)):
        base=make_layers(job_dir,idx,clean(job.get('shorts_title') or source.get('title'),120),caption,brand,clean(source.get('contact_phone'),60),visual['path'],visual['kind'],scene_count,template,fixed_background)
        transition=rng.choice([x for x in transition_pool if not transitions or x!=transitions[-1]]);transitions.append(transition)
        segment=job_dir/f'segment_{idx:02d}.mp4';render_segment(base,visual['path'],visual['kind'],segment,duration,idx,transition,template);segments.append(segment)
    concat=job_dir/'concat.txt';concat.write_text(''.join(f"file '{p.as_posix()}'\n" for p in segments),encoding='utf-8')
    silent=job_dir/'silent.mp4';subprocess.run([FFMPEG,'-y','-f','concat','-safe','0','-i',str(concat),'-c','copy',str(silent)],check=True,capture_output=True,text=True,timeout=300)
    if local_preview:
        preview_root=OUTPUT_ROOT/'intro_previews';preview_root.mkdir(parents=True,exist_ok=True)
        output=preview_root/f"realtor_{job['realtor_id']}_job_{job['id']}_intro_{intro_template.template_id:02d}.mp4"
        # 웹진과 YouTube 대표화면 검수용. 운영 업로드 단계에서는 이 이미지를
        # 사용자 지정 썸네일로 전송한 뒤 임시 파일을 삭제할 수 있다.
        shutil.copy2(youtube_thumbnail,preview_root/f"realtor_{job['realtor_id']}_job_{job['id']}_intro_{intro_template.template_id:02d}_thumbnail.jpg")
    else:
        OUTPUT_ROOT.mkdir(parents=True,exist_ok=True);output=OUTPUT_ROOT/f"multi_shorts_{job['id']}.mp4"
    work_output=job_dir/'final_new.mp4'
    # concat 컨테이너의 실제 길이를 기준으로 사용하여 반올림 오차로 마지막 프레임과
    # 마무리 멘트가 잘리지 않게 한다.
    silent_duration=media_duration(silent)
    total_duration=max(silent_duration,intro_template.duration+narration_duration+ending_hold if tts_ok else silent_duration)
    seed=int(job['id'])+int(job.get('render_version') or 1)*101+intro_template.template_id*1009
    bgm=choose_bgm(seed+313)
    if bgm is None:bgm=make_builtin_bgm(job_dir/'builtin_bgm.wav',total_duration,seed+313)
    delay_ms=int(intro_template.duration*1000);fade_out=max(0.0,total_duration-1.1)
    cmd=[FFMPEG,'-y','-i',str(silent)]
    if tts_ok:
        # 음성이 나올 때 BGM을 자동으로 낮추고 인트로부터 음악은 바로 시작한다.
        cmd+=['-i',str(audio),'-stream_loop','-1','-i',str(bgm),'-filter_complex',
              f'[1:a]adelay={delay_ms}:all=1,volume=1.0[voice];'
              f'[2:a]loudnorm=I=-18:LRA=7:TP=-2,volume={BGM_VOLUME:.3f},afade=t=in:st=0:d=0.45,afade=t=out:st={fade_out:.3f}:d=1.1[music];'
              f'[music][voice]sidechaincompress=threshold=0.025:ratio=7:attack=20:release=320[ducked];'
              f'[voice][ducked]amix=inputs=2:duration=longest:dropout_transition=1[a]',
              '-map','0:v','-map','[a]','-c:v','copy','-c:a','aac','-t',f'{total_duration:.3f}']
    else:
        cmd+=['-stream_loop','-1','-i',str(bgm),'-filter_complex',
              f'[1:a]loudnorm=I=-18:LRA=7:TP=-2,volume={BGM_VOLUME:.3f},afade=t=in:st=0:d=0.45,afade=t=out:st={fade_out:.3f}:d=1.1[a]',
              '-map','0:v','-map','[a]','-c:v','copy','-c:a','aac','-t',f'{total_duration:.3f}']
    cmd+=[str(work_output)]
    try:
        subprocess.run(cmd,check=True,capture_output=True,text=True,timeout=300)
    except subprocess.CalledProcessError as exc:
        # Cafe24/Windows에 설치된 일부 FFmpeg 빌드는 loudnorm, sidechaincompress,
        # adelay의 all=1 조합을 지원하지 않는다. 영상 렌더 전체를 실패시키지 않고
        # 기본 필터만 사용하는 호환 믹스로 한 번 자동 재시도한다.
        if not (tts_ok and bgm):
            raise
        LOGGER.warning('고급 BGM 믹스 미지원 → 호환 믹스로 재시도: %s',clean(exc.stderr,800))
        compatible_filter=(
            f'[1:a]adelay={delay_ms}|{delay_ms},volume=1.0[voice];'
            f'[2:a]volume=0.180,afade=t=in:st=0:d=0.45,'
            f'afade=t=out:st={fade_out:.3f}:d=1.1[music];'
            f'[voice][music]amix=inputs=2:duration=longest:dropout_transition=1[a]'
        )
        compatible_cmd=[FFMPEG,'-y','-i',str(silent),'-i',str(audio),
                        '-stream_loop','-1','-i',str(bgm),'-filter_complex',compatible_filter,
                        '-map','0:v','-map','[a]','-c:v','copy','-c:a','aac',
                        '-t',f'{total_duration:.3f}',str(work_output)]
        subprocess.run(compatible_cmd,check=True,capture_output=True,text=True,timeout=300)
    # 기존 공개·검수 영상을 렌더 중간 실패로 훼손하지 않고 완성본만 원자적으로 교체한다.
    os.replace(work_output,output)
    # --template-id 비교 렌더는 다음 템플릿 실행에 덮이지 않도록 별도 파일도 보존한다.
    if template_source=='manual' and not local_preview:
        comparison_output=OUTPUT_ROOT/f"multi_shorts_{job['id']}_template_{template.template_id}.mp4"
        shutil.copy2(output,comparison_output)
    if intro_template_source=='manual' and not local_preview:
        comparison_output=OUTPUT_ROOT/f"multi_shorts_{job['id']}_intro_{intro_template.template_id:02d}.mp4"
        shutil.copy2(output,comparison_output)
    return (output,tts_ok,scene_count,transitions,selected_voice,voice_gender,bgm,template,template_source,
            intro_template,intro_template_source)

def finish(conn,job,output,plan):
    with conn.cursor() as cur:
        cur.execute("UPDATE multi_shorts_jobs SET queue_status='rendered',temp_file_path=%s,rendered_at=NOW(),error_message=NULL,updated_at=NOW() WHERE id=%s",(output.as_posix(),job['id']))
        cur.execute("INSERT INTO multi_shorts_job_events (shorts_job_id,event_type,from_status,to_status,message,event_data,created_at) VALUES (%s,'render_completed','rendering','rendered','웹진 전용 영상 렌더 완료',%s,NOW())",(job['id'],json.dumps(plan,ensure_ascii=False)))
    conn.commit()

def fail(conn,job,error):
    conn.rollback()
    next_status='failed' if int(job.get('retry_count') or 0)+1>=3 else 'render_retry_wait'
    with conn.cursor() as cur:cur.execute("UPDATE multi_shorts_jobs SET queue_status=%s,retry_count=retry_count+1,error_message=%s,finished_at=IF(%s='failed',NOW(),finished_at),updated_at=NOW() WHERE id=%s",(next_status,str(error)[:60000],next_status,job['id']))
    conn.commit()

def main():
    parser=argparse.ArgumentParser();parser.add_argument('--job-id',type=int,default=0);parser.add_argument('--template-id',type=int,choices=tuple(range(1,11)));parser.add_argument('--intro-template-id',type=int,choices=INTRO_TEMPLATE_IDS);parser.add_argument('--dry-run',action='store_true');parser.add_argument('--rerender',action='store_true');parser.add_argument('--local-preview',action='store_true');args=parser.parse_args()
    if args.rerender and not args.job_id:parser.error('--rerender는 안전을 위해 --job-id와 함께 사용해야 합니다.')
    if args.local_preview and not args.job_id:parser.error('--local-preview는 --job-id와 함께 사용해야 합니다.')
    logging.basicConfig(level=logging.INFO,format='%(asctime)s [%(levelname)s] %(message)s');conn=get_conn();job=None;locked=False
    try:
        with conn.cursor() as cur:cur.execute('SELECT GET_LOCK(%s,0) acquired',(LOCK_NAME,));locked=int((cur.fetchone() or {}).get('acquired') or 0)==1
        if not locked:print('[MULTI SHORTS RENDER SKIP] another_worker_running');return
        job=load_local_preview_job(conn,args.job_id) if args.local_preview else claim(conn,args.job_id,args.rerender)
        if not job:print('[MULTI SHORTS RENDER DONE] no_script_ready_job');return
        source=source_bundle(conn,job)
        if args.template_id:source['request']['template_id']=args.template_id
        if args.intro_template_id:source['request']['intro_template_id']=args.intro_template_id
        if args.local_preview:source['request']['_local_preview']=True
        fallback_needed=max(0,MIN_PHOTOS-len(source['photos']))
        preview_photo_count=len(source['photos'])+fallback_needed
        preview_template,preview_source=select_template(source.get('request'),preview_photo_count,len(source['floorplans']),int(job['id']))
        preview_intro,preview_intro_source=select_intro_template(source.get('request'),int(job['id'])+int(job.get('render_version') or 1))
        plan={'job_id':job['id'],'property_type':source.get('real_estate_type'),'template_id':preview_template.template_id,'template_name':preview_template.name,'template_source':preview_source,'intro_template_id':preview_intro.template_id,'intro_template_name':preview_intro.name,'intro_template_source':preview_intro_source,'real_image_count':len(source['photos']),'fallback_needed':fallback_needed,'floorplan_count':len(source['floorplans']),'motion_scope':'photo_only','fallback_label':'매물 유형 참고','target_seconds':TARGET_SHORTS_SECONDS,'target_photos':TARGET_PHOTOS}
        if args.dry_run:
            if not args.local_preview:
                restore_status=job.get('queue_status') if job.get('queue_status') in ('script_ready','render_retry_wait','rendered') else 'script_ready'
                with conn.cursor() as cur:cur.execute("UPDATE multi_shorts_jobs SET queue_status=%s,updated_at=NOW() WHERE id=%s",(restore_status,job['id']))
                conn.commit()
            print(json.dumps({'ok':True,'dry_run':True,**plan},ensure_ascii=False,indent=2));return
        job_dir=TEMP_ROOT/f"job_{job['id']}";job_dir.mkdir(parents=True,exist_ok=True)
        visual_seed=int(job['id'])*1009+preview_intro.template_id*7919+int(job.get('render_version') or 1)
        photos,floorplans,_=prepare_visuals(source,job_dir,visual_seed);plan.update({'usable_photo_count':len(photos),'usable_floorplan_count':len(floorplans),'reference_image_count':sum(x['kind']=='reference' for x in photos)})
        output,tts_ok,scenes,transitions,selected_voice,voice_gender,bgm,template,template_source,intro_template,intro_template_source=render(job,source,photos,floorplans,job_dir);plan.update({'tts':tts_ok,'voice':selected_voice,'voice_gender':voice_gender,'bgm':bgm.name if bgm else None,'bgm_volume':BGM_VOLUME,'template_id':template.template_id,'template_name':template.name,'template_source':template_source,'intro_template_id':intro_template.template_id,'intro_template_name':intro_template.name,'intro_template_source':intro_template_source,'scene_count':scenes,'transitions':transitions,'fixed_background':True,'output':output.as_posix()})
        if args.local_preview:
            print(f"[MULTI SHORTS LOCAL PREVIEW DONE] job_id={job['id']} intro={intro_template.template_id}:{intro_template.name} output={output.as_posix()}")
            return
        finish(conn,job,output,plan)
        print(f"[MULTI SHORTS RENDER DONE] job_id={job['id']} status=rendered template={template.template_id}:{template.name} intro={intro_template.template_id}:{intro_template.name} intro_source={intro_template_source} real={plan['real_image_count']} reference={plan['reference_image_count']} floorplans={plan['usable_floorplan_count']} scenes={scenes} tts={int(tts_ok)} voice={selected_voice} transitions={','.join(transitions)}")
    except Exception as exc:
        if job:fail(conn,job,exc)
        LOGGER.exception('웹진 숏츠 렌더 실패: %s',exc);raise SystemExit(1)
    finally:
        if locked:
            try:
                with conn.cursor() as cur:cur.execute('SELECT RELEASE_LOCK(%s)',(LOCK_NAME,))
            except Exception:pass
        conn.close()

if __name__=='__main__':main()
