# ARIFE ↔ Blender 다리 (v0.8.7 — 2026-09-07: 사이트(아리페 페이지)에서 온 block 명령 실행 + 진행 보고 · v0.8.6: 배지를 왼쪽 아래(타임라인 위)로, 바 열리면 바 위로 · v0.8.5: 바 안에 탭(만들기·연결·보관함)으로 N 패널 내용 흡수 · Blender 5 media_type 렌더 고침 · v0.8.4: 심볼을 둥근 배지로(테두리 색 = 상태) · 이름 「아리페」 · v0.8.3: 심볼을 단추처럼(판·테두리·이름표·호버·손 커서) · AGENT_NAME 한 곳 · v0.8.2: 위쪽 헤더 줄 제거 · 심볼 가운데 상단 · 바에 ▶ 블록 컴퓨터/⚙ · v0.8.1: 아래 입력 바 열고 닫기 + 애니메이션(심볼 클릭 · ⌄) · v0.8.0: 블록 컴퓨터(위쪽 헤더 줄 · Gemini 계획 → 실행 → 렌더 → AI 영상) · v0.7.0 — 2026-09-07: AI 조종(ARIFE MCP 서버 명령 실행 · 심장박동) + 뷰포트 심볼·입력 바 오버레이 · v0.6.1 — 2026-09-06: Hailuo 3 최소 5초 · 모델 4종 추가 — VACE 깊이·뼈대, Wan 3.0, Hailuo 3 · v0.5.1: 프롬프트 다듬기를 사이트 공용 창구(enhance)로 · v0.5.0: 단계형 패널 · 역할별 참조 그림 9장 · 시댄스 30초)
# ─────────────────────────────────────────────────────────────
# 설치: Blender → 편집 → 환경 설정 → 애드온 → 설치… → 이 파일 선택 → 체크
# 위치: 3D 뷰포트/비디오 시퀀서 오른쪽 N 패널 → "ARIFE" 탭
#
# 하는 일 (2단계 범위):
#   · ARIFE 서버 연결 확인 (영상 요금표 API 호출)                      [1단계]
#   · ARIFE 결과 영상/이미지 URL을 비디오 시퀀서(VSE)에 바로 얹기        [1단계]
#   · 연결 코드 로그인 — 사이트에서 발급한 코드 붙여넣기, 토큰 자동 갱신  [2단계]
#   · 보관함 목록 — 내 영상 목록을 패널에서 보고 클릭 한 번으로 가져오기 [2단계]
#   · 렌더 업로드 — Blender 렌더 결과를 ARIFE 보관함(Storage)으로 올리기 [2단계]
#   · AI 영상 원버튼 — 렌더 업로드→ltxRender 생성→VSE·보관함 저장       [3단계]
#
# 설계 메모(왜 이렇게 만들었나):
#   · bpy는 스레드 안전하지 않다. 그래서 네트워크는 전부 백그라운드
#     스레드에서 돌리고, 결과 반영(패널 갱신·환경설정 저장·VSE 조작)은
#     queue에 담았다가 bpy.app.timers 가 메인 스레드에서 꺼내 실행한다.
#   · 표준 라이브러리만 쓴다(urllib/json/threading/queue/http.client).
#     외부 패키지를 깔게 하면 설치 문턱이 확 높아지기 때문.
#   · 로그인은 비밀번호가 아니라 "연결 코드"(사이트가 발급한 1회용
#     action_link)로 한다 — 구글 소셜 로그인 계정은 비밀번호가 없기 때문.
#     링크를 리다이렉트 없이 열어 Location 헤더의 # 조각에서 토큰을 꺼낸다.
#     코드는 1회용이라 디스크에 남기지 않고(WindowManager), 받은 토큰만
#     환경 설정에 보관해 다음 실행 때 자동 로그인이 되게 한다.

bl_info = {
    "name": "ARIFE Bridge",
    "author": "ARIFE",
    "version": (0, 9, 14),
    "blender": (3, 6, 0),
    "location": "VSE·3D뷰 N패널 > ARIFE",
    "description": "ARIFE 블록 컴퓨터 — 글 한 줄로 블록아웃·렌더·AI 영상 · AI 조종(Claude·Codex·Cursor) · 보관함·업로드",
    "category": "Sequencer",
}

import os
import re
import json
import time
import base64
import queue
import tempfile
import threading
import webbrowser
import http.client
import urllib.error
import urllib.parse
import urllib.request

import bpy

SUPABASE_URL = "https://rrxudvdmafiersukaenv.supabase.co"
API_BASE = SUPABASE_URL + "/functions/v1"
# 사이트 자바스크립트에 이미 공개돼 있는 anon 키 — 비밀이 아니라서 기본값으로
# 심어 둔다. 사용자가 따로 붙여넣을 필요가 없고, 바뀌면 환경 설정에서 덮어쓴다.
DEFAULT_ANON_KEY = (
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
    "eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJyeHVkdmRtYWZpZXJzdWthZW52Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODM1NTU3NjQsImV4cCI6MjA5OTEzMTc2NH0."
    "BRnvq5z9o5krKW4IcXnWTk1PogfDf_C5Y9RPx7L2V14"
)
CONNECT_PAGE_URL = "https://arifeart.kr/desk#blender"   # 마이페이지 › 블렌더 연결 (코드 발급)
UPLOAD_BUCKET = "editor2-assets"
MAX_UPLOAD_BYTES = 300 * 1024 * 1024  # 300MB — 브라우저 업로드와 같은 상한
RENDER_REAL_URL = "https://arifeart.kr/arife"   # 아리페 에이전트 페이지
LIBRARY_URL = "https://arifeart.kr/library.html"
GEMINI_URL = SUPABASE_URL + "/functions/v1/arife-gemini"
ANCHOR_COST = 22  # 첫 장면 그리기(seedream5pro 1장) — 서버 요금표와 동일
# 기하 고정 서두 — 웹의 AI 블렌더 페이지가 쓰던 것과 같다. 영문이 더 잘 듣는다(실측).
GEO_LOCK = "Repaint the surfaces of this exact scene without changing the geometry, camera angle or composition. "


# ── 메인 스레드 실행 큐 ──────────────────────────────────────
# 스레드는 bpy를 만지면 안 되므로, "메인 스레드에서 할 일"을 함수째로
# 큐에 넣고 타이머가 하나씩 꺼내 실행한다.
_main_q = queue.Queue()

# 패널에 보여줄 상태 문구/업로드 진행률. 스레드가 쓰고 타이머가 읽는
# 단순 dict — GIL 덕에 이 정도 읽기/쓰기는 잠금 없이 안전하다.
_state = {
    "status": "",            # 패널 하단에 보여줄 한 줄 메시지
    "busy": "",              # 진행 중인 작업 이름(중복 실행 방지)
    "upload_progress": -1.0, # 0.0~1.0, 음수면 업로드 중 아님
    "upload_done": False,    # 업로드 성공 → 실사 변환 버튼 노출
    "credits": None,         # 보유 크레딧 (None이면 아직 모름)
    "ai_status": "",         # AI 영상 파이프라인 진행 문구 (한국어)
    "ai_quote": None,        # 예상 크레딧 (None이면 아직 안 물어봄)
}


def _run_on_main(fn):
    _main_q.put(fn)


def _pump():
    """0.2초마다 도는 타이머. 큐를 비우고, 업로드 진행률을 씬 속성에
    복사한 뒤 패널을 다시 그리게 한다."""
    while True:
        try:
            fn = _main_q.get_nowait()
        except queue.Empty:
            break
        try:
            fn()
        except Exception as e:
            print("[ARIFE] 메인 스레드 작업 실패:", e)
    # 진행률은 스레드가 dict에 쓰고, 여기(메인)서만 씬 속성에 반영한다.
    try:
        scene = bpy.context.scene
        if scene is not None:
            scene.arife_upload_progress = max(_state["upload_progress"], 0.0)
            # AI 진행 문구도 씬 속성으로 복사 — 패널은 씬 속성만 읽으면 된다.
            if scene.arife_ai_status != _state["ai_status"]:
                scene.arife_ai_status = _state["ai_status"]
        _redraw()
    except Exception:
        pass
    return 0.2


def _redraw():
    # 타이머에서 값만 바꾸면 패널이 안 바뀐 것처럼 보인다 — 강제로 다시 그린다.
    try:
        wm = bpy.context.window_manager
        for win in wm.windows:
            for area in win.screen.areas:
                if area.type in {"SEQUENCE_EDITOR", "VIEW_3D", "PREFERENCES"}:
                    area.tag_redraw()
    except Exception:
        pass


def _set_status(msg):
    _state["status"] = msg
    print("[ARIFE]", msg)


# ── 환경 설정 ────────────────────────────────────────────────
class ArifePrefs(bpy.types.AddonPreferences):
    bl_idname = __name__

    anon_key: bpy.props.StringProperty(
        name="ARIFE 공개 키(anon)",
        description="ARIFE 웹사이트가 쓰는 공개 키. 기본값이 심어져 있어 보통은 건드릴 필요 없다.",
        default=DEFAULT_ANON_KEY,
        subtype="PASSWORD",
    )
    # 로그인 세션 — 비밀번호가 아니라 토큰만 저장한다. subtype PASSWORD로
    # 화면에 노출되지 않게 가린다.
    email: bpy.props.StringProperty(
        name="이메일", description="ARIFE 로그인 이메일", default="")
    access_token: bpy.props.StringProperty(
        name="액세스 토큰", default="", subtype="PASSWORD",
        options={"HIDDEN"})
    refresh_token: bpy.props.StringProperty(
        name="갱신 토큰", default="", subtype="PASSWORD",
        options={"HIDDEN"})
    token_expires_at: bpy.props.StringProperty(
        # float를 문자열로 저장 — 예전 블렌더에서 FloatProperty 정밀도로
        # 유닉스 시각이 잘리는 일을 피하기 위해서다.
        name="토큰 만료 시각", default="0", options={"HIDDEN"})

    def draw(self, context):
        col = self.layout.column()
        col.prop(self, "anon_key")
        col.label(text="키는 ARIFE 관리자에게 받으세요. 공개(anon) 키만 넣으면 됩니다.")
        col.separator()
        if self.access_token:
            col.label(text=f"로그인됨: {self.email}", icon="USER")
            col.label(text="로그인/로그아웃은 N패널의 ARIFE 탭에서 하세요.")
        else:
            col.label(text="로그인은 N패널의 ARIFE 탭에서 하세요.")


def _prefs():
    return bpy.context.preferences.addons[__name__].preferences


def _anon_key():
    # 사용자가 칸을 비워 버려도 기본 키로 돌아가게 한다.
    return _prefs().anon_key.strip() or DEFAULT_ANON_KEY


def _online_blocked():
    # Blender 4.2+에는 "인터넷 접근 허용" 스위치가 있다. 꺼져 있으면
    # 네트워크를 시도하지 말고 이유를 알려준다. 구버전에는 속성이 없다.
    return hasattr(bpy.app, "online_access") and not bpy.app.online_access


def _snapshot_auth():
    """메인 스레드에서 토큰을 dict로 복사해 스레드에 넘긴다.
    스레드가 prefs(bpy)를 직접 읽지 않게 하기 위한 절차."""
    p = _prefs()
    try:
        exp = float(p.token_expires_at or "0")
    except ValueError:
        exp = 0.0
    return {
        "anon": _anon_key(),
        "email": p.email,
        "access_token": p.access_token,
        "refresh_token": p.refresh_token,
        "expires_at": exp,
    }


def _save_auth(auth):
    """스레드가 갱신한 토큰을 메인 스레드에서 prefs에 되쓴다."""
    def apply():
        p = _prefs()
        p.email = auth.get("email", p.email)
        p.access_token = auth.get("access_token", "")
        p.refresh_token = auth.get("refresh_token", "")
        p.token_expires_at = repr(auth.get("expires_at", 0.0))
    _run_on_main(apply)


# ── HTTP 도우미 (스레드에서만 호출) ──────────────────────────
def _http(method, url, body=None, headers=None, timeout=30):
    data = None
    hdrs = dict(headers or {})
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        hdrs.setdefault("Content-Type", "application/json")
    req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
    with urllib.request.urlopen(req, timeout=timeout) as res:
        raw = res.read()
    if not raw:
        return None
    try:
        return json.loads(raw.decode("utf-8"))
    except ValueError:
        return raw


def _auth_error_text(e):
    # Supabase는 오류 본문에 이유를 담아 준다. 사용자에게 그대로 다
    # 보여주긴 길어서 핵심(msg/error_description)만 뽑는다.
    try:
        body = json.loads(e.read().decode("utf-8"))
        return body.get("error_description") or body.get("msg") or body.get("message") or str(e)
    except Exception:
        return str(e)


def _jwt_payload(token):
    """JWT 가운데 조각(base64url)을 풀어 내용(dict)을 꺼낸다.
    외부 라이브러리 없이 되는 이유: 서명 검증이 아니라 내용 읽기만
    필요하기 때문이다."""
    try:
        payload = token.split(".")[1]
        payload += "=" * (-len(payload) % 4)  # 패딩 복원
        return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8"))
    except Exception:
        return {}


def _jwt_sub(token):
    return _jwt_payload(token).get("sub", "")


def _ensure_token(auth):
    """토큰이 만료(임박)했으면 refresh_token으로 갱신한다.
    스레드에서 호출 — 성공 시 auth dict를 고쳐 쓰고 prefs 저장을 예약한다."""
    if auth["access_token"] and time.time() < auth["expires_at"] - 60:
        return auth
    if not auth["refresh_token"]:
        raise RuntimeError("로그인이 필요합니다.")
    data = _http(
        "POST", SUPABASE_URL + "/auth/v1/token?grant_type=refresh_token",
        body={"refresh_token": auth["refresh_token"]},
        headers={"apikey": auth["anon"]},
    )
    auth["access_token"] = data["access_token"]
    auth["refresh_token"] = data.get("refresh_token", auth["refresh_token"])
    auth["expires_at"] = time.time() + float(data.get("expires_in", 3600))
    user = data.get("user") or {}
    auth["email"] = user.get("email", auth["email"])
    _save_auth(auth)
    return auth


def _rest_headers(auth):
    return {
        "apikey": auth["anon"],
        "Authorization": "Bearer " + auth["access_token"],
        "Accept": "application/json",
    }


def _fetch_credits(auth):
    rows = _http(
        "GET",
        SUPABASE_URL + "/rest/v1/user_credits?select=balance",
        headers=_rest_headers(auth),
    )
    if isinstance(rows, list) and rows:
        return rows[0].get("balance", 0)
    return 0


# ── 연결 확인 (1단계 유지) ───────────────────────────────────
class ARIFE_OT_ping(bpy.types.Operator):
    bl_idname = "arife.ping"
    bl_label = "ARIFE 연결 확인"
    bl_description = "요금표 API를 불러 서버와 키가 맞는지 확인한다"

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        key = _anon_key()
        try:
            data = _http(
                "GET", API_BASE + "/arife-video/api/video/rates",
                headers={"apikey": key, "Authorization": "Bearer " + key,
                         "Accept": "application/json"},
                timeout=15,
            )
            n = len(data.get("rates", {}))
            self.report({"INFO"}, f"연결 성공 — 모델 {n}개 (요금표 v{data.get('version')})")
        except Exception as e:
            self.report({"ERROR"}, f"Connect failed: {e}")
            return {"CANCELLED"}
        return {"FINISHED"}


# ── 연결(로그인) / 로그아웃 ──────────────────────────────────
# 왜 이메일/비밀번호가 아닌가: ARIFE 계정은 구글 같은 소셜 로그인이 많아
# 비밀번호 자체가 없는 계정이 흔하다. 그래서 사이트(내 계정 → 블렌더 연결)
# 에서 발급한 1회용 "연결 코드"(= 계정 확인 링크 action_link)를 붙여넣어
# 같은 계정의 토큰을 받아 온다. 어떤 로그인 방식이든 다 통한다.
class _NoRedirect(urllib.request.HTTPRedirectHandler):
    """리다이렉트를 따라가지 않는 핸들러.
    연결 코드(action_link)를 열면 서버가 302로 넘기면서 Location 헤더의
    # 조각(#access_token=…)에 토큰을 실어 보낸다. 리다이렉트를 따라가면
    urllib이 # 조각을 버려 토큰을 영영 못 읽으므로, 여기서 멈춰 세운다."""

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def _parse_token_fragment(location):
    """Location 헤더 URL의 #access_token=…&refresh_token=… 조각을 푼다.
    실패(만료·재사용)면 토큰 대신 ?error_code=… 로 오므로 조각과 쿼리를
    둘 다 살핀다."""
    parts = urllib.parse.urlsplit(location)
    frag = urllib.parse.parse_qs(parts.fragment)
    query = urllib.parse.parse_qs(parts.query)

    def pick(name):
        return (frag.get(name) or query.get(name) or [""])[0]

    if not pick("access_token"):
        err = pick("error_code") or pick("error") or "알 수 없는 오류"
        desc = pick("error_description").replace("+", " ")
        raise RuntimeError(
            f"코드가 만료됐거나 이미 사용됐습니다({err}). "
            "사이트에서 코드를 새로 발급해 주세요. " + desc)
    return pick("access_token"), pick("refresh_token"), pick("expires_in")


def _exchange_connect_code(code, anon):
    """연결 코드 → 토큰 교환. 스레드 전용.
    · 코드가 URL(action_link)이면: 리다이렉트를 따라가지 않고 GET 해서
      Location 헤더의 # 조각에서 토큰을 직접 꺼낸다. /auth/v1/verify 를
      직접 부르면 서버 설정에 따라 otp_expired 가 나는 일이 있어서,
      브라우저 로그인과 똑같은 경로를 그대로 밟는 쪽이 가장 확실하다.
    · URL이 아니면(예전 형식 해시 토큰): /auth/v1/verify 로 교환한다."""
    if code.startswith("http://") or code.startswith("https://"):
        opener = urllib.request.build_opener(_NoRedirect())
        location = ""
        try:
            res = opener.open(urllib.request.Request(code), timeout=30)
            # 3xx 인데도 예외가 안 난 경우를 대비해 응답 헤더도 본다.
            location = res.headers.get("Location") or ""
        except urllib.error.HTTPError as e:
            # redirect_request 가 None 을 돌려주면 3xx 응답이 HTTPError 로
            # 떨어진다 — 오류가 아니라 우리가 원한 바로 그 상황이다.
            if 300 <= e.code < 400:
                location = e.headers.get("Location") or ""
            else:
                raise RuntimeError("연결 코드 확인 실패: " + _auth_error_text(e))
        if not location:
            raise RuntimeError(
                "연결 코드 응답에 이동 주소가 없습니다. "
                "사이트에서 코드를 새로 발급해 주세요.")
        access, refresh, expires = _parse_token_fragment(location)
        try:
            ttl = float(expires or "3600")
        except ValueError:
            ttl = 3600.0
        return {
            "anon": anon,
            # 이메일은 액세스 토큰(JWT) 안에 들어 있어 따로 물을 필요 없다.
            "email": _jwt_payload(access).get("email", ""),
            "access_token": access,
            "refresh_token": refresh,
            "expires_at": time.time() + ttl,
        }
    # URL 이 아니면 해시 토큰으로 보고 verify 교환을 시도한다.
    data = _http(
        "POST", SUPABASE_URL + "/auth/v1/verify",
        body={"type": "magiclink", "token_hash": code},
        headers={"apikey": anon},
    )
    user = data.get("user") or {}
    return {
        "anon": anon,
        "email": user.get("email", ""),
        "access_token": data["access_token"],
        "refresh_token": data.get("refresh_token", ""),
        "expires_at": time.time() + float(data.get("expires_in", 3600)),
    }


class ARIFE_OT_connect(bpy.types.Operator):
    bl_idname = "arife.connect"
    bl_label = "연결"
    bl_description = "사이트에서 복사한 연결 코드로 ARIFE 계정에 연결한다"

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        wm = context.window_manager
        code = wm.arife_connect_code.strip()
        anon = _anon_key()
        if not anon:
            self.report({"ERROR"}, "No ARIFE public (anon) key in preferences.")
            return {"CANCELLED"}
        if not code:
            self.report({"ERROR"}, "Paste the connect code (arifeart.kr > My page > Blender connect).")
            return {"CANCELLED"}
        if _state["busy"]:
            self.report({"WARNING"}, "다른 작업이 진행 중입니다. 잠시 후 다시 시도하세요.")
            return {"CANCELLED"}

        _state["busy"] = "connect"
        _set_status("연결 중…")

        def work():
            try:
                auth = _exchange_connect_code(code, anon)
                _save_auth(auth)
                try:
                    _state["credits"] = _fetch_credits(auth)
                except Exception:
                    _state["credits"] = None
                _set_status(f"로그인됨: {auth['email'] or '내 계정'}")
                # 쓴 코드는 칸에서 지운다 — 어차피 1회용이라 다시 못 쓴다.
                def clear_code():
                    bpy.context.window_manager.arife_connect_code = ""
                _run_on_main(clear_code)
                # 2026-09-09 — 로그인하면 아리페(AI 조종)를 바로 켠다. 「켜기」 단추를 한 번 더
                # 찾아 누르는 게 헷갈린다는 피드백. 끄고 싶으면 연결 탭에서 끈다.
                def auto_on():
                    try:
                        _agent_start()
                        _set_status(f"로그인됨: {auth['email'] or '내 계정'} — 아리페(AI 조종) 켜짐")
                    except Exception as e:
                        print("[ARIFE] auto agent:", e)
                _run_on_main(auto_on)
            except urllib.error.HTTPError as e:
                _set_status("연결 실패: " + _auth_error_text(e) + " — 사이트에서 코드를 새로 발급해 주세요.")
            except Exception as e:
                _set_status(f"Connect failed: {e}")
            finally:
                _state["busy"] = ""

        threading.Thread(target=work, daemon=True).start()
        return {"FINISHED"}


class ARIFE_OT_open_connect_page(bpy.types.Operator):
    bl_idname = "arife.open_connect_page"
    bl_label = "연결 코드 받으러 가기"
    bl_description = "arifeart.kr › 마이페이지 › 블렌더 연결 페이지를 브라우저로 연다 (코드 발급·복사)"

    def execute(self, context):
        webbrowser.open(CONNECT_PAGE_URL)
        return {"FINISHED"}


class ARIFE_OT_logout(bpy.types.Operator):
    bl_idname = "arife.logout"
    bl_label = "로그아웃"
    bl_description = "저장된 토큰을 지운다"

    def execute(self, context):
        p = _prefs()
        p.access_token = ""
        p.refresh_token = ""
        p.token_expires_at = "0"
        _state["credits"] = None
        _state["upload_done"] = False
        context.window_manager.arife_assets.clear()
        _set_status("로그아웃했습니다.")
        return {"FINISHED"}


class ARIFE_OT_refresh_credits(bpy.types.Operator):
    bl_idname = "arife.refresh_credits"
    bl_label = "크레딧 새로고침"
    bl_description = "보유 크레딧을 다시 불러온다"

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        auth = _snapshot_auth()
        if not auth["refresh_token"] and not auth["access_token"]:
            self.report({"ERROR"}, "Sign in first.")
            return {"CANCELLED"}

        def work():
            try:
                a = _ensure_token(auth)
                _state["credits"] = _fetch_credits(a)
                _set_status("크레딧을 갱신했습니다.")
            except Exception as e:
                _set_status(f"크레딧 조회 실패: {e}")

        threading.Thread(target=work, daemon=True).start()
        return {"FINISHED"}


# ── 보관함 목록 ──────────────────────────────────────────────
class ArifeAssetItem(bpy.types.PropertyGroup):
    asset_id: bpy.props.StringProperty()
    title: bpy.props.StringProperty()
    bucket_id: bpy.props.StringProperty()
    object_path: bpy.props.StringProperty()
    mime_type: bpy.props.StringProperty()


class ARIFE_OT_fetch_library(bpy.types.Operator):
    bl_idname = "arife.fetch_library"
    bl_label = "보관함 새로고침"
    bl_description = "내 보관함의 최근 영상 20개를 불러온다"

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        auth = _snapshot_auth()
        if not auth["refresh_token"] and not auth["access_token"]:
            self.report({"ERROR"}, "Sign in first.")
            return {"CANCELLED"}
        if _state["busy"]:
            self.report({"WARNING"}, "다른 작업이 진행 중입니다.")
            return {"CANCELLED"}
        _state["busy"] = "library"
        _set_status("보관함을 불러오는 중…")

        def work():
            try:
                a = _ensure_token(auth)
                rows = _http(
                    "GET",
                    SUPABASE_URL + "/rest/v1/media_assets"
                    "?select=id,title,bucket_id,object_path,mime_type"
                    "&deleted_at=is.null&order=created_at.desc&limit=20",
                    headers=_rest_headers(a),
                )
                items = [r for r in (rows or [])
                         if (r.get("mime_type") or "").startswith("video/")]

                def apply():
                    coll = bpy.context.window_manager.arife_assets
                    coll.clear()
                    for r in items:
                        it = coll.add()
                        it.asset_id = str(r.get("id", ""))
                        it.title = r.get("title") or r.get("object_path", "")
                        it.bucket_id = r.get("bucket_id", "")
                        it.object_path = r.get("object_path", "")
                        it.mime_type = r.get("mime_type", "")
                _run_on_main(apply)
                _set_status(f"보관함 영상 {len(items)}개를 불러왔습니다.")
            except Exception as e:
                _set_status(f"보관함 조회 실패: {e}")
            finally:
                _state["busy"] = ""

        threading.Thread(target=work, daemon=True).start()
        return {"FINISHED"}


def _add_media_to_vse(scene, dest, name):
    """내려받은 파일을 VSE 트랙에 얹는다 — v0.1의 가져오기 로직을 함수로
    빼서 URL 가져오기·보관함 가져오기가 같이 쓴다. 메인 스레드 전용."""
    if not scene.sequence_editor:
        scene.sequence_editor_create()
    seq = scene.sequence_editor
    frame = int(scene.frame_current)
    # 블렌더 4.4부터 sequences → strips 로 이름이 바뀌었고,
    # 새 판(5.x)에서는 옛 이름이 아예 사라져서 여기서 갈라 준다.
    # v0.4.8 — `or` 로 고르면 안 된다: 시퀀서가 "비어 있으면" 컬렉션이 거짓 취급되어
    # 멀쩡한 strips 를 버리고 None 이 됐다(빈 시퀀서에서만 터지던 원인, 실측 2026-08-14).
    col = getattr(seq, "strips", None)
    if col is None:
        col = getattr(seq, "sequences", None)
    every = getattr(seq, "strips_all", None)
    if every is None:
        every = getattr(seq, "sequences_all", None)
    if every is None:
        every = []
    if col is None:
        raise RuntimeError(
            "이 블렌더 판(" + bpy.app.version_string + ")의 시퀀서 API를 찾지 못했습니다.")
    channel = 1
    for s in every:
        channel = max(channel, s.channel + 1)

    ext = name.lower().rsplit(".", 1)[-1] if "." in name else "mp4"
    if ext in ("png", "jpg", "jpeg", "webp"):
        strip = col.new_image(name=name, filepath=dest, channel=channel, frame_start=frame)
    else:
        strip = col.new_movie(name=name, filepath=dest, channel=channel, frame_start=frame)
        # 소리 트랙도 같이 — 실패해도(무음 영상) 영상만 얹으면 된다.
        try:
            col.new_sound(name=name + " 소리", filepath=dest, channel=channel + 1, frame_start=frame)
        except Exception:
            pass

    # 씬 길이를 클립 끝까지 늘려 준다
    end = strip.frame_final_end
    if scene.frame_end < end:
        scene.frame_end = end
    return channel


class ARIFE_OT_import_asset(bpy.types.Operator):
    bl_idname = "arife.import_asset"
    bl_label = "VSE로 가져오기"
    bl_description = "이 영상을 내려받아 시퀀서 트랙에 얹는다"

    index: bpy.props.IntProperty()

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        coll = context.window_manager.arife_assets
        if self.index < 0 or self.index >= len(coll):
            self.report({"ERROR"}, "The list changed — refresh the library.")
            return {"CANCELLED"}
        item = coll[self.index]
        bucket, path, title = item.bucket_id, item.object_path, item.title
        auth = _snapshot_auth()
        if _state["busy"]:
            self.report({"WARNING"}, "다른 작업이 진행 중입니다.")
            return {"CANCELLED"}
        _state["busy"] = "import"
        _set_status(f"내려받는 중: {title}")

        def work():
            try:
                a = _ensure_token(auth)
                # 비공개 버킷이라 서명 URL을 먼저 발급받아야 내려받을 수 있다.
                quoted = urllib.parse.quote(path)
                signed = _http(
                    "POST",
                    SUPABASE_URL + f"/storage/v1/object/sign/{bucket}/{quoted}",
                    body={"expiresIn": 3600},
                    headers=_rest_headers(a),
                )
                url = SUPABASE_URL + "/storage/v1" + signed["signedURL"]
                name = os.path.basename(path) or "arife_clip.mp4"
                if "." not in name:
                    name += ".mp4"
                dest = os.path.join(tempfile.gettempdir(), "arife_" + name)
                urllib.request.urlretrieve(url, dest)

                def apply():
                    try:
                        sc = getattr(bpy.context, "scene", None) or (bpy.data.scenes[0] if bpy.data.scenes else None)
                        if sc is None:
                            raise RuntimeError("씬을 찾지 못했습니다")
                        ch = _add_media_to_vse(sc, dest, name)
                        _set_status(f"가져오기 완료: {title} (채널 {ch})")
                    except Exception as e:
                        import traceback
                        traceback.print_exc()
                        _set_status(f"시퀀서에 얹지 못했습니다 [{type(e).__name__}] {e}")
                _run_on_main(apply)
            except Exception as e:
                _set_status(f"가져오기 실패: {e}")
            finally:
                _state["busy"] = ""

        threading.Thread(target=work, daemon=True).start()
        return {"FINISHED"}


# ── URL → 시퀀서 가져오기 (1단계 유지) ───────────────────────
class ARIFE_OT_import_url(bpy.types.Operator):
    bl_idname = "arife.import_url"
    bl_label = "URL 영상 가져오기"
    bl_description = "ARIFE 결과 영상(https)을 내려받아 시퀀서 트랙에 얹는다"

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        url = context.scene.arife_media_url.strip()
        if not url.startswith("https://"):
            self.report({"ERROR"}, "Paste an https URL.")
            return {"CANCELLED"}

        # 내려받기 — Blender 임시 폴더에 저장
        name = url.split("?")[0].rstrip("/").split("/")[-1] or "arife_clip.mp4"
        if "." not in name:
            name += ".mp4"
        dest = os.path.join(tempfile.gettempdir(), "arife_" + name)
        try:
            urllib.request.urlretrieve(url, dest)
        except Exception as e:
            self.report({"ERROR"}, f"Download failed: {e}")
            return {"CANCELLED"}

        try:
            channel = _add_media_to_vse(context.scene, dest, name)
        except Exception as e:
            self.report({"ERROR"}, f"Could not add to the sequencer: {e}")
            return {"CANCELLED"}
        self.report({"INFO"}, f"가져오기 완료: {name} (채널 {channel})")
        return {"FINISHED"}


# ── 렌더 업로드 ──────────────────────────────────────────────
def _resolve_render_file(scene):
    """렌더 결과 파일을 찾는다. ① 사용자가 「렌더 파일」 칸에 직접 고른 파일,
    ② render.filepath 자체가 파일이면 그것, ③ frame_path()(프레임 번호·확장자가
    붙은 실제 출력 경로) 순서로 본다."""
    picked = bpy.path.abspath(getattr(scene, "arife_render_file", "") or "")
    if picked and os.path.isfile(picked):
        return picked
    raw = bpy.path.abspath(scene.render.filepath)
    if raw and os.path.isfile(raw):
        return raw
    try:
        fp = scene.render.frame_path(frame=scene.frame_current)
        if fp and os.path.isfile(fp):
            return fp
    except Exception:
        pass
    return None


def _upload_file_with_progress(url, filepath, headers, progress_cb):
    """urllib은 업로드 진행률을 알려주지 않아서 http.client로 직접
    조각(256KB)씩 보내며 퍼센트를 계산한다."""
    parsed = urllib.parse.urlsplit(url)
    conn = http.client.HTTPSConnection(parsed.hostname, parsed.port or 443, timeout=600)
    try:
        size = os.path.getsize(filepath)
        path = parsed.path + ("?" + parsed.query if parsed.query else "")
        conn.putrequest("POST", path)
        for k, v in headers.items():
            conn.putheader(k, v)
        conn.putheader("Content-Length", str(size))
        conn.endheaders()
        sent = 0
        with open(filepath, "rb") as f:
            while True:
                chunk = f.read(256 * 1024)
                if not chunk:
                    break
                conn.send(chunk)
                sent += len(chunk)
                progress_cb(sent / size)
        res = conn.getresponse()
        body = res.read().decode("utf-8", "replace")
        if res.status >= 400:
            raise RuntimeError(f"업로드 실패 (HTTP {res.status}): {body[:200]}")
        return body
    finally:
        conn.close()


class ARIFE_OT_upload_render(bpy.types.Operator):
    bl_idname = "arife.upload_render"
    bl_label = "렌더를 ARIFE로 올리기"
    bl_description = "렌더된 결과 파일을 ARIFE 보관함(Storage)으로 업로드한다"

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        auth = _snapshot_auth()
        if not auth["refresh_token"] and not auth["access_token"]:
            self.report({"ERROR"}, "Sign in first.")
            return {"CANCELLED"}
        if _state["busy"]:
            self.report({"WARNING"}, "다른 작업이 진행 중입니다.")
            return {"CANCELLED"}

        scene = context.scene
        scene_name = scene.name  # 타이머에서 씬을 되찾을 열쇠
        filepath = _resolve_render_file(scene)
        if not filepath:
            self.report({"ERROR"}, "No render file. Render first (Ctrl+F12).")
            return {"CANCELLED"}
        byte_size = os.path.getsize(filepath)
        if byte_size > MAX_UPLOAD_BYTES:
            self.report({"ERROR"}, f"파일이 300MB를 넘습니다({byte_size // (1024*1024)}MB). 더 작게 렌더하거나 압축해 주세요.")
            return {"CANCELLED"}

        _state["busy"] = "upload"
        _state["upload_progress"] = 0.0
        _state["upload_done"] = False
        _set_status("업로드 시작…")

        def work():
            try:
                a = _ensure_token(auth)
                uid = _jwt_sub(a["access_token"])
                if not uid:
                    raise RuntimeError("토큰에서 사용자 id를 읽지 못했습니다. 다시 로그인해 주세요.")
                base = os.path.splitext(os.path.basename(filepath))[0] or "render"
                stamp = time.strftime("%Y%m%d-%H%M%S")
                object_path = f"{uid}/blender/{stamp}-{base}.mp4"
                quoted = urllib.parse.quote(object_path)

                def on_progress(p):
                    _state["upload_progress"] = p

                _upload_file_with_progress(
                    SUPABASE_URL + f"/storage/v1/object/{UPLOAD_BUCKET}/{quoted}",
                    filepath,
                    headers={
                        "apikey": a["anon"],
                        "Authorization": "Bearer " + a["access_token"],
                        "Content-Type": "video/mp4",
                        "x-upsert": "true",
                    },
                    progress_cb=on_progress,
                )

                # 스토리지에 올렸으면 보관함 DB(media_assets)에도 줄을 넣어야
                # 웹사이트 보관함에 나타난다.
                _http(
                    "POST",
                    SUPABASE_URL + "/rest/v1/media_assets",
                    body={
                        "user_id": uid,
                        "bucket_id": UPLOAD_BUCKET,
                        "object_path": object_path,
                        "kind": "output_video",
                        "mime_type": "video/mp4",
                        "title": f"블렌더 렌더 {base}",
                        "source_tool": "blender",
                        "byte_size": byte_size,
                    },
                    headers={
                        "apikey": a["anon"],
                        "Authorization": "Bearer " + a["access_token"],
                        "Prefer": "return=minimal",
                    },
                )
                _state["upload_done"] = True
                _set_status("올렸습니다 — arifeart.kr/arife 에서 실사로 바꿔 보세요")
            except Exception as e:
                _set_status(f"업로드 실패: {e}")
            finally:
                _state["upload_progress"] = -1.0
                _state["busy"] = ""

        threading.Thread(target=work, daemon=True).start()
        return {"FINISHED"}


class ARIFE_OT_open_render_real(bpy.types.Operator):
    bl_idname = "arife.open_render_real"
    bl_label = "아리페 에이전트 페이지 열기"
    bl_description = "arifeart.kr/arife 를 브라우저로 연다"

    def execute(self, context):
        webbrowser.open(RENDER_REAL_URL)
        return {"FINISHED"}


# ── AI 영상 만들기 (v0.3 원버튼 파이프라인) ──────────────────
# render-real.html 이 브라우저에서 하던 일(렌더 업로드 → ltxRender 생성 →
# 결과 저장)을 버튼 하나로 압축했다. 순서:
#   렌더 파일 찾기 → Storage 업로드 → 서명 URL → /api/video/generate →
#   5초 간격 폴링 → 결과 내려받기 → VSE에 얹기 + 보관함(media_assets) 기록.
AI_MODEL = "ltxRender"  # 기본 모델 (선택칸의 기본값)


def _api_headers(a):
    return {
        "apikey": a["anon"],
        "Authorization": "Bearer " + a["access_token"],
        "Accept": "application/json",
    }


def _api_error(e):
    """arife-video 함수의 오류 본문(JSON)에서 사람이 읽을 메시지를 꺼낸다.
    크레딧 부족은 필요한 값까지 붙여 한국어로 또렷하게 알려 준다."""
    try:
        body = json.loads(e.read().decode("utf-8"))
    except Exception:
        return str(e)
    if body.get("code") == "INSUFFICIENT_CREDITS":
        cost = body.get("cost")
        extra = f" (이 작업에 {cost}크레딧 필요)" if cost is not None else ""
        return "크레딧이 부족합니다" + extra + " — arifeart.kr 요금 페이지에서 충전해 주세요."
    return body.get("error") or body.get("message") or str(e)


def _set_ai(msg):
    _state["ai_status"] = msg
    print("[ARIFE AI]", msg)


# v0.6 — 모델 갈래
#   V2V_MODELS : 렌더 영상을 video_url 로 통째로 넣는다(mode "video"). 서버가 카메라·움직임을 유지한 채 다시 그린다.
#   REFV_MODELS: 렌더 영상을 '참조 영상'(reference_video_urls)으로 넣는다(mode "text"). 프롬프트에서 Video 1 로 지목.
#   MULTI_REF  : 참조 그림을 여러 장 받는 모델(그 밖은 앵커 1장).
V2V_MODELS = ("ltxRender", "seedance25", "geminiOmni", "vaceDepth", "vacePose")
REFV_MODELS = ("wan30", "hailuo3")
MULTI_REF = {"seedance25": 9, "wan30": 10, "hailuo3": 9}   # 서버 REF_SPEC 과 같은 값 (Wan 3.0 은 10장)
H3_RES = {"480p": "768p", "720p": "2K"}

def _ai_duration(scene):
    model = scene.arife_ai_model
    if model == "seedance25":
        return int(scene.arife_ai_duration_sd)
    if model == "geminiOmni":
        return int(scene.arife_ai_duration_gm)
    if model in ("vaceDepth", "vacePose"):
        return int(scene.arife_ai_duration_vace)
    if model == "wan30":
        return int(scene.arife_ai_duration_wan)
    if model == "hailuo3":
        return int(scene.arife_ai_duration_h3)
    return int(scene.arife_ai_duration)

def _ai_body(scene):
    """패널 설정을 서버 요청 본문 형태로 모은다. 메인 스레드에서 호출.
    mode·aspect_ratio 를 견적 때부터 넣는다 — 시댄스 2.5 는 영상 참조면
    요금이 0.6배라서, mode 가 빠지면 견적이 실제 차감보다 비싸게 나온다."""
    model = scene.arife_ai_model
    body = {
        "model": model,
        "duration": _ai_duration(scene),
        "resolution": H3_RES.get(scene.arife_ai_resolution, "2K") if model == "hailuo3" else scene.arife_ai_resolution,
        "mode": "text" if model in REFV_MODELS else "video",
        "aspect_ratio": "16:9",
    }
    if model == "ltxRender":
        body["enable_detail_refine"] = bool(scene.arife_ai_refine)
    return body


class ARIFE_OT_ai_quote(bpy.types.Operator):
    bl_idname = "arife.ai_quote"
    bl_label = "예상 크레딧 확인"
    bl_description = "지금 설정(길이·해상도·다듬기)으로 차감될 크레딧을 서버에 물어본다"

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        auth = _snapshot_auth()
        if not auth["refresh_token"] and not auth["access_token"]:
            self.report({"ERROR"}, "Sign in first.")
            return {"CANCELLED"}
        if _state["busy"]:
            self.report({"WARNING"}, "다른 작업이 진행 중입니다.")
            return {"CANCELLED"}
        body = _ai_body(context.scene)
        _state["busy"] = "quote"

        def work():
            try:
                a = _ensure_token(auth)
                # 금액 계산은 반드시 서버에 묻는다 — 애드온에 요금표 사본을
                # 두면 서버와 반드시 어긋난다(웹 화면도 같은 이유로 quote를 쓴다).
                data = _http(
                    "POST", API_BASE + "/arife-video/api/video/quote",
                    body=body, headers=_api_headers(a), timeout=30,
                )
                _state["ai_quote"] = data.get("cost")
                _set_status(f"예상 크레딧: {data.get('cost')}")
            except urllib.error.HTTPError as e:
                _set_status("견적 확인 실패: " + _api_error(e))
            except Exception as e:
                _set_status(f"견적 확인 실패: {e}")
            finally:
                _state["busy"] = ""

        threading.Thread(target=work, daemon=True).start()
        return {"FINISHED"}


class ARIFE_OT_prompt_upgrade(bpy.types.Operator):
    bl_idname = "arife.prompt_upgrade"
    bl_label = "프롬프트 업그레이드"
    bl_description = "짧은 한국어 설명을 AI가 상세한 영어 프롬프트로 다듬는다. 한 번 더 누르면 원문으로 되돌린다"

    # 웹의 프롬프트 업그레이드와 같은 도우미(arife-gemini)를 쓴다.
    # 영상용 지시문: 장면을 자세히 칠하되 카메라 지시는 넣지 않는다 —
    # 카메라는 블렌더 렌더가 이미 정했기 때문이다.
    UP_SYS = (
        "너는 영상 생성 프롬프트 전문가다. 사용자가 3D 렌더 위에 어떤 장면을 그릴지 "
        "한국어로 짧게 적었다. 이것을 영상 생성 모델이 잘 이해하는 상세하고 시각적인 "
        "영어 프롬프트 한 단락으로 확장하라. 규칙: (1) 무엇이 나오는지, 재질·색·조명·"
        "배경·분위기를 구체화한다. (2) 카메라 움직임이나 앵글 지시는 넣지 않는다 — "
        "카메라는 원본 렌더가 정한다. (3) 설명·따옴표·머리말 없이 프롬프트 문장만 출력한다."
    )

    def execute(self, context):
        scene = context.scene
        # 이미 다듬어 둔 상태면 → 원문으로 되돌리기
        if scene.arife_ai_prompt_orig:
            scene.arife_ai_prompt = scene.arife_ai_prompt_orig
            scene.arife_ai_prompt_orig = ""
            _set_status("원문으로 되돌렸습니다.")
            return {"FINISHED"}
        raw = scene.arife_ai_prompt.strip()
        if not raw:
            self.report({"ERROR"}, "Type the scene in the prompt field first.")
            return {"CANCELLED"}
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        auth = _snapshot_auth()
        if not auth["refresh_token"] and not auth["access_token"]:
            self.report({"ERROR"}, "Sign in first.")
            return {"CANCELLED"}
        if _state["busy"]:
            self.report({"WARNING"}, "다른 작업이 진행 중입니다.")
            return {"CANCELLED"}
        _state["busy"] = "prompt"
        _set_status("프롬프트를 다듬는 중…")
        model_hint = str(getattr(scene, "arife_ai_model", "") or "")

        def work():
            try:
                a = _ensure_token(auth)
                data = _http(
                    "POST", GEMINI_URL,
                    # v0.5.1 — 사이트 공용 보강 창구(arife-gemini action "enhance", target "render").
                    # 규칙(UP_SYS)은 서버로 옮겼다 — 웹·블렌더가 같은 모델·같은 규칙으로 다듬는다.
                    body={"action": "enhance", "target": "render", "prompt": raw,
                          "model": model_hint},
                    headers={
                        "apikey": a["anon"],
                        "Authorization": "Bearer " + a["access_token"],
                        "Content-Type": "application/json",
                    },
                    timeout=60,
                )
                text = str((data or {}).get("text") or "").strip()
                if not text:
                    raise RuntimeError((data or {}).get("error") or "다듬은 결과를 받지 못했습니다.")

                def apply():
                    sc = bpy.context.scene
                    sc.arife_ai_prompt_orig = raw
                    sc.arife_ai_prompt = text[:1000]
                    _set_status("다듬었습니다 — 마음에 안 들면 「원문으로」를 누르세요.")
                _run_on_main(apply)
            except urllib.error.HTTPError as e:
                _set_status("다듬기 실패: " + _api_error(e))
            except Exception as e:
                _set_status(f"다듬기 실패: {e}")
            finally:
                _state["busy"] = ""

        threading.Thread(target=work, daemon=True).start()
        return {"FINISHED"}


class ARIFE_OT_open_library_page(bpy.types.Operator):
    bl_idname = "arife.open_library_page"
    bl_label = "사이트 영상 보관함 열기"
    bl_description = "완성 영상이 저장되는 arifeart.kr 영상 보관함을 브라우저로 연다"

    def execute(self, context):
        webbrowser.open(LIBRARY_URL)
        return {"FINISHED"}


def _render_anchor_frame(scene):
    """첫 장면 그리기용으로 시작 프레임 한 장을 PNG로 렌더한다.
    메인 스레드 전용(bpy.ops). 출력 설정은 건드린 뒤 반드시 원래대로 되돌린다."""
    dest = os.path.join(tempfile.gettempdir(), f"arife_anchor_{int(time.time())}.png")
    r = scene.render
    keep_path = r.filepath
    keep_format = r.image_settings.file_format
    keep_media = getattr(r.image_settings, "media_type", None)
    keep_frame = scene.frame_current
    try:
        if keep_media is not None:
            r.image_settings.media_type = "IMAGE"
        r.image_settings.file_format = "PNG"
        r.filepath = dest
        scene.frame_set(scene.frame_start)
        bpy.ops.render.render(write_still=True)
    finally:
        # 되돌리는 순서 주의: 새 블렌더에서 FFmpeg 형식은 media_type 이
        # VIDEO 일 때만 고를 수 있어서 media_type 부터 되돌린다.
        if keep_media is not None:
            try:
                r.image_settings.media_type = keep_media
            except Exception:
                pass
        try:
            r.image_settings.file_format = keep_format
        except Exception:
            pass
        r.filepath = keep_path
        scene.frame_set(keep_frame)
    return dest if os.path.isfile(dest) else None



# v0.5 — 역할별 참조 그림 수집. 순서는 캐릭터 → 배경 → 효과 (서버는 이 순서로 @Image1… 을 매긴다).
REF_GROUPS = (("char", "캐릭터"), ("bg", "배경"), ("fx", "효과"))
def _collect_refs(scene):
    """[(절대경로, 역할키), …]  옛 arife_ref_image·2·3 은 캐릭터로 친다. 중복·없는 파일은 뺀다."""
    out, seen = [], set()
    legacy = [getattr(scene, n, "") for n in ("arife_ref_image", "arife_ref_image2", "arife_ref_image3")]
    for g, _ in REF_GROUPS:
        paths = [getattr(scene, f"arife_ref_{g}{i}", "") for i in (1, 2, 3)]
        if g == "char":
            paths = legacy + paths
        for raw in paths:
            rp = bpy.path.abspath(raw or "")
            if rp and os.path.isfile(rp) and rp not in seen:
                seen.add(rp); out.append((rp, g))
    return out[:MULTI_REF.get(getattr(scene, "arife_ai_model", ""), 9)]   # 모델 상한(Wan 3.0 은 10장)

def _ref_legend(refs):
    """@Image 번호에 역할을 붙여 시댄스가 무엇을 어디에 쓸지 알게 한다."""
    if not refs: return ""
    role = {"char": "a CHARACTER reference — keep this character's face, outfit, colors and proportions exactly",
            "bg":   "an ENVIRONMENT reference — match this place: architecture, materials, lighting, era",
            "fx":   "a VFX / MOOD reference — apply this kind of effect, atmosphere and color grade"}
    parts = [f"@Image{i + 1} is {role[g]}" for i, (_, g) in enumerate(refs)]
    return " References: " + "; ".join(parts) + "."

class ARIFE_OT_ai_generate(bpy.types.Operator):
    bl_idname = "arife.ai_generate"
    bl_label = "렌더 → AI 영상 만들기"
    bl_description = "렌더 결과를 올려 AI로 영상을 만들고, 완성본을 VSE와 사이트 영상 보관함에 넣는다"

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network).")
            return {"CANCELLED"}
        auth = _snapshot_auth()
        if not auth["refresh_token"] and not auth["access_token"]:
            self.report({"ERROR"}, "Sign in first.")
            return {"CANCELLED"}
        if _state["busy"]:
            self.report({"WARNING"}, "다른 작업이 진행 중입니다.")
            return {"CANCELLED"}

        scene = context.scene
        scene_name = scene.name  # v0.7 — ⑥ VSE 얹기에서 쓰는데 빠져 있었다(NameError)
        filepath = _resolve_render_file(scene)
        if not filepath:
            self.report({"ERROR"}, "렌더 결과 파일이 없습니다. 먼저 렌더(Ctrl+F12 / 애니메이션 렌더)를 해주세요.")
            return {"CANCELLED"}
        byte_size = os.path.getsize(filepath)
        if byte_size > MAX_UPLOAD_BYTES:
            self.report({"ERROR"}, f"파일이 300MB를 넘습니다({byte_size // (1024*1024)}MB). 더 작게 렌더하거나 압축해 주세요.")
            return {"CANCELLED"}

        body = _ai_body(scene)
        body["mode"] = "video"
        body["prompt"] = scene.arife_ai_prompt.strip()

        # 참조 그림 — 사용자가 직접 고른 이미지가 있으면 그것이 기준(앵커)이 된다.
        # 자동 첫 장면(+22크레딧)은 건너뛴다 — 이미 기준이 있으니까.
        ref_pairs = _collect_refs(scene) if scene.arife_ai_model != "geminiOmni" else []
        ref_pairs = ref_pairs[:MULTI_REF.get(scene.arife_ai_model, 1)]   # 여러 장은 시댄스·Wan 3.0·Hailuo 3
        ref_paths = [rp for rp, _ in ref_pairs]
        ref_path = ref_paths[0] if ref_paths else ""

        # 첫 장면 앵커 — 시작 프레임을 지금(메인 스레드에서) 한 장 렌더해 둔다.
        # 워크벤치·이비 한 장은 몇 초면 끝난다. 그림은 뒤(작업 스레드)에서 그린다.
        anchor_png = None
        if scene.arife_ai_anchor and scene.arife_ai_model != "geminiOmni" and not ref_path:
            if not body["prompt"]:
                self.report({"ERROR"}, "첫 장면을 그리려면 프롬프트가 필요합니다.")
                return {"CANCELLED"}
            try:
                anchor_png = _render_anchor_frame(scene)
            except Exception as e:
                self.report({"ERROR"}, f"첫 프레임 렌더 실패: {e}")
                return {"CANCELLED"}
            if not anchor_png:
                self.report({"ERROR"}, "첫 프레임을 렌더하지 못했습니다.")
                return {"CANCELLED"}

        # 시댄스는 "회색 렌더 → 완성 장면" 을 변신 연출로 만들어 버린 적이 있다(실측:
        # 첫 프레임이 회색 큐브, 마지막에야 완성). 첫 프레임부터 완성된 모습이 나오도록
        # 여기서 @Video1·@Image1 지시문을 직접 감싼다 — @표기가 있으면 서버는 그대로 둔다.
        if scene.arife_ai_model == "seedance25" and "@" not in body["prompt"]:
            said = body["prompt"]
            will_have_img = bool(ref_paths) or bool(anchor_png)
            has_char = any(g == "char" for _, g in ref_pairs)
            lead = (
                ("Completely repaint @Video1 so it looks exactly like @Image1 — @Image1 is the finished look of this scene. "
                 if (will_have_img and not ref_pairs) else
                 "Completely repaint @Video1 as the scene described, using the references below for their stated roles. ")
                if will_have_img else
                "Completely repaint @Video1 as the scene described: "
            ) + (_ref_legend(ref_pairs) + " " if ref_pairs else "")
            body["prompt"] = (
                lead + said +
                " CRITICAL: every frame, including the very first frame, must already show the fully finished scene."
                " Never show the original gray boxes at any moment. No transformation, no morphing, no reveal transition —"
                " the scene is finished from frame one to the last frame."
                " Hold every camera move and every object's motion from @Video1 on the same frames;"
                " do not change the timing, framing or composition."
            )
        # v0.6 — Wan 3.0 · Hailuo 3: 참조를 "Video 1" "Image N" 으로 지목한다(@ 표기가 아니다).
        if scene.arife_ai_model in REFV_MODELS and "Video 1" not in body["prompt"]:
            said = body["prompt"]
            legend = ""
            if ref_pairs:
                role = {"char": "a CHARACTER reference — keep this character's face, outfit, colors and proportions exactly",
                        "bg":   "an ENVIRONMENT reference — match this place: architecture, materials, lighting, era",
                        "fx":   "a VFX / MOOD reference — apply this kind of effect, atmosphere and color grade"}
                legend = " " + "; ".join(f"Image {i + 1} is {role[g]}" for i, (_, g) in enumerate(ref_pairs)) + "."
            body["prompt"] = (
                "Video 1 is a gray-box 3D workbench render that defines the exact camera path, timing and layout of the scene. "
                "Completely repaint Video 1 as the scene described: " + said + legend +
                " CRITICAL: every frame, including the first, must show the fully finished scene — no gray boxes, no untextured geometry, no reveal transition."
                " Keep every camera move and every object's motion of Video 1 on the same frames; do not change timing, framing or composition."
            )

        _state["busy"] = "ai"
        _state["upload_progress"] = 0.0
        _set_ai("준비 중…")

        def work():
            try:
                a = _ensure_token(auth)
                uid = _jwt_sub(a["access_token"])
                if not uid:
                    raise RuntimeError("토큰에서 사용자 id를 읽지 못했습니다. 다시 로그인해 주세요.")
                base = os.path.splitext(os.path.basename(filepath))[0] or "render"
                stamp = time.strftime("%Y%m%d-%H%M%S")
                object_path = f"{uid}/blender/{stamp}-{base}.mp4"
                quoted = urllib.parse.quote(object_path)

                # ① 렌더 원본 업로드 — AI 서버(fal)가 읽을 재료.
                #    보관함(media_assets)에는 넣지 않는다: 원본 클레이 렌더까지
                #    목록에 쌓이면 결과물 찾기가 어려워진다.
                _set_ai("① 렌더 업로드 중…")
                _upload_file_with_progress(
                    SUPABASE_URL + f"/storage/v1/object/{UPLOAD_BUCKET}/{quoted}",
                    filepath,
                    headers={
                        "apikey": a["anon"],
                        "Authorization": "Bearer " + a["access_token"],
                        "Content-Type": "video/mp4",
                        "x-upsert": "true",
                    },
                    progress_cb=lambda pr: _state.__setitem__("upload_progress", pr),
                )
                _state["upload_progress"] = -1.0

                # editor2-assets 는 공개 버킷이라 공개 주소를 그대로 쓴다.
                # 서명 URL은 뒤에 ?token= 이 붙어서 시댄스가 "지원 형식은
                # mp4, mov" 라며 튕긴다(실측) — 주소가 .mp4 로 끝나야 한다.
                body["video_url"] = SUPABASE_URL + f"/storage/v1/object/public/{UPLOAD_BUCKET}/{quoted}"
                # v0.6 — Wan 3.0 · Hailuo 3 는 렌더를 '참조 영상'으로 받는다(영상→영상 경로가 아니다).
                if body["model"] in REFV_MODELS:
                    body["reference_video_urls"] = [body.pop("video_url")]
                    body["mode"] = "text"

                # ①-1 참조 그림(직접 고른 것들) — 올려서 기준 그림으로 쓴다.
                # 첫 장은 image_url(기본 모델의 앵커), 전체는 image_urls(시댄스, 최대 9장).
                if ref_paths:
                    uploaded = []
                    for ri, rp in enumerate(ref_paths):
                        ext = os.path.splitext(rp)[1].lower() or ".png"
                        ctype = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}.get(ext, "image/png")
                        r_obj = urllib.parse.quote(f"{uid}/blender/{stamp}-ref{ri + 1}{ext}")
                        _set_ai(f"①-1 참조 그림 업로드 중… ({ri + 1}/{len(ref_paths)})")
                        _upload_file_with_progress(
                            SUPABASE_URL + f"/storage/v1/object/{UPLOAD_BUCKET}/{r_obj}",
                            rp,
                            headers={
                                "apikey": a["anon"],
                                "Authorization": "Bearer " + a["access_token"],
                                "Content-Type": ctype,
                                "x-upsert": "true",
                            },
                            progress_cb=lambda pr: None,
                        )
                        uploaded.append(SUPABASE_URL + f"/storage/v1/object/public/{UPLOAD_BUCKET}/{r_obj}")
                    body["image_url"] = uploaded[0]
                    if body["model"] in REFV_MODELS:
                        body["image_urls"] = uploaded            # 참조 경로는 image_urls 로 전부 받는다
                    elif len(uploaded) > 1:
                        body["image_urls"] = uploaded[1:]

                # ①-2 첫 장면 그리기(선택) — 시작 프레임을 seedream 으로 먼저
                # 칠해 앵커로 쓴다. 웹의 AI 블렌더 페이지가 하던 2단계와 동일.
                if anchor_png:
                    _set_ai(f"①-2 첫 장면 그리는 중… ({ANCHOR_COST}크레딧, 보통 1~2분)")
                    a_path = f"{uid}/blender/{stamp}-anchor.png"
                    a_quoted = urllib.parse.quote(a_path)
                    _upload_file_with_progress(
                        SUPABASE_URL + f"/storage/v1/object/{UPLOAD_BUCKET}/{a_quoted}",
                        anchor_png,
                        headers={
                            "apikey": a["anon"],
                            "Authorization": "Bearer " + a["access_token"],
                            "Content-Type": "image/png",
                            "x-upsert": "true",
                        },
                        progress_cb=lambda pr: None,
                    )
                    # 앵커 그림도 같은 이유로 공개 주소(.png 로 끝남)를 쓴다.
                    a_public = SUPABASE_URL + f"/storage/v1/object/public/{UPLOAD_BUCKET}/{a_quoted}"
                    img_job = _http(
                        "POST", API_BASE + "/arife-image/api/image/generate",
                        body={
                            "model": "seedream5pro",
                            "prompt": GEO_LOCK + body["prompt"],
                            "aspect_ratio": "16:9",
                            "resolution": "2K",
                            "quality": "quality",
                            "num_images": 1,
                            "image_url": a_public,
                        },
                        headers=_api_headers(a), timeout=120,
                    )
                    i_status = (img_job or {}).get("statusUrl") or ""
                    i_response = (img_job or {}).get("responseUrl") or ""
                    if not i_status or not i_response:
                        raise RuntimeError("첫 장면 그리기를 시작하지 못했습니다: " + str((img_job or {}).get("error") or img_job))
                    i_waited = 0
                    while True:
                        time.sleep(4)
                        i_waited += 4
                        ist = _http(
                            "GET",
                            API_BASE + "/arife-image/api/image/status?statusUrl="
                            + urllib.parse.quote(i_status, safe=""),
                            headers=_api_headers(a), timeout=60,
                        )
                        istat = str((ist or {}).get("status", "")).upper()
                        if istat == "COMPLETED":
                            break
                        if istat in ("FAILED", "ERROR"):
                            raise RuntimeError("첫 장면 그리기에 실패했습니다. 잠시 뒤 다시 시도해 주세요.")
                        if i_waited >= 300:
                            raise RuntimeError("첫 장면 그리기 응답이 너무 늦어 중단했습니다.")
                        _set_ai(f"①-2 첫 장면 그리는 중… {i_waited}초 경과")
                    iout = _http(
                        "GET",
                        API_BASE + "/arife-image/api/image/result?responseUrl="
                        + urllib.parse.quote(i_response, safe=""),
                        headers=_api_headers(a), timeout=120,
                    )
                    styled = ""
                    try:
                        styled = ((iout or {}).get("images") or [{}])[0].get("url") or ""
                    except Exception:
                        styled = ""
                    if not styled:
                        raise RuntimeError("그린 첫 장면 주소를 받지 못했습니다.")
                    body["image_url"] = styled
                    _set_ai("①-2 첫 장면 완성 — 이 그림이 영상의 기준이 됩니다.")

                # ② 생성 접수 — 여기서 크레딧이 차감된다(실패 시 서버가 자동 환불).
                _set_ai("② AI 영상 생성 요청 중…")
                job = _http(
                    "POST", API_BASE + "/arife-video/api/video/generate",
                    body=body, headers=_api_headers(a), timeout=120,
                )
                status_url = (job or {}).get("statusUrl") or ""
                response_url = (job or {}).get("responseUrl") or ""
                if not status_url or not response_url:
                    raise RuntimeError("영상 작업을 시작하지 못했습니다: " + str((job or {}).get("error") or job))
                cost = (job or {}).get("cost")

                # ③ 폴링 — 5초 간격. 15분 넘으면 포기(서버가 실패 시 환불 처리).
                waited = 0
                while True:
                    time.sleep(5)
                    waited += 5
                    st = _http(
                        "GET",
                        API_BASE + "/arife-video/api/video/status?statusUrl="
                        + urllib.parse.quote(status_url, safe=""),
                        headers=_api_headers(a), timeout=60,
                    )
                    stat = str((st or {}).get("status", "")).upper()
                    if stat == "COMPLETED":
                        break
                    if stat in ("FAILED", "ERROR"):
                        raise RuntimeError((st or {}).get("error") or "영상 생성에 실패했습니다. 크레딧은 자동 환불됩니다.")
                    if waited >= 900:
                        raise RuntimeError("응답이 너무 늦어 중단했습니다. 잠시 뒤 다시 시도해 주세요.")
                    _set_ai(f"③ AI 영상 만드는 중… {waited}초 경과 (보통 1~2분)")

                # ④ 결과 주소 → 임시 파일로 내려받기
                _set_ai("④ 완성본 내려받는 중…")
                out = _http(
                    "GET",
                    API_BASE + "/arife-video/api/video/result?responseUrl="
                    + urllib.parse.quote(response_url, safe=""),
                    headers=_api_headers(a), timeout=120,
                )
                vurl = (((out or {}).get("video") or {}).get("url")) or ""
                if not vurl:
                    raise RuntimeError("완성된 영상 주소를 받지 못했습니다. 잠시 뒤 다시 시도해 주세요.")
                dest = os.path.join(tempfile.gettempdir(), f"arife_ai_{stamp}.mp4")
                urllib.request.urlretrieve(vurl, dest)

                # ⑤ 보관함 저장 — fal의 결과 주소는 임시라 그대로 두면 사라진다.
                #    내려받은 사본을 우리 Storage에 다시 올리고 media_assets에
                #    줄을 넣어야 웹 보관함·편집기에서 계속 쓸 수 있다.
                _set_ai("⑤ 보관함에 저장하는 중…")
                archive_path = f"{uid}/blender/ai/{stamp}-ai.mp4"
                archive_quoted = urllib.parse.quote(archive_path)
                out_size = os.path.getsize(dest)
                _upload_file_with_progress(
                    SUPABASE_URL + f"/storage/v1/object/{UPLOAD_BUCKET}/{archive_quoted}",
                    dest,
                    headers={
                        "apikey": a["anon"],
                        "Authorization": "Bearer " + a["access_token"],
                        "Content-Type": "video/mp4",
                        "x-upsert": "true",
                    },
                    progress_cb=lambda pr: None,
                )
                _http(
                    "POST",
                    SUPABASE_URL + "/rest/v1/media_assets",
                    body={
                        "user_id": uid,
                        "bucket_id": UPLOAD_BUCKET,
                        "object_path": archive_path,
                        "kind": "output_video",
                        "mime_type": "video/mp4",
                        "title": "블렌더 AI 변환 " + time.strftime("%Y-%m-%d %H:%M"),
                        "source_tool": "blender",
                        "byte_size": out_size,
                    },
                    headers={
                        "apikey": a["anon"],
                        "Authorization": "Bearer " + a["access_token"],
                        "Prefer": "return=minimal",
                    },
                )

                # ⑥ VSE에 얹기 — bpy 조작이라 메인 스레드로 넘긴다.
                #    타이머 문맥에서는 bpy.context.scene 이 비어 있을 수 있어(실측 의심 지점)
                #    작업을 시작한 씬을 이름으로 다시 찾는다. 실패하면 이유 전체를
                #    시스템 콘솔(Window→Toggle System Console)에 남긴다.
                def apply():
                    try:
                        sc = bpy.data.scenes.get(scene_name) or getattr(bpy.context, "scene", None)
                        if sc is None:
                            raise RuntimeError("씬을 찾지 못했습니다 (" + scene_name + ")")
                        ch = _add_media_to_vse(sc, dest, os.path.basename(dest))
                        done = f"완료 — VSE 채널 {ch}에 얹었고 보관함에도 저장했습니다."
                        if cost is not None:
                            done += f" (차감 {cost}크레딧)"
                        _set_ai(done)
                    except Exception as e:
                        import traceback
                        traceback.print_exc()
                        _set_ai(f"완성본을 시퀀서에 얹지 못했습니다 [{type(e).__name__}] {e} — 파일은 저장됨: {dest}")
                _run_on_main(apply)
            except urllib.error.HTTPError as e:
                _set_ai("실패: " + _api_error(e))
            except Exception as e:
                _set_ai(f"실패: {e}")
            finally:
                _state["upload_progress"] = -1.0
                _state["busy"] = ""

        threading.Thread(target=work, daemon=True).start()
        return {"FINISHED"}


# ── 패널 ─────────────────────────────────────────────────────

# ═════════════════════════════════════════════════════════════
# v0.7 — AI 조종 (ARIFE MCP 서버가 넣은 명령을 받아 Blender 에서 실행) + 뷰포트 오버레이(심볼 · 입력 바)
# ─────────────────────────────────────────────────────────────
# 왜 소켓이 아니라 서버 줄(polling)인가
#   · blender-mcp 식 로컬 소켓은 Claude 쪽 설정 파일(uvx…)과 포트가 필요하다. ARIFE 는 이미 커넥터(OAuth)로
#     붙어 있으므로, 명령을 서버 표(public.blender_commands)에 넣고 애드온이 1초마다 읽어 가면 사용자 쪽엔
#     아무 설정도 필요 없다. 왕복 1~2초는 블록아웃 작업에서 체감이 없다(실측 예정).
#   · 표는 RLS 로 본인 줄만 보인다. 애드온은 로그인 토큰(사용자 JWT)으로 붙는다.
# 안전
#   · 코드 실행(run)은 사용자 파일을 직접 고친다 — 패널과 입력 바에 "먼저 저장" 경고를 박는다.
#   · AI 조종은 기본 꺼짐. 켠 동안만 폴링·심장박동(5초)을 보낸다. Blender 를 닫으면 자연히 꺼진다.
# 실행 흐름
#   스레드(네트워크)  → queued 명령 하나 집어 running 으로 표시 → _run_on_main(실행) → 결과를 _agent_out 큐에
#   → 스레드가 done/error 로 PATCH.  render 는 메인에서 렌더(동기, Blender 가 원래 그렇게 돈다) → 스레드가 업로드.
# ═════════════════════════════════════════════════════════════
import io as _io
import traceback as _tb
import contextlib as _ctx
import collections as _collections

AGENT_VERSION = ".".join(str(v) for v in bl_info["version"])
AGENT_POLL_SEC = 1.0
AGENT_BEAT_SEC = 5.0
GOLD = (0.780, 0.667, 0.522, 1.0)          # #C7AA85
INK  = (0.043, 0.059, 0.078, 0.90)         # #0b0f14
PANEL = (0.082, 0.110, 0.149, 0.92)        # #151c26
LINE = (0.165, 0.204, 0.259, 1.0)          # #2a3442
TXT  = (0.902, 0.922, 0.949, 1.0)          # #e6ebf2
DIM  = (0.604, 0.655, 0.722, 1.0)          # #9aa7b8
BLUE = (0.561, 0.722, 1.0, 1.0)            # #8fb8ff
GREEN = (0.349, 0.827, 0.541, 1.0)

# 심볼(골드) — arife-symbol-gold.svg 의 두 곡선을 41점씩 다각형으로 편 것(0..1, y 위쪽)
_SYM = [[(0.4837,0.8747),(0.466,0.8379),(0.4452,0.7946),(0.4219,0.7457),(0.3964,0.6922),(0.3694,0.6352),(0.3412,0.5757),(0.3125,0.5146),(0.2836,0.453),(0.2551,0.392),(0.2275,0.3324),(0.2135,0.3037),(0.1985,0.2766),(0.1825,0.2511),(0.1653,0.2274),(0.147,0.2054),(0.1275,0.1853),(0.1067,0.1672),(0.0847,0.1511),(0.0614,0.1371),(0.0368,0.1253),(0.0657,0.1273),(0.0952,0.1336),(0.1251,0.1434),(0.1553,0.156),(0.1858,0.1705),(0.2164,0.1862),(0.247,0.2023),(0.2775,0.218),(0.3079,0.2326),(0.3379,0.2452),(0.3533,0.2511),(0.3684,0.2563),(0.3831,0.261),(0.3975,0.2652),(0.4114,0.2687),(0.4249,0.2717),(0.4378,0.2741),(0.4501,0.276),(0.4618,0.2772),(0.4728,0.2779)],
        [(0.5163,0.8747),(0.534,0.8379),(0.5548,0.7946),(0.5781,0.7457),(0.6036,0.6922),(0.6306,0.6352),(0.6588,0.5757),(0.6875,0.5146),(0.7164,0.453),(0.7449,0.392),(0.7725,0.3324),(0.7865,0.3037),(0.8015,0.2766),(0.8175,0.2511),(0.8347,0.2274),(0.853,0.2054),(0.8725,0.1853),(0.8933,0.1672),(0.9153,0.1511),(0.9386,0.1371),(0.9632,0.1253),(0.9343,0.1273),(0.9048,0.1336),(0.8749,0.1434),(0.8447,0.156),(0.8142,0.1705),(0.7836,0.1862),(0.753,0.2023),(0.7225,0.218),(0.6921,0.2326),(0.6621,0.2452),(0.6467,0.2511),(0.6316,0.2563),(0.6169,0.261),(0.6025,0.2652),(0.5886,0.2687),(0.5751,0.2717),(0.5622,0.2741),(0.5499,0.276),(0.5382,0.2772),(0.5272,0.2779)]]

_agent = {
    "on": False,            # AI 조종 켜짐
    "thread": None,
    "stop": False,
    "last_beat": 0.0,       # 마지막 심장박동 시각
    "last_cmd": 0.0,        # 마지막 명령 시각
    "current": "",          # 실행 중 명령 한 줄
    "log": _collections.deque(maxlen=12),
    "info": {},             # 메인 스레드가 채우는 씬 정보(스레드는 읽기만)
    "error": "",
    "busy_render": False,
    "block_cmd": "",        # 사이트에서 온 block 명령 id — 진행 로그를 그 줄에 적는다
    "block_sent": 0,        # 마지막으로 보낸 로그 줄 수
}
_agent_out = queue.Queue()   # (cmd_id, ok, result) — 메인 → 스레드
_bar = {"show": True, "hover": "", "hits": {}, "modal": False, "pulse": 0.0, "msg": "",
        # v0.9.14 — 바 안에서 바로 타이핑(영문). {"text":str,"caret":int,"scroll":int} 또는 None
        "edit": None,
        # v0.8.1 — 열고 닫기 애니메이션: target 0/1, pos 는 0..1 로 따라간다(0.28초, ease)
        "target": 0.0, "pos": 0.0, "anim_from": 0.0, "anim_t0": 0.0, "anim_dur": 0.28,
        # v0.8.5 — N 패널 내용을 바 안 탭으로: 만들기 · 연결 · 보관함
        "tab": "make"}


def _bar_set_open(open_, animate=True):
    """아래 입력 바 열기/닫기 — 그리기 쪽이 pos 를 target 으로 부드럽게 옮긴다."""
    tgt = 1.0 if open_ else 0.0
    if _bar["target"] == tgt and not animate:
        _bar["pos"] = tgt; return
    _bar["anim_from"] = _bar["pos"]
    _bar["anim_t0"] = time.time()
    _bar["target"] = tgt
    if not animate: _bar["pos"] = tgt
    _bar["pulse"] = time.time()
    # 움직이는 동안만 촘촘히 다시 그린다
    if not bpy.app.timers.is_registered(_bar_anim_tick):
        bpy.app.timers.register(_bar_anim_tick, first_interval=0.0)


def _edit_tick():
    """v0.9.14 — 글자 커서(캐럿) 깜빡임: 편집 중에만 돈다."""
    if _bar.get("edit") is None:
        return None
    _redraw()
    return 0.12


def _bar_anim_tick():
    _redraw()
    return None if abs(_bar["pos"] - _bar["target"]) < 0.001 else 1 / 60


def _bar_progress():
    """지금 pos 를 계산해 갱신하고 돌려준다(그리기에서 부른다)."""
    tgt, frm = _bar["target"], _bar["anim_from"]
    if abs(_bar["pos"] - tgt) < 0.001:
        _bar["pos"] = tgt; return tgt
    k = min(1.0, (time.time() - _bar["anim_t0"]) / _bar["anim_dur"])
    # 열릴 때는 튀어나오듯(ease-out back 살짝), 닫힐 때는 빨리 빠지듯(ease-in)
    if tgt > frm:
        c1 = 1.2; e = 1 + (c1 + 1) * (k - 1) ** 3 + c1 * (k - 1) ** 2
    else:
        e = k * k
    _bar["pos"] = frm + (tgt - frm) * e
    return _bar["pos"]


def _agent_log(line):
    _agent["log"].append(time.strftime("%H:%M:%S ") + line)
    print("[ARIFE·AI]", line)


# ── 씬 요약 · 코드 실행 · 렌더 (전부 메인 스레드) ──────────────
def _scene_summary():
    s = bpy.context.scene
    objs = []
    for o in list(bpy.data.objects)[:200]:
        d = {
            "name": o.name, "type": o.type,
            "location": [round(v, 2) for v in o.matrix_world.translation],
            "dimensions": [round(v, 2) for v in o.dimensions],
        }
        if o.parent: d["parent"] = o.parent.name
        if o.animation_data and o.animation_data.action: d["animated"] = True
        if o.type == "CAMERA": d["lens_mm"] = round(o.data.lens, 1)
        objs.append(d)
    cam = s.camera.name if s.camera else None
    r = s.render
    return {
        "file": bpy.data.filepath or "(저장 안 됨)", "scene": s.name, "camera": cam,
        "frame_start": s.frame_start, "frame_end": s.frame_end, "frame_current": s.frame_current, "fps": r.fps,
        "resolution": [r.resolution_x, r.resolution_y, r.resolution_percentage], "engine": r.engine,
        "object_count": len(bpy.data.objects), "objects": objs,
        "selected": [o.name for o in bpy.context.selected_objects][:50],
        "blender": bpy.app.version_string, "addon": AGENT_VERSION,
    }


# ── 아리페 촬영·배치·움직임 키트 (v0.9.14) ───────────────────────────
#   blender_run 코드 안에서 `arife.` 로 바로 쓴다. 두뇌(LLM)가 카메라·움직임을
#   매번 손으로 키프레임 짜면 모델이 좋아도 결과가 들쭉날쭉해서, 검증된 재료를
#   함수로 두고 두뇌는 "무엇을 어떻게 찍을지"만 고르게 한다.
#   · 프레임 인자는 정수 프레임. 초로 주고 싶으면 arife.f(초).
#   · 앞 방향(forward): 가져온 GLB 는 대개 -Y 를 앞으로 본다. arife.lay.car 도 -Y 가 앞.
#     기본 forward='-Y'. 직접 만든 상자가 +Y 가 앞이면 forward='+Y'.
#   · 모든 함수는 만든 물체(또는 마지막 프레임)를 돌려준다.
import math as _math
import random as _random
from mathutils import Vector as _Vec, Matrix as _Mat


class _NS:
    pass


def _fcurves(obj):
    ad = obj.animation_data
    if not ad or not ad.action:
        return []
    act = ad.action
    try:
        fcs = getattr(act, "fcurves", None)
        if fcs is not None:
            return list(fcs)
    except Exception:
        pass
    try:
        return list(act.layers[0].strips[0].channelbags[0].fcurves)
    except Exception:
        return []


def _interp(obj, mode="BEZIER", paths=None):
    for fc in _fcurves(obj):
        if paths and fc.data_path not in paths:
            continue
        for kp in fc.keyframe_points:
            kp.interpolation = mode


def _scene():
    return bpy.context.scene


def _fps():
    return _scene().render.fps or 24


def _fr(start, end):
    s = _scene()
    return (s.frame_start if start is None else int(start)), (s.frame_end if end is None else int(end))


def _obj(o):
    if isinstance(o, str):
        return bpy.data.objects.get(o)
    return o


def _vec(v):
    if hasattr(v, "matrix_world"):
        return v.matrix_world.translation.copy()
    return _Vec(v)


def _fwd(forward):
    return {"+Y": _Vec((0, 1, 0)), "-Y": _Vec((0, -1, 0)), "+X": _Vec((1, 0, 0)), "-X": _Vec((-1, 0, 0))}.get(str(forward).upper(), _Vec((0, -1, 0)))


def _yaw_for(d, forward):
    """진행 방향 d(월드) 를 볼 때 z 회전각 — forward 축이 d 를 향하게."""
    base = _math.atan2(d.y, d.x)
    off = {"+X": 0.0, "+Y": -_math.pi / 2, "-X": _math.pi, "-Y": _math.pi / 2}.get(str(forward).upper(), _math.pi / 2)
    return base + off


def _spline(points, n, smooth=True):
    """점 목록 → 길이가 고른 n 개 표본(Catmull-Rom). 점이 2개면 직선."""
    P = [_Vec(p) if not hasattr(p, "matrix_world") else _vec(p) for p in points]
    if len(P) == 1:
        return [P[0].copy() for _ in range(n)], 0.0
    dense = []
    if smooth and len(P) >= 3:
        ext = [P[0]] + P + [P[-1]]
        for i in range(1, len(ext) - 2):
            p0, p1, p2, p3 = ext[i - 1], ext[i], ext[i + 1], ext[i + 2]
            for k in range(40):
                t = k / 40.0
                dense.append(0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t * t + (-p0 + 3 * p1 - 3 * p2 + p3) * t * t * t))
        dense.append(P[-1].copy())
    else:
        for i in range(len(P) - 1):
            for k in range(40):
                dense.append(P[i].lerp(P[i + 1], k / 40.0))
        dense.append(P[-1].copy())
    cum = [0.0]
    for i in range(1, len(dense)):
        cum.append(cum[-1] + (dense[i] - dense[i - 1]).length)
    L = cum[-1] or 1e-6
    out = []
    j = 0
    for i in range(n):
        target = L * i / max(1, n - 1)
        while j < len(cum) - 2 and cum[j + 1] < target:
            j += 1
        seg = (cum[j + 1] - cum[j]) or 1e-9
        t = min(1.0, max(0.0, (target - cum[j]) / seg))
        out.append(dense[j].lerp(dense[j + 1], t))
    return out, L


def _path_len(points, smooth=True):
    return _spline(points, 2, smooth)[1] if len(points) > 1 else 0.0


def _key(obj, path, frame):
    obj.keyframe_insert(data_path=path, frame=int(frame))


def _clear(obj, paths=("location", "rotation_euler"), start=None, end=None, mods=True):
    """구간 안의 기존 키(와 노이즈 모디파이어)를 지운다 — 같은 카메라를 다시 찍을 때 옛 키가 섞이지 않게."""
    s, e = _fr(start, end)
    for fc in _fcurves(obj):
        if fc.data_path not in paths: continue
        if mods:
            for m in list(fc.modifiers): fc.modifiers.remove(m)
        for kp in reversed(list(fc.keyframe_points)):
            if s <= kp.co.x <= e:
                try: fc.keyframe_points.remove(kp)
                except Exception: pass


def _make_kit():
    K = _NS(); K.cam = _NS(); K.move = _NS(); K.lay = _NS()

    # ── 공통 ──────────────────────────────────────────
    def f(seconds):
        """초 → 프레임(정수)."""
        return int(round(float(seconds) * _fps()))
    K.f = f

    def scene(seconds=None, fps=None, start=1):
        """장면 길이 맞추기. arife.scene(5) → 1~120프레임(24fps). (start, end) 를 돌려준다."""
        s = _scene()
        if fps: s.render.fps = int(fps)
        s.frame_start = int(start)
        if seconds is not None: s.frame_end = int(start) + f(seconds) - 1
        s.frame_current = s.frame_start
        return s.frame_start, s.frame_end
    K.scene = scene

    def obj(name):
        return bpy.data.objects.get(name)
    K.obj = obj

    def mat(o, color, name=None, rough=0.6, metal=0.0, emit=0.0):
        """색·재질. 워크벤치(OBJECT 색)·EEVEE 둘 다 보이게 obj.color 와 노드 재질을 같이 넣는다. 자식에도 적용."""
        o = _obj(o)
        col = tuple(float(c) for c in color[:3])
        m = bpy.data.materials.new(name or ("ARIFE_M_" + o.name))
        m.diffuse_color = (*col, 1.0); m.roughness = rough; m.metallic = metal
        m.use_nodes = True
        b = m.node_tree.nodes.get("Principled BSDF")
        if b:
            b.inputs["Base Color"].default_value = (*col, 1.0)
            b.inputs["Roughness"].default_value = rough
            b.inputs["Metallic"].default_value = metal
            if emit:
                for nm in ("Emission Color", "Emission"):
                    if nm in b.inputs: b.inputs[nm].default_value = (*col, 1.0); break
                if "Emission Strength" in b.inputs: b.inputs["Emission Strength"].default_value = float(emit)
        for x in [o] + list(o.children_recursive):
            x.color = (*col, 1.0)
            if x.type == "MESH":
                x.data.materials.clear(); x.data.materials.append(m)
        return m
    K.mat = mat

    def look(camera, target):
        """카메라(또는 아무 물체)가 target 을 계속 바라보게(TRACK_TO). 기존 TRACK_TO 는 지운다."""
        c = _obj(camera); t = _obj(target)
        for con in list(c.constraints):
            if con.type == "TRACK_TO": c.constraints.remove(con)
        con = c.constraints.new("TRACK_TO"); con.target = t; con.track_axis = "TRACK_NEGATIVE_Z"; con.up_axis = "UP_Y"
        return con
    K.look = look

    def unlook(camera):
        c = _obj(camera)
        for con in list(c.constraints):
            if con.type == "TRACK_TO": c.constraints.remove(con)
    K.unlook = unlook

    def ease(o, mode="BEZIER"):
        """키프레임 보간: 'LINEAR'(등속) · 'BEZIER'(부드럽게)."""
        _interp(_obj(o), mode)
    K.ease = ease

    def noise(o, path="location", strength=0.05, scale=12.0, start=None, end=None):
        """흔들림(NOISE 모디파이어). 키가 없으면 지금 값으로 키 하나 넣고 건다."""
        o = _obj(o)
        if not _fcurves(o): _key(o, path, _scene().frame_start)
        s, e = _fr(start, end)
        for fc in _fcurves(o):
            if fc.data_path != path: continue
            mod = fc.modifiers.new("NOISE"); mod.strength = float(strength); mod.scale = float(scale); mod.phase = fc.array_index * 7.0
            mod.use_restricted_range = True; mod.frame_start = s; mod.frame_end = e; mod.blend_in = 4; mod.blend_out = 4
    K.noise = noise

    # ── 카메라 ────────────────────────────────────────
    def cam_get(create=True, lens=None, name="ARIFE_Cam"):
        """장면 카메라. 없으면 만든다(새로 만들 때만 35mm). lens 를 주면 바꾼다."""
        s = _scene()
        c = s.camera
        if c is None and create:
            d = bpy.data.cameras.new(name); c = bpy.data.objects.new(name, d); s.collection.objects.link(c); s.camera = c
            c.location = (0, -12, 5); c.rotation_euler = (_math.radians(70), 0, 0); d.lens = 35.0
        if c is not None:
            if lens: c.data.lens = float(lens)
            if c.data.clip_end < 1000: c.data.clip_end = 2000.0   # 큰 장면(빌딩 숲)이 잘리지 않게
        return c
    K.cam.get = cam_get

    def blocked(cam_loc, target, frame=None, ignore=(), ignore_moving=True):
        """cam_loc 에서 target(물체/좌표)까지 시선을 가리는 물체 이름(없으면 None). frame 을 주면 그 프레임에서. ignore_moving=True 면 지나가는 차·사람은 안 침."""
        sc = _scene(); cur = sc.frame_current
        if frame is not None: sc.frame_set(int(frame))
        try:
            o = _Vec(cam_loc); tpos = _vec(_obj(target)) if isinstance(target, str) else _vec(target)
            d = tpos - o; L = d.length
            if L < 1e-6: return None
            dg = bpy.context.evaluated_depsgraph_get()
            hit, loc, _n, _i, ob, _m = sc.ray_cast(dg, o, d.normalized(), distance=L - 0.5)
            if not hit or ob is None: return None
            root = ob
            while root.parent is not None: root = root.parent
            if ignore_moving and root.animation_data and root.animation_data.action: return None   # 지나가는 차·사람은 가림으로 안 침
            names = set(ignore)
            tgt = _obj(target) if (isinstance(target, str) or hasattr(target, "matrix_world")) else None
            if tgt is not None: names |= {tgt.name} | {x.name for x in tgt.children_recursive}
            return None if ob.name in names else ob.name
        finally:
            sc.frame_set(cur)
    K.cam.blocked = blocked

    def cam_place(loc, look_at=None, lens=None, frame=None, until=None, avoid=True):
        """카메라 고정 배치. look_at 은 물체나 좌표. frame 을 주면 그 프레임에 키, until 까지 주면 그 구간의 옛 키를 지우고 양 끝에 키(고정 샷 구간). avoid=True 면 목표까지 시선이 막혔을 때 위로 올려 보고, 그래도 막히면 경고를 print 한다(실측: 빌딩 안에 카메라를 둔 적 있음)."""
        c = cam_get(True, lens or 0)
        pos = _Vec(loc)
        if avoid and look_at is not None:
            b = blocked(pos, look_at, frame)
            tries = 0
            while b and tries < 5:
                pos.z += 2.0; tries += 1; b = blocked(pos, look_at, frame)
            if b: print(f"[arife.cam.place] 경고: 카메라 {tuple(round(v, 1) for v in loc)} 에서 목표까지 '{b}' 가 가립니다 — 위치를 바꾸세요")
            elif tries: print(f"[arife.cam.place] 시선이 막혀 카메라를 {tries * 2}m 올렸습니다 → z={round(pos.z, 1)}")
        c.location = pos
        if look_at is not None:
            if hasattr(look_at, "matrix_world"): look(c, look_at)
            else:
                unlook(c)
                d = _Vec(look_at) - c.location
                c.rotation_euler = d.to_track_quat("-Z", "Y").to_euler()
        if frame is not None:
            _clear(c, ("location", "rotation_euler"), frame, until if until is not None else frame)   # 구간의 옛 키(추격 등)가 섞이지 않게
            c.location = pos
            for fr in ([frame] + ([until] if until is not None and until != frame else [])):
                _key(c, "location", fr)
                if not hasattr(look_at, "matrix_world"): _key(c, "rotation_euler", fr)
        return c
    K.cam.place = cam_place

    def cam_follow(target, behind=8.0, up=3.0, side=0.0, start=None, end=None, lag=0.15, forward="-Y", look=True, step=3, lens=None):
        """추격 카메라: target 을 따라가며 뒤(behind)·위(up)·옆(side) 만큼 떨어져 찍는다. lag 는 초(뒤늦게 따라오는 느낌)."""
        t = _obj(target); c = cam_get(True, lens or 0)
        s, e = _fr(start, end); lagf = int(round(lag * _fps())); _clear(c, ("location", "rotation_euler"), s, e)
        fwd = _fwd(forward)
        sc = _scene(); cur = sc.frame_current
        for fr in list(range(s, e + 1, max(1, step))) + [e]:
            f0 = max(sc.frame_start, fr - lagf)
            # 앞 방향: 물체가 움직이면 "가는 방향"(실측: 회전 없이 +Y 로 밀어 둔 차를 forward 축으로 잡으면 카메라가 앞에 감), 멈춰 있으면 forward 축
            sc.frame_set(max(sc.frame_start, f0 - 2)); p_prev = t.matrix_world.translation.copy()
            sc.frame_set(f0)
            mw = t.matrix_world; pos = mw.translation.copy(); rot = mw.to_quaternion()
            vel = pos - p_prev; vel.z = 0
            if vel.length > 0.02: wf = vel
            else:
                wf = (rot @ fwd); wf.z = 0
                if wf.length < 1e-6: wf = fwd.copy()
            wf.normalize(); wr = _Vec((wf.y, -wf.x, 0))
            c.location = pos - wf * behind + wr * side + _Vec((0, 0, up))
            _key(c, "location", fr)
        sc.frame_set(cur)
        if look: K.look(c, t)
        # 시선 점검(네 군데 표본) — 가리는 물체가 있으면 알려 준다
        warn = []
        for q in (0.1, 0.4, 0.7, 0.95):
            fq = int(s + (e - s) * q); sc.frame_set(fq); cp = c.matrix_world.translation.copy(); sc.frame_set(cur)
            b = blocked(cp, t, fq, ignore_moving=False)   # 추격 카메라는 같이 달리는 차가 계속 가리면 문제 → 움직이는 것도 친다
            if b: warn.append((fq, b))
        if len(warn) >= 2: print("[arife.cam.follow] 경고: 시선이 막히는 프레임 " + ", ".join(f"{f}:{n}" for f, n in warn) + " — up 을 키우거나(3~4) side 를 늘리거나 behind 를 바꾸세요")
        return c
    K.cam.follow = cam_follow

    def cam_orbit(target, radius=8.0, height=3.0, deg_from=0.0, deg_to=360.0, start=None, end=None, look=True, step=2, lens=None):
        """target 둘레를 도는 카메라(아크·360). deg 는 +X 축 기준 반시계."""
        t = _obj(target); c = cam_get(True, lens or 0)
        s, e = _fr(start, end); _clear(c, ("location", "rotation_euler"), s, e); sc = _scene(); cur = sc.frame_current
        for fr in list(range(s, e + 1, max(1, step))) + [e]:
            u = (fr - s) / max(1, e - s); a = _math.radians(deg_from + (deg_to - deg_from) * u)
            sc.frame_set(fr); pos = t.matrix_world.translation.copy()
            c.location = pos + _Vec((radius * _math.cos(a), radius * _math.sin(a), height))
            _key(c, "location", fr)
        sc.frame_set(cur)
        if look: K.look(c, t)
        _interp(c, "LINEAR", {"location"})
        return c
    K.cam.orbit = cam_orbit

    def cam_dolly(frm, to, start=None, end=None, look_at=None, easing="BEZIER", lens=None):
        """카메라 직선 이동(밀고 들어가기·빠지기·옆으로 트래킹·크레인 상승 모두). look_at 물체를 주면 계속 바라본다."""
        c = cam_get(True, lens or 0); s, e = _fr(start, end); _clear(c, ("location", "rotation_euler"), s, e)
        if look_at is not None and hasattr(look_at, "matrix_world"): K.look(c, look_at)
        elif look_at is not None: unlook(c)
        for fr, p in ((s, frm), (e, to)):
            c.location = _Vec(p); _key(c, "location", fr)
            if look_at is not None and not hasattr(look_at, "matrix_world"):
                d = _Vec(look_at) - c.location; c.rotation_euler = d.to_track_quat("-Z", "Y").to_euler(); _key(c, "rotation_euler", fr)
        _interp(c, easing, {"location", "rotation_euler"})
        return c
    K.cam.dolly = cam_dolly
    K.cam.crane = cam_dolly

    def cam_path(points, start=None, end=None, look_at=None, smooth=True, easing="LINEAR", lens=None, step=2):
        """카메라가 점들을 잇는 곡선을 따라간다. look_at 없으면 진행 방향을 본다."""
        c = cam_get(True, lens or 0); s, e = _fr(start, end); _clear(c, ("location", "rotation_euler"), s, e)
        n = max(2, (e - s) // max(1, step) + 1)
        pts, _L = _spline(points, n, smooth)
        if look_at is not None and hasattr(look_at, "matrix_world"): K.look(c, look_at)
        else: unlook(c)
        for i, p in enumerate(pts):
            fr = s + i * step if i < n - 1 else e
            c.location = p; _key(c, "location", fr)
            if look_at is None:
                d = (pts[min(i + 1, n - 1)] - pts[max(i - 1, 0)])
                if d.length > 1e-6: c.rotation_euler = d.to_track_quat("-Z", "Y").to_euler(); _key(c, "rotation_euler", fr)
            elif not hasattr(look_at, "matrix_world"):
                d = _Vec(look_at) - p; c.rotation_euler = d.to_track_quat("-Z", "Y").to_euler(); _key(c, "rotation_euler", fr)
        _interp(c, easing, {"location", "rotation_euler"})
        return c
    K.cam.path = cam_path

    def cam_handheld(strength=0.03, rot_deg=0.5, scale=10.0, start=None, end=None):
        """손떨림. 카메라에 위치·회전 노이즈를 건다(추격·오비트 뒤에 얹어도 됨)."""
        c = cam_get(True, 0)
        for fc in _fcurves(c):
            for m in list(fc.modifiers):
                if m.type == "NOISE": fc.modifiers.remove(m)
        if not any(fc.data_path == "rotation_euler" for fc in _fcurves(c)): _key(c, "rotation_euler", _scene().frame_start)
        K.noise(c, "location", strength, scale, start, end)
        K.noise(c, "rotation_euler", _math.radians(rot_deg), scale * 0.8, start, end)
        return c
    K.cam.handheld = cam_handheld

    def cam_lens(mm, start=None, end=None, to=None):
        """렌즈(mm). to 를 주면 start→end 동안 줌(mm→to). 24=넓게 · 35=보통 · 85=망원."""
        c = cam_get(True, 0)
        if to is None:
            c.data.lens = float(mm); return c
        s, e = _fr(start, end)
        c.data.lens = float(mm); c.data.keyframe_insert(data_path="lens", frame=s)
        c.data.lens = float(to); c.data.keyframe_insert(data_path="lens", frame=e)
        return c
    K.cam.lens = cam_lens

    def cam_shake(frame, strength=0.25, length=8):
        """충격 흔들림(폭발·착지). frame 부터 length 프레임 동안 튄다."""
        c = cam_get(True, 0)
        base = c.location.copy(); rnd = _random.Random(int(frame))
        _key(c, "location", frame - 1)
        for i in range(int(length)):
            k = strength * (1 - i / float(length))
            c.location = base + _Vec((rnd.uniform(-k, k), rnd.uniform(-k, k), rnd.uniform(-k, k) * 0.5)); _key(c, "location", frame + i)
        c.location = base; _key(c, "location", frame + length)
        return c
    K.cam.shake = cam_shake

    # ── 움직임 ────────────────────────────────────────
    def move_to(o, loc, start=None, end=None, face=True, forward="-Y", easing="LINEAR"):
        """직선 이동. face=True 면 가는 방향을 보게 z 회전."""
        o = _obj(o); s, e = _fr(start, end); _clear(o, ("location", "rotation_euler"), s, e)
        a = o.location.copy(); b = _Vec(loc)
        if face and (b - a).length > 1e-6:
            o.rotation_euler.z = _yaw_for(b - a, forward); _key(o, "rotation_euler", s)
        o.location = a; _key(o, "location", s)
        o.location = b; _key(o, "location", e)
        _interp(o, easing, {"location"})
        return o
    K.move.to = move_to

    def move_path(o, points, start=None, end=None, face=True, forward="-Y", smooth=True, easing="LINEAR", step=2, bank=0.0):
        """점들을 잇는 곡선을 따라 이동(등속). bank(도) 를 주면 커브에서 기운다."""
        o = _obj(o); s, e = _fr(start, end); _clear(o, ("location", "rotation_euler"), s, e)
        n = max(2, (e - s) // max(1, step) + 1)
        pts, _L = _spline(points, n, smooth)
        prev_yaw = None
        for i, p in enumerate(pts):
            fr = s + i * step if i < n - 1 else e
            o.location = p; _key(o, "location", fr)
            if face:
                d = pts[min(i + 1, n - 1)] - pts[max(i - 1, 0)]
                if d.length > 1e-6:
                    yaw = _yaw_for(d, forward)
                    if prev_yaw is not None:
                        while yaw - prev_yaw > _math.pi: yaw -= 2 * _math.pi
                        while yaw - prev_yaw < -_math.pi: yaw += 2 * _math.pi
                    roll = 0.0
                    if bank and prev_yaw is not None: roll = max(-1, min(1, (yaw - prev_yaw) * 8)) * _math.radians(bank)
                    o.rotation_euler = (0.0, roll if str(forward).upper().endswith("Y") else 0.0, yaw)
                    prev_yaw = yaw
                    _key(o, "rotation_euler", fr)
        _interp(o, easing, {"location", "rotation_euler"})
        return o
    K.move.path = move_path

    def move_drive(o, points, speed=15.0, start=None, forward="-Y", wheels="auto", wheel_radius=0.35, smooth=True, bank=0.0):
        """차·사람이 길을 따라 등속(speed m/s)으로 달린다. 끝 프레임을 돌려준다(장면보다 길면 잘림). wheels='auto' 면 이름에 '_W' 가 든 자식 바퀴를 굴린다."""
        o = _obj(o); s = _scene().frame_start if start is None else int(start)
        L = _path_len(points, smooth)
        frames = max(2, int(round(L / max(0.1, float(speed)) * _fps())))
        e = s + frames
        move_path(o, points, s, e, True, forward, smooth, "LINEAR", 2, bank)
        ws = []
        if wheels == "auto": ws = [c for c in o.children_recursive if "_W" in c.name]
        elif wheels: ws = [_obj(w) for w in wheels]
        sign = 1.0 if str(forward).upper() in ("-Y", "+X") else -1.0
        for w in ws:
            _clear(w, ("rotation_euler",), s, e)
            w.rotation_euler.x = 0.0; _key(w, "rotation_euler", s)
            w.rotation_euler.x = sign * L / max(0.05, wheel_radius); _key(w, "rotation_euler", e)
            _interp(w, "LINEAR", {"rotation_euler"})
        return e
    K.move.drive = move_drive

    def move_spin(o, axis="Z", turns=1.0, start=None, end=None):
        """제자리 회전."""
        o = _obj(o); s, e = _fr(start, end); i = "XYZ".index(axis.upper())
        _key(o, "rotation_euler", s)
        o.rotation_euler[i] += _math.pi * 2 * float(turns); _key(o, "rotation_euler", e)
        _interp(o, "LINEAR", {"rotation_euler"})
        return o
    K.move.spin = move_spin

    def move_jump(o, height=1.5, start=None, end=None, forward_by=(0, 0, 0)):
        """점프(포물선). forward_by 만큼 앞으로도 간다."""
        o = _obj(o); s, e = _fr(start, end); _clear(o, ("location",), s, e); a = o.location.copy(); b = a + _Vec(forward_by)
        for k in range(0, 11):
            u = k / 10.0; fr = int(round(s + (e - s) * u))
            o.location = a.lerp(b, u) + _Vec((0, 0, 4 * height * u * (1 - u))); _key(o, "location", fr)
        _interp(o, "BEZIER", {"location"})
        return o
    K.move.jump = move_jump

    def move_bob(o, amp=0.08, period=0.5, start=None, end=None, axis="Z"):
        """들썩임(달리기 몸통·물결). 사이클 노이즈 대신 사인 키."""
        o = _obj(o); s, e = _fr(start, end); _clear(o, ("location",), s, e, mods=False); i = "XYZ".index(axis.upper()); base = o.location[i]
        stepf = max(1, int(round(period * _fps() / 4)))
        for fr in range(s, e + 1, stepf):
            o.location[i] = base + amp * _math.sin((fr - s) / (period * _fps()) * 2 * _math.pi); _key(o, "location", fr)
        o.location[i] = base
        return o
    K.move.bob = move_bob

    def move_hold(o, frame):
        """지금 위치·회전을 frame 에 키(멈춤·시작점 고정)."""
        o = _obj(o); _key(o, "location", frame); _key(o, "rotation_euler", frame); return o
    K.move.hold = move_hold

    # ── 배치 ──────────────────────────────────────────
    def _link(o):
        _scene().collection.objects.link(o); return o

    def lay_prim(kind="cube", name="ARIFE_Obj", size=(1, 1, 1), loc=(0, 0, 0), color=None, rot_z=0.0, on_ground=True):
        """기본 도형. size=(x,y,z) 미터. on_ground=True 면 loc 의 z 가 바닥(밑면)이다."""
        sz = _Vec(size) if not isinstance(size, (int, float)) else _Vec((size, size, size))
        k = kind.lower()
        if k == "cube": bpy.ops.mesh.primitive_cube_add(size=1.0)
        elif k in ("cyl", "cylinder"): bpy.ops.mesh.primitive_cylinder_add(radius=0.5, depth=1.0, vertices=24)
        elif k == "sphere": bpy.ops.mesh.primitive_uv_sphere_add(radius=0.5, segments=24, ring_count=12)
        elif k == "cone": bpy.ops.mesh.primitive_cone_add(radius1=0.5, depth=1.0, vertices=16)
        elif k == "plane": bpy.ops.mesh.primitive_plane_add(size=1.0)
        else: bpy.ops.mesh.primitive_cube_add(size=1.0)
        o = bpy.context.active_object; o.name = name
        o.scale = (sz.x, sz.y, sz.z if k != "plane" else 1.0)
        z = float(loc[2]) + (sz.z / 2.0 if (on_ground and k != "plane") else 0.0)
        o.location = (float(loc[0]), float(loc[1]), z); o.rotation_euler.z = _math.radians(rot_z)
        if color is not None: K.mat(o, color)
        return o
    K.lay.prim = lay_prim

    def lay_box(name, size=(1, 1, 1), loc=(0, 0, 0), color=None, rot_z=0.0):
        return lay_prim("cube", name, size, loc, color, rot_z)
    K.lay.box = lay_box

    def lay_ground(size=300.0, color=(0.22, 0.22, 0.22), name="ARIFE_Ground"):
        return lay_prim("plane", name, (size, size, 1), (0, 0, 0), color)
    K.lay.ground = lay_ground

    def lay_road(length=200.0, width=8.0, loc=(0, 0, 0), rot_z=0.0, color=(0.12, 0.12, 0.13), lanes=2, marks=True, name="ARIFE_Road"):
        """길(-Y→+Y 방향, rot_z 로 돌림). 차선 점선까지 한 메시로 만든다. 도로 물체를 돌려준다."""
        r = lay_prim("plane", name, (width, length, 1), (loc[0], loc[1], loc[2] + 0.01), color, rot_z)
        if marks and lanes >= 2:
            verts = []; faces = []; hw = width / 2.0
            xs = [-hw + width * i / lanes for i in range(1, lanes)]
            y = -length / 2.0
            while y < length / 2.0:
                for x in xs:
                    b = len(verts)
                    verts += [(x - 0.08, y, 0.02), (x + 0.08, y, 0.02), (x + 0.08, y + 3.0, 0.02), (x - 0.08, y + 3.0, 0.02)]
                    faces.append((b, b + 1, b + 2, b + 3))
                y += 6.0
            m = bpy.data.meshes.new(name + "_Marks"); m.from_pydata(verts, [], faces); m.update()
            mk = bpy.data.objects.new(name + "_Marks", m); _link(mk); mk.parent = r
            mk.matrix_parent_inverse = _Mat.Identity(4); mk.scale = (1.0 / max(1e-6, r.scale.x), 1.0 / max(1e-6, r.scale.y), 1.0)
            K.mat(mk, (0.9, 0.9, 0.8))
        return r
    K.lay.road = lay_road

    def _prism(name, w, l, z0, z1, top_w=None, top_y=None, front_drop=0.0, color=None):
        """사다리꼴 기둥(차체·캐빈용). 밑면 w×l(y 는 -l/2..l/2), 윗면은 top_w 폭·top_y=(y0,y1). front_drop 은 앞(-Y) 윗변을 낮춤(보닛 경사)."""
        tw = top_w or w; ty0, ty1 = top_y or (-l / 2.0, l / 2.0)
        v = [(-w / 2, -l / 2, z0), (w / 2, -l / 2, z0), (w / 2, l / 2, z0), (-w / 2, l / 2, z0),
             (-tw / 2, ty0, z1 - front_drop), (tw / 2, ty0, z1 - front_drop), (tw / 2, ty1, z1), (-tw / 2, ty1, z1)]
        f = [(0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7)]
        m = bpy.data.meshes.new(name); m.from_pydata(v, [], f); m.update()
        o = bpy.data.objects.new(name, m); _link(o)
        if color is not None: K.mat(o, color)
        return o

    def lay_car(name="ARIFE_Car", loc=(0, 0, 0), color=(0.8, 0.1, 0.1), rot_z=0.0, kind="sedan", length=None, width=None, root=None):
        """프록시 자동차(보닛 경사 차체 + 사다리꼴 캐빈 + 유리색 + 바퀴 4개, 앞=-Y). kind: sedan·sports·suv·truck·van. 루트 Empty 를 돌려준다 — 움직임은 루트에.
        root= 에 기존 루트를 주면 부품만 새로 만들어 붙인다(움직임 유지)."""
        spec = {"sedan": (4.6, 1.85, 0.62, 0.55, 0.42), "sports": (4.4, 1.9, 0.5, 0.42, 0.36), "suv": (4.8, 1.95, 0.85, 0.7, 0.55), "truck": (6.5, 2.3, 1.1, 1.0, 0.36), "van": (5.0, 1.95, 0.9, 0.85, 0.6)}
        L, W, bodyH, cabH, cabFrac = spec.get(kind, spec["sedan"])
        L = length or L; W = width or W
        if root is None:
            root = bpy.data.objects.new(name, None); _link(root); root.location = _Vec(loc); root.rotation_euler.z = _math.radians(rot_z)
        else:
            for ch in list(root.children_recursive): bpy.data.objects.remove(ch, do_unlink=True)
            name = root.name
        zb = 0.28
        body = _prism(name + "_Body", W, L, zb, zb + bodyH, top_w=W * 0.96, top_y=(-L / 2 + 0.25, L / 2 - 0.15), front_drop=bodyH * 0.35, color=color)
        cy0 = -L * 0.18 if kind != "sports" else -L * 0.05
        cy1 = cy0 + L * cabFrac
        glass = (0.12, 0.16, 0.22)
        cab = _prism(name + "_Cabin", W * 0.9, cy1 - cy0, zb + bodyH - 0.02, zb + bodyH + cabH, top_w=W * 0.78, top_y=(cy0 + (cy1 - cy0) * 0.28, cy1 - (cy1 - cy0) * 0.12), color=glass)
        cab.location.y = (cy0 + cy1) / 2.0
        glass_alpha = 0.6   # 반투명 유리 — 운전석의 캐릭터가 보이게(워크벤치·EEVEE 둘 다). 실측 2026-09-10
        try:
            cc = list(cab.color); cc[3] = glass_alpha; cab.color = cc
            for m in cab.data.materials:
                if m:
                    m.blend_method = "BLEND"; d = list(m.diffuse_color); d[3] = glass_alpha; m.diffuse_color = d
                    b = m.node_tree.nodes.get("Principled BSDF") if m.use_nodes else None
                    if b and "Alpha" in b.inputs: b.inputs["Alpha"].default_value = glass_alpha
        except Exception as e:
            print("[arife] glass alpha:", e)
        roof = lay_prim("cube", name + "_Roof", (W * 0.76, (cy1 - cy0) * 0.58, 0.05), (0, (cy0 + cy1) / 2.0 + (cy1 - cy0) * 0.08, zb + bodyH + cabH - 0.03), color)
        parts = [body, cab, roof]
        wr = 0.34 if kind != "suv" else 0.4
        for i, (sx, sy) in enumerate(((-1, -1), (1, -1), (-1, 1), (1, 1))):
            w = lay_prim("cyl", f"{name}_W{i + 1}", (wr * 2, wr * 2, 0.26), (sx * (W / 2.0 - 0.05), sy * (L * 0.32), 0.0), (0.08, 0.08, 0.09), on_ground=False)
            w.data.transform(_Mat.Rotation(_math.radians(90), 4, "Y"))   # 바퀴 축을 X 로 — 굴림은 rotation_euler.x
            w.location.z = wr; parts.append(w)
        for p in parts:   # 부품 좌표는 루트 기준이라 parent inverse 는 단위행렬
            p.parent = root; p.matrix_parent_inverse = _Mat.Identity(4)
        return root
    K.lay.car = lay_car

    def lay_person(name="ARIFE_Person", loc=(0, 0, 0), color=(0.2, 0.4, 0.9), height=1.75):
        """프록시 사람(몸통+머리). 진짜 팔다리 동작이 필요하면 character_make."""
        root = bpy.data.objects.new(name, None); _link(root); root.location = _Vec(loc)
        b = lay_prim("cyl", name + "_Body", (0.45, 0.3, height * 0.72), (0, 0, 0), color)
        h = lay_prim("sphere", name + "_Head", (0.24, 0.24, 0.26), (0, 0, height * 0.74), (0.9, 0.75, 0.6))
        for p in (b, h): p.parent = root; p.matrix_parent_inverse = _Mat.Identity(4)
        return root
    K.lay.person = lay_person

    def lay_row(make, n, start=(0, 0, 0), step=(0, 6, 0)):
        """make(i, (x,y,z)) 를 n 번 불러 줄 세운다. 예: arife.lay.row(lambda i,p: arife.lay.box(f'ARIFE_B{i}',(4,4,10),p), 8, (-10,0,0), (0,8,0))"""
        out = []; s = _Vec(start); st = _Vec(step)
        for i in range(int(n)): out.append(make(i, tuple(s + st * i)))
        return out
    K.lay.row = lay_row

    def lay_grid(make, nx, ny, start=(0, 0, 0), step=(6, 6, 0)):
        out = []; s = _Vec(start); i = 0
        for iy in range(int(ny)):
            for ix in range(int(nx)):
                out.append(make(i, (s.x + step[0] * ix, s.y + step[1] * iy, s.z))); i += 1
        return out
    K.lay.grid = lay_grid

    def lay_scatter(make, n, area=(-40, -40, 40, 40), seed=1, min_dist=3.0, avoid=()):
        """영역 안에 무작위 배치. avoid=[(x,y,r)…] 원 안은 피한다. make(i,(x,y,0))."""
        rnd = _random.Random(int(seed)); pts = []; out = []; tries = 0
        while len(pts) < int(n) and tries < n * 60:
            tries += 1; x = rnd.uniform(area[0], area[2]); y = rnd.uniform(area[1], area[3])
            if any((x - a[0]) ** 2 + (y - a[1]) ** 2 < a[2] ** 2 for a in avoid): continue
            if any((x - p[0]) ** 2 + (y - p[1]) ** 2 < min_dist ** 2 for p in pts): continue
            pts.append((x, y)); out.append(make(len(pts) - 1, (x, y, 0.0)))
        return out
    K.lay.scatter = lay_scatter

    def lay_city(area=(-80, -80, 80, 80), count=70, seed=1, h=(8, 45), w=(6, 16), gap=3.0, road=("Y", 6.0), colors=None, name="ARIFE_Bldg"):
        """빌딩 블록 도시. road=('Y', 반폭) 이면 x∈[-반폭,반폭] 띠(길)를 비운다. 빌딩 목록을 돌려준다."""
        rnd = _random.Random(int(seed)); placed = []; out = []; tries = 0
        cols = colors or [(0.35, 0.36, 0.4), (0.5, 0.5, 0.52), (0.28, 0.3, 0.36), (0.6, 0.58, 0.55), (0.42, 0.45, 0.5)]
        while len(out) < int(count) and tries < count * 80:
            tries += 1
            bw = rnd.uniform(w[0], w[1]); bd = rnd.uniform(w[0], w[1]); bh = rnd.uniform(h[0], h[1])
            x = rnd.uniform(area[0] + bw / 2, area[2] - bw / 2); y = rnd.uniform(area[1] + bd / 2, area[3] - bd / 2)
            if road:
                ax, half = str(road[0]).upper(), float(road[1]) + gap
                if ax == "Y" and abs(x) - bw / 2 < half: continue
                if ax == "X" and abs(y) - bd / 2 < half: continue
            if any(abs(x - p[0]) < (bw + p[2]) / 2 + gap and abs(y - p[1]) < (bd + p[3]) / 2 + gap for p in placed): continue
            placed.append((x, y, bw, bd))
            out.append(lay_box(f"{name}{len(out) + 1}", (bw, bd, bh), (x, y, 0), rnd.choice(cols)))
        return out
    K.lay.city = lay_city

    def lay_trees(n=20, area=(-40, -40, 40, 40), seed=2, avoid=(), name="ARIFE_Tree"):
        """원뿔+기둥 나무 n 그루."""
        def mk(i, p):
            root = bpy.data.objects.new(f"{name}{i + 1}", None); _link(root); root.location = _Vec(p)
            t = lay_prim("cyl", f"{name}{i + 1}_T", (0.35, 0.35, 1.6), (0, 0, 0), (0.35, 0.22, 0.1))
            c = lay_prim("cone", f"{name}{i + 1}_C", (2.6, 2.6, 4.0), (0, 0, 1.4), (0.1, 0.4, 0.15))
            for q in (t, c): q.parent = root; q.matrix_parent_inverse = _Mat.Identity(4)
            return root
        return lay_scatter(mk, n, area, seed, 3.5, avoid)
    K.lay.trees = lay_trees

    def lay_light(kind="SUN", energy=3.0, rot=(50, 0, 30), color=(1, 1, 1), loc=(0, 0, 20), name="ARIFE_Light"):
        """조명. SUN(rot 도) · POINT/SPOT(loc). EEVEE 렌더에서 필요. 워크벤치는 없어도 됨."""
        d = bpy.data.lights.new(name, kind.upper()); d.energy = float(energy); d.color = tuple(color)
        o = bpy.data.objects.new(name, d); _link(o); o.location = _Vec(loc); o.rotation_euler = tuple(_math.radians(v) for v in rot)
        return o
    K.lay.light = lay_light

    def lay_sky(color=(0.55, 0.65, 0.8), strength=1.0):
        """하늘(월드) 색 — EEVEE 렌더 배경·환경광."""
        s = _scene(); w = s.world or bpy.data.worlds.new("ARIFE_World"); s.world = w; w.use_nodes = True
        bg = w.node_tree.nodes.get("Background")
        if bg: bg.inputs[0].default_value = (*color[:3], 1.0); bg.inputs[1].default_value = float(strength)
        w.color = tuple(color[:3])
        return w
    K.lay.sky = lay_sky

    def lay_clear(prefix="ARIFE_"):
        """prefix 로 시작하는 물체 모두 지우기(장면 초기화)."""
        n = 0
        for o in list(bpy.data.objects):
            if o.name.startswith(prefix): bpy.data.objects.remove(o, do_unlink=True); n += 1
        return n
    K.lay.clear = lay_clear

    def lay_bounds(o):
        """월드 좌표 (min, max) 벡터."""
        o = _obj(o); xs = []; ys = []; zs = []
        for x in [o] + list(o.children_recursive):
            if x.type != "MESH": continue
            for c in x.bound_box:
                w = x.matrix_world @ _Vec(c); xs.append(w.x); ys.append(w.y); zs.append(w.z)
        if not xs: p = o.matrix_world.translation; return p.copy(), p.copy()
        return _Vec((min(xs), min(ys), min(zs))), _Vec((max(xs), max(ys), max(zs)))
    K.lay.bounds = lay_bounds

    def lay_on_ground(o, z=0.0):
        """물체(자식 포함) 밑면이 z 에 닿게 내린다."""
        o = _obj(o); lo, _hi = lay_bounds(o); o.location.z += (z - lo.z); return o
    K.lay.on_ground = lay_on_ground

    def lay_fit(o, longest=2.0):
        """가장 긴 변이 longest m 가 되게 크기 조절(가져온 GLB 정규화)."""
        o = _obj(o); lo, hi = lay_bounds(o); L = max(hi.x - lo.x, hi.y - lo.y, hi.z - lo.z) or 1.0
        o.scale = tuple(v * (longest / L) for v in o.scale); return o
    K.lay.fit = lay_fit

    # ── 물리(리지드바디)·효과 — v0.9.7 (2026-09-10 지뢰·전복 실측에서 뽑음) ─────────────
    #   흐름: arife.phys.ground(지형…) → 물체는 원래 키프레임대로 움직이다가
    #         arife.phys.tumble(차, frame, …) 로 그 프레임부터 물리(중력·마찰·충돌)로 넘어간다
    #         (자식·바퀴·캐릭터가 같이 굴러감). 폭발은 arife.fx.explode(loc, frame).
    #         렌더 전에 arife.phys.bake() 한 번.
    K.phys = _NS(); K.fx = _NS()
    import bmesh as _bmesh

    def _rb_world(start=None, end=None, substeps=12, iterations=20):
        s = _scene()
        if s.rigidbody_world is None:
            with bpy.context.temp_override(scene=s): bpy.ops.rigidbody.world_add()
        w = s.rigidbody_world; w.enabled = True
        w.substeps_per_frame = int(substeps); w.solver_iterations = int(iterations)
        a, b = _fr(start, end); w.point_cache.frame_start = a; w.point_cache.frame_end = b
        return w
    K.phys.world = _rb_world

    def _rb_add(o, typ="ACTIVE", shape="CONVEX_HULL", mass=1.0, friction=0.7, restitution=0.15):
        if o.rigid_body is None:
            with bpy.context.temp_override(object=o, active_object=o, selected_objects=[o], scene=_scene()):
                bpy.ops.rigidbody.object_add(type=typ)
        rb = o.rigid_body; rb.type = typ; rb.collision_shape = shape
        if typ == "ACTIVE": rb.mass = max(0.001, float(mass))
        rb.friction = float(friction); rb.restitution = float(restitution)
        return rb

    def _ground_objs(on=None):
        if on:
            objs = [_obj(o) for o in (on if isinstance(on, (list, tuple)) else [on])]
        else:
            objs = [o for o in _scene().objects if o.type == "MESH" and any(k in o.name for k in ("Ground", "Mountain", "Terrain", "Road", "Trail", "Floor", "Hill"))]
        return [o for o in objs if o is not None and o.type == "MESH"]

    def phys_ground(*objs, friction=0.9, shape="MESH"):
        """지형·길·바닥을 움직이지 않는 충돌체(passive)로. 아무것도 안 주면 이름에 Ground/Mountain/Road/Trail 이 든 메시 전부."""
        _rb_world(); out = []
        for o in _ground_objs(list(objs) or None):
            _rb_add(o, "PASSIVE", shape, 0.0, friction, 0.1); out.append(o)
        return out
    K.phys.ground = phys_ground

    def height(x, y, on=None):
        """(x,y) 위 지면 높이(가장 높은 표면). on 은 지형 물체(들); 없으면 이름으로 찾는다. 지형이 없으면 0."""
        best = None
        for o in _ground_objs(on):
            try:
                ok, loc, _n, _i = o.ray_cast(o.matrix_world.inverted() @ _Vec((float(x), float(y), 1000.0)), _Vec((0, 0, -1)))
            except Exception:
                ok = False
            if ok:
                z = (o.matrix_world @ loc).z; best = z if best is None else max(best, z)
        return 0.0 if best is None else best
    K.lay.height = height

    def downhill(x, y, on=None, r=8.0):
        """(x,y) 에서 내리막 방향(수평 단위벡터). 거의 평지면 None."""
        h0 = height(x, y, on); best = None
        for a in range(0, 360, 20):
            d = _Vec((_math.cos(_math.radians(a)), _math.sin(_math.radians(a)), 0.0))
            h = height(x + d.x * r, y + d.y * r, on)
            if best is None or h < best[0]: best = (h, d)
        return best[1] if (best and best[0] < h0 - 0.3) else None
    K.lay.downhill = downhill

    def _dir_vec(direction, root, P, forward, on):
        fw = (root.matrix_world.to_3x3() @ _fwd(forward)); fw.z = 0.0
        if fw.length < 1e-6: fw = _Vec((0, -1, 0))
        fw.normalize(); left = _Vec((0, 0, 1)).cross(fw)
        if isinstance(direction, str):
            k = direction.lower()
            if k == "downhill":
                d = downhill(P.x, P.y, on)
                if d is None: d = left if height(P.x + left.x * 8, P.y + left.y * 8, on) < height(P.x - left.x * 8, P.y - left.y * 8, on) else -left
                if abs(d.dot(fw)) > 0.8:   # 진행 방향과 나란하면 길 옆으로
                    d = left if height(P.x + left.x * 8, P.y + left.y * 8, on) < height(P.x - left.x * 8, P.y - left.y * 8, on) else -left
                return d, fw, left
            if k == "left": return left, fw, left
            if k == "right": return -left, fw, left
            if k == "back": return -fw, fw, left
            return fw, fw, left
        d = _Vec(direction); d.z = 0.0
        return (d.normalized() if d.length > 1e-6 else fw), fw, left

    def phys_tumble(root, frame, up=10.0, side=7.0, direction="downhill", spin=330.0, spin_axis="forward", mass=1800.0, com=0.6, ground=None, hold=3, forward="-Y", friction=0.7, bounce=0.15, damping=0.05):
        """움직이던 물체(차 루트 등)를 frame 에서 물리로 넘겨 튕겨 굴린다 — 폭발·충돌·추락.
        frame 까지는 원래 키프레임대로 가고, 그 뒤엔 중력·마찰·지형 충돌만으로 굴러 멈춘다(자식·바퀴·캐릭터 전부 같이).
        up/side: 초속(m/s). direction: 'downhill'(기본)·'left'·'right'·'forward'·'back'·(x,y,z). spin: 도/초, spin_axis 'forward'=옆구르기·'side'=앞구르기·'up'.
        mass kg, com 무게중심 높이(m), ground 충돌 지형(없으면 이름으로 찾음). 돌려주는 값: 충돌 프록시(렌더엔 안 보임).
        렌더 전에 arife.phys.bake(). 실측: up 10·side 7·spin 330 이면 SUV 가 6m 떠서 16m 굴러 바위에 멈춤."""
        root = _obj(root); s = _scene(); F = int(frame); fps = float(_fps()); dt = 1.0 / fps
        gnd = _ground_objs(ground); phys_ground(*gnd)
        pname = "ARIFE_" + root.name.replace("ARIFE_", "") + "_RB"
        for o in list(bpy.data.objects):
            if o.name in (pname, pname + "_Anchor"): bpy.data.objects.remove(o, do_unlink=True)
        for c in [c for c in root.constraints if c.name == "ARIFE_Wreck"]: root.constraints.remove(c)
        COM = _Vec((0.0, 0.0, float(com)))
        s.frame_set(F); Minv = root.matrix_world.inverted()
        bm = _bmesh.new(); n = 0
        for part in root.children_recursive:
            if part.type != "MESH" or any(m.type == "ARMATURE" for m in part.modifiers) or len(part.data.vertices) > 60000: continue
            par = part.parent; skip = False
            while par is not None and par != root:
                if par.type == "ARMATURE" or par.name.endswith("_Off"): skip = True; break
                par = par.parent
            if skip: continue
            me = part.data.copy(); me.transform(_Mat.Translation(-COM) @ (Minv @ part.matrix_world)); bm.from_mesh(me); bpy.data.meshes.remove(me); n += 1
        if root.type == "MESH":
            me = root.data.copy(); me.transform(_Mat.Translation(-COM)); bm.from_mesh(me); bpy.data.meshes.remove(me); n += 1
        pm = bpy.data.meshes.new(pname); bm.to_mesh(pm); bm.free()
        prox = bpy.data.objects.new(pname, pm); _link(prox)
        prox.display_type = "WIRE"; prox.hide_render = True; prox.rotation_mode = "XYZ"
        rb = _rb_add(prox, "ACTIVE", "CONVEX_HULL", mass, friction, bounce); rb.linear_damping = damping; rb.angular_damping = damping * 2
        rb.kinematic = True; rb.keyframe_insert("kinematic", frame=s.frame_start)
        for fr in range(s.frame_start, F + 1):
            s.frame_set(fr); Mw = root.matrix_world
            prox.location = Mw @ COM; prox.rotation_euler = Mw.to_euler("XYZ")
            _key(prox, "location", fr); _key(prox, "rotation_euler", fr)
        s.frame_set(F); Mw = root.matrix_world.copy(); base = Mw @ COM; P = Mw.translation.copy(); rot0 = Mw.to_euler("XYZ")
        d, fw, left = _dir_vec(direction, root, P, forward, gnd)
        ax = {"forward": fw, "side": left, "up": _Vec((0, 0, 1))}.get(str(spin_axis).lower(), fw)
        for i in range(1, int(hold) + 1):
            t = dt * i
            prox.location = base + _Vec((0, 0, float(up) * t - 4.9 * t * t)) + d * (float(side) * t)
            prox.rotation_euler = (_Mat.Rotation(_math.radians(float(spin)) * t, 4, ax) @ rot0.to_matrix().to_4x4()).to_euler("XYZ")
            _key(prox, "location", F + i); _key(prox, "rotation_euler", F + i)
        REL = F + int(hold) + 1
        rb.kinematic = True; rb.keyframe_insert("kinematic", frame=REL - 1)
        rb.kinematic = False; rb.keyframe_insert("kinematic", frame=REL)
        _interp(prox, "LINEAR")
        anchor = bpy.data.objects.new(pname + "_Anchor", None); _link(anchor)
        anchor.parent = prox; anchor.matrix_parent_inverse = _Mat.Identity(4); anchor.location = -COM
        con = root.constraints.new("COPY_TRANSFORMS"); con.name = "ARIFE_Wreck"; con.target = anchor
        con.influence = 0.0; con.keyframe_insert("influence", frame=REL - 1)
        con.influence = 1.0; con.keyframe_insert("influence", frame=REL)
        s.frame_set(s.frame_start)
        print(f"[arife.phys.tumble] {root.name}: {F}f 까지 키프레임 → {REL}f 부터 물리 (up {up} side {side} m/s, {direction}, spin {spin}°/s, {n} parts)")
        return prox
    K.phys.tumble = phys_tumble

    def phys_drop(root, frame, mass=100.0, com=0.5, ground=None, **kw):
        """frame 부터 그냥 물리로 떨어뜨린다(튕김 없음) — 절벽·무너짐·낙하."""
        return phys_tumble(root, frame, up=0.0, side=0.0, spin=0.0, mass=mass, com=com, ground=ground, hold=1, **kw)
    K.phys.drop = phys_drop

    def phys_bake():
        """리지드바디 시뮬을 굽는다(렌더 전에 한 번). tumble/explode 뒤 필수."""
        s = _scene(); w = s.rigidbody_world
        if w is None: return False
        cur = s.frame_current; s.frame_set(s.frame_start)
        try:
            with bpy.context.temp_override(scene=s, point_cache=w.point_cache): bpy.ops.ptcache.free_bake_all()
        except Exception: pass
        try:
            with bpy.context.temp_override(scene=s, point_cache=w.point_cache): bpy.ops.ptcache.bake_all(bake=True)
        except Exception as e:
            print("[arife.phys.bake] bake_all 실패, 프레임을 밟아 계산:", e)
            for fr in range(s.frame_start, s.frame_end + 1): s.frame_set(fr)
        s.frame_set(cur); return True
    K.phys.bake = phys_bake

    def phys_where(prox, frames):
        """프록시(또는 물체)의 프레임별 위치 — 굴러간 결과 확인용."""
        o = _obj(prox); s = _scene(); cur = s.frame_current; out = []
        for fr in frames:
            s.frame_set(int(fr)); p = o.matrix_world.translation; out.append((int(fr), round(p.x, 2), round(p.y, 2), round(p.z, 2)))
        s.frame_set(cur); return out
    K.phys.where = phys_where

    # ── 효과(블록아웃용 — 실사화 때 AI 가 불·연기로 바꿔 그린다) ──
    def _fx_prim(kind, name, size, loc, col, alpha=1.0):
        bm = _bmesh.new()
        if kind == "sphere": _bmesh.ops.create_uvsphere(bm, u_segments=16, v_segments=10, radius=0.5)
        elif kind == "cyl": _bmesh.ops.create_cone(bm, cap_ends=True, segments=16, radius1=0.5, radius2=0.5, depth=1.0)
        else: _bmesh.ops.create_cube(bm, size=1.0)
        me = bpy.data.meshes.new(name); bm.to_mesh(me); bm.free()
        o = bpy.data.objects.new(name, me); _link(o)
        o.location = _Vec(loc); o.scale = _Vec(size) if not isinstance(size, (int, float)) else _Vec((size, size, size))
        m = bpy.data.materials.new("ARIFE_M_" + o.name); m.diffuse_color = (col[0], col[1], col[2], alpha); m.blend_method = "BLEND"; m.use_nodes = True
        b = m.node_tree.nodes.get("Principled BSDF")
        if b:
            b.inputs["Base Color"].default_value = (col[0], col[1], col[2], 1.0)
            if "Alpha" in b.inputs: b.inputs["Alpha"].default_value = alpha
        o.data.materials.append(m); o.color = (col[0], col[1], col[2], alpha)
        return o

    def _fx_hide(o, frame, val):
        o.hide_render = val; o.keyframe_insert("hide_render", frame=int(frame))
        o.hide_viewport = val; o.keyframe_insert("hide_viewport", frame=int(frame))

    def _fx_alpha(o, frame, a):
        c = list(o.color); c[3] = a; o.color = c; o.keyframe_insert("color", frame=int(frame))
        m = o.data.materials[0]; d = list(m.diffuse_color); d[3] = a; m.diffuse_color = d; m.keyframe_insert("diffuse_color", frame=int(frame))

    def fx_smoke(loc, frame, n=7, size=6.0, rise=16.0, duration=170, color=(0.24, 0.23, 0.22), alpha=0.5, seed=3, name="ARIFE_Smoke"):
        """연기 기둥: 반투명 구 n 개가 커지며 올라가고 옅어져 duration 프레임 뒤 사라진다. loc 은 바닥."""
        rnd = _random.Random(seed); F = int(frame); base = _Vec(loc); out = []
        for i in range(int(n)):
            col = tuple(min(1.0, c + i * 0.02) for c in color)
            o = _fx_prim("sphere", f"{name}{i}", 0.01, base, col, alpha)
            off = _Vec((rnd.uniform(-1, 1), rnd.uniform(-1, 1), rnd.uniform(0, 0.6))) * (size / 6.0)
            drift = _Vec((rnd.uniform(-2.5, 2.5), rnd.uniform(-2.5, 2.5), 0.0)) * (size / 6.0); d0 = i * 3
            _fx_hide(o, _scene().frame_start, True); _fx_hide(o, F + 2 + d0, False); _fx_hide(o, F + duration + 5, True)
            for fr, sc_, dz, a in ((F + 2 + d0, 0.1, 0.1, alpha * 1.1), (F + 14 + d0, 0.43, 0.14, alpha), (F + 45 + d0, 0.7, 0.34, alpha * 0.8), (F + int(duration * 0.65), 0.92, 0.69, alpha * 0.45), (F + duration, 1.08, 1.0, 0.0)):
                k = (fr - F) / float(duration)
                o.scale = (size * sc_, size * sc_, size * sc_ * 1.15); o.location = base + off * (1 + k * 2) + drift * k + _Vec((0, 0, rise * dz))
                _key(o, "scale", fr); _key(o, "location", fr); _fx_alpha(o, fr, min(1.0, a))
            _interp(o, "LINEAR"); out.append(o)
        return out
    K.fx.smoke = fx_smoke

    def fx_dust(loc, frame, size=3.0, duration=40, color=(0.62, 0.56, 0.46)):
        """흙먼지 퍼프(착지·급정거). 짧고 옅다."""
        return fx_smoke(loc, frame, n=4, size=size, rise=size * 0.6, duration=duration, color=color, alpha=0.4, name="ARIFE_Dust")
    K.fx.dust = fx_dust

    def fx_explode(loc, frame, size=6.0, debris=14, smoke=True, mine=True, duration=170, color=(1.0, 0.55, 0.08), seed=7, ground=None):
        """폭발 블록아웃: 불덩이(커졌다 사라짐)·흰 섬광·연기(fx.smoke)·파편(물리로 튀어 떨어짐)·지뢰 원반(밟기 전까지만 보임).
        loc 은 바닥 위치(z 는 지면 높이; arife.lay.height 로). 차를 날리려면 같은 frame 에 arife.phys.tumble(차, frame) 를 같이. 파편은 물리라 arife.phys.bake() 필요."""
        F = int(frame); base = _Vec(loc); rnd = _random.Random(seed); s = _scene(); out = _NS()
        for o in list(bpy.data.objects):
            if o.name in ("ARIFE_Mine", "ARIFE_Blast", "ARIFE_BlastCore") or o.name.startswith("ARIFE_Debris"): bpy.data.objects.remove(o, do_unlink=True)
        if mine:
            m = _fx_prim("cyl", "ARIFE_Mine", (0.5, 0.5, 0.08), base + _Vec((0, 0, 0.04)), (0.15, 0.13, 0.1))
            _fx_hide(m, s.frame_start, False); _fx_hide(m, F + 1, True); out.mine = m
        bl = _fx_prim("sphere", "ARIFE_Blast", 0.01, base + _Vec((0, 0, size * 0.1)), color, 1.0)
        _fx_hide(bl, s.frame_start, True); _fx_hide(bl, F, False); _fx_hide(bl, F + 34, True)
        for fr, sc_, a in ((F, 0.003, 1.0), (F + 2, 0.53, 1.0), (F + 6, 0.92, 1.0), (F + 14, 1.08, 0.7), (F + 33, 1.17, 0.0)):
            bl.scale = (size * sc_,) * 3; _key(bl, "scale", fr); _fx_alpha(bl, fr, a)
        _interp(bl, "LINEAR"); out.blast = bl
        co = _fx_prim("sphere", "ARIFE_BlastCore", 0.01, base + _Vec((0, 0, size * 0.1)), (1.0, 0.95, 0.6), 1.0)
        _fx_hide(co, s.frame_start, True); _fx_hide(co, F, False); _fx_hide(co, F + 12, True)
        for fr, sc_, a in ((F, 0.003, 1.0), (F + 1, 0.4, 1.0), (F + 4, 0.58, 1.0), (F + 11, 0.5, 0.0)):
            co.scale = (size * sc_,) * 3; _key(co, "scale", fr); _fx_alpha(co, fr, a)
        _interp(co, "LINEAR"); out.core = co
        out.smoke = fx_smoke(base, frame, size=size, rise=size * 2.7, duration=duration, seed=seed) if smoke else []
        out.debris = []
        if debris:
            _rb_world(); phys_ground(*_ground_objs(ground)); dt = 1.0 / float(_fps()); REL = F + 4
            for i in range(int(debris)):
                sz = rnd.uniform(0.15, 0.45) * (size / 6.0)
                d = _fx_prim("cube", f"ARIFE_Debris{i}", (sz, sz * rnd.uniform(0.5, 1.2), sz * rnd.uniform(0.3, 1.0)), base + _Vec((0, 0, 0.3)), rnd.choice([(0.12, 0.1, 0.09), (0.9, 0.45, 0.1), (0.3, 0.3, 0.32)]))
                _fx_hide(d, s.frame_start, True); _fx_hide(d, F, False)
                rb = _rb_add(d, "ACTIVE", "BOX", 6.0, 0.7, 0.2); rb.kinematic = True; rb.keyframe_insert("kinematic", frame=s.frame_start)
                ang = rnd.uniform(0, 2 * _math.pi); v = _Vec((_math.cos(ang), _math.sin(ang), 0.0)) * rnd.uniform(4, 13) * (size / 6.0); vz = rnd.uniform(8, 18) * (size / 6.0)
                d.rotation_mode = "XYZ"
                for k in range(0, 4):
                    t = dt * k; d.location = base + _Vec((0, 0, 0.3)) + v * t + _Vec((0, 0, vz * t)); d.rotation_euler = (rnd.uniform(-0.3, 0.3) * k, rnd.uniform(-0.3, 0.3) * k, 0)
                    _key(d, "location", F + k); _key(d, "rotation_euler", F + k)
                rb.kinematic = True; rb.keyframe_insert("kinematic", frame=REL - 1); rb.kinematic = False; rb.keyframe_insert("kinematic", frame=REL)
                _interp(d, "LINEAR"); out.debris.append(d)
        print(f"[arife.fx.explode] {F}f @ {tuple(round(v, 1) for v in base)} size {size} debris {debris}")
        return out
    K.fx.explode = fx_explode

    def cam_clear_spot(targets, frames=None, dist=(16, 20, 24, 28, 34), heights=(2.5, 4.5, 7, 10, 14), prefer=24.0, ignore=(), on=None):
        """가리는 것 없이 targets 를 전부 볼 수 있는 카메라 자리(Vector) 하나. targets 는 좌표들 또는 물체(물체면 frames 의 위치를 샘플). 낮고 prefer 거리에 가까운 곳 우선.
        쓰는 법: p = arife.cam.clear_spot(차, frames=range(150, 300, 25)); arife.cam.place(p, look_at=차, frame=161, until=360); arife.look(cam, 차)"""
        s = _scene(); cur = s.frame_current; pts = []
        tl = targets if isinstance(targets, (list, tuple)) else [targets]
        for t in tl:
            ob = _obj(t) if isinstance(t, str) else (t if hasattr(t, "matrix_world") else None)
            if ob is None: pts.append(_Vec(t)); continue
            for fr in (frames if frames else [s.frame_current]):
                s.frame_set(int(fr)); pts.append(ob.matrix_world.translation.copy())
        if not pts: s.frame_set(cur); return None
        center = sum(pts, _Vec()) / len(pts)
        mid = int(frames[len(frames) // 2]) if frames else s.frame_current
        s.frame_set(mid); dg = bpy.context.evaluated_depsgraph_get()
        skip = set(ignore)
        for t in tl:
            ob = _obj(t) if isinstance(t, str) else (t if hasattr(t, "matrix_world") else None)
            if ob is not None:
                r = ob
                while r.parent is not None: r = r.parent
                skip |= {r.name} | {x.name for x in r.children_recursive}
        def _ray(cp, tp):
            d = tp - cp; L = d.length
            if L < 1e-6: return True
            hit, _l, _n, _i, ob, _m = s.ray_cast(dg, cp, d.normalized(), distance=L - 0.8)
            if not hit or ob is None: return True
            return ob.name in skip or ob.name.startswith(("ARIFE_Blast", "ARIFE_Smoke", "ARIFE_Debris", "ARIFE_Dust")) or ob.name.endswith("_RB")
        def _vis(cp, tp):   # 중심 + 둘레 4점(1.5m) — 나뭇가지 하나가 살짝 걸려도 통과, 반 이상 가리면 실패
            pts5 = [tp, tp + _Vec((1.5, 0, 0)), tp + _Vec((-1.5, 0, 0)), tp + _Vec((0, 1.5, 0)), tp + _Vec((0, 0, 1.2))]
            return sum(1 for q in pts5 if _ray(cp, q)) >= 4
        best = None
        for r in dist:
            for a in range(0, 360, 12):
                d = _Vec((_math.cos(_math.radians(a)), _math.sin(_math.radians(a)), 0.0))
                for h in heights:
                    p = center + d * float(r); p.z = height(p.x, p.y, on) + float(h)
                    vis = sum(1 for tp in pts if _vis(p, tp))
                    score = vis * 100 - float(h) * 2 - abs(float(r) - prefer) * 0.5
                    if best is None or score > best[0]: best = (score, vis, p.copy())
        s.frame_set(cur)
        if best is None: return None
        if best[1] < len(pts): print(f"[arife.cam.clear_spot] 경고: 전부 보이는 자리가 없어 {best[1]}/{len(pts)} 보이는 자리를 골랐습니다")
        return best[2]
    K.cam.clear_spot = cam_clear_spot

    # ── 뜨개(knit) — 아무 메시나 실 한 가닥으로 감고, 풀리는 동작 (v0.9.10, 2026-09-10 실측) ─────
    def fx_knit(obj, stitch=0.35, pitch=0.30, yarn=0.08, color=(0.93, 0.36, 0.25), sub=6, loop_h=None, loop_out=None, name=None, hide_source=True):
        """메시(건물·캐릭터·아무 형태)를 수평으로 켜켜이 잘라 각 단을 실 곡선으로 만든다(코 모양 포함) — 뜨개질로 뜬 것처럼.
        stitch 코 너비(m)·pitch 단 높이·yarn 실 반지름. 돌려주는 값: 실 커브 물체(단마다 스플라인 하나, 아래→위). 원본은 숨김.
        실측: 6×6×10m 건물 → 31단·12k점, 렌더 부담 없음. 풀리는 동작은 arife.fx.unravel(실, start, end)."""
        import bmesh as _bm2
        src = _obj(obj); s = _scene(); dg = bpy.context.evaluated_depsgraph_get()
        ev = src.evaluated_get(dg); me = ev.to_mesh()
        base = _bm2.new(); base.from_mesh(me); base.transform(src.matrix_world); ev.to_mesh_clear()
        zs = [v.co.z for v in base.verts]; z0, z1 = min(zs), max(zs)
        loop_h = loop_h if loop_h is not None else pitch * 0.36; loop_out = loop_out if loop_out is not None else yarn * 0.9
        cd = bpy.data.curves.new((name or "ARIFE_Knit_" + src.name), "CURVE"); cd.dimensions = "3D"
        rows = 0; z = z0 + pitch * 0.5
        while z < z1 - pitch * 0.2:
            bm = base.copy()
            res = _bm2.ops.bisect_plane(bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], plane_co=(0, 0, z), plane_no=(0, 0, 1), clear_outer=True, clear_inner=True)
            cut = [e for e in res["geom_cut"] if isinstance(e, _bm2.types.BMEdge)]
            # 잘린 변들을 고리(loop)로 잇는다
            adj = {}
            for e in cut:
                a, b = e.verts[0], e.verts[1]
                adj.setdefault(a.index, []).append(b); adj.setdefault(b.index, []).append(a)
            seen = set()
            for e in cut:
                v = e.verts[0]
                if v.index in seen: continue
                loop = [v]; seen.add(v.index); cur = v; prev = None
                while True:
                    nxt = [n for n in adj.get(cur.index, []) if n is not prev and n.index not in seen]
                    if not nxt: break
                    cur, prev = nxt[0], cur; loop.append(cur); seen.add(cur.index)
                if len(loop) < 3: continue
                P = [x.co.copy() for x in loop]
                # 둘레 길이 기준으로 코 개수만큼 다시 샘플(닫힌 고리)
                segs = [(P[(i + 1) % len(P)] - P[i]).length for i in range(len(P))]; per = sum(segs)
                n_st = max(6, int(per / stitch)); n = n_st * sub
                cx = sum(P, _Vec()) / len(P)
                pts = []; acc = 0.0; k = 0
                for i in range(n):
                    target = per * i / n
                    while k < len(P) - 1 and acc + segs[k] < target: acc += segs[k]; k += 1
                    t = (target - acc) / max(1e-9, segs[k]); p = P[k].lerp(P[(k + 1) % len(P)], t)
                    d = (P[(k + 1) % len(P)] - P[k]); d.z = 0
                    nrm = _Vec((d.y, -d.x, 0)); nrm.normalize() if nrm.length > 1e-9 else None
                    if (p - cx).dot(nrm) < 0: nrm = -nrm            # 바깥쪽으로
                    ph = 2 * _math.pi * (i % sub) / sub
                    q = p + nrm * (loop_out * (0.5 + 0.5 * _math.cos(ph))) + _Vec((0, 0, loop_h * _math.sin(ph)))
                    pts.append(q)
                sp = cd.splines.new("POLY"); sp.points.add(len(pts) - 1); sp.use_cyclic_u = True
                for j, q in enumerate(pts): sp.points[j].co = (q.x, q.y, q.z, 1.0)
                rows += 1
            bm.free(); z += pitch
        base.free()
        cd.bevel_depth = yarn; cd.bevel_resolution = 4; cd.use_fill_caps = True
        o = bpy.data.objects.new(cd.name, cd); _link(o); K.mat(o, color)
        if hide_source: src.hide_render = True; src.hide_viewport = True
        print(f"[arife.fx.knit] {src.name}: {rows} splines, {sum(len(sp.points) for sp in cd.splines)} pts")
        return o
    K.fx.knit = fx_knit

    def fx_unravel(knit, start, end, tail=True, tail_len=9.0, top_down=True, overlap=1.6, sway=0.5):
        """뜨개 실이 풀린다: 단(스플라인)마다 실이 한쪽 끝부터 사라지고(위 단부터), 풀린 끝에 실 꼬리가 붙어 하늘 쪽으로 흔들린다.
        start~end 프레임 동안 전부 풀림. overlap: 동시에 풀리는 단 수(클수록 부드럽게 겹침)."""
        o = _obj(knit); cd = o.data; s = _scene(); a, b = int(start), int(end)
        sps = list(cd.splines); n = len(sps)
        order = list(range(n - 1, -1, -1)) if top_down else list(range(n))
        # 닫힌 고리는 잘라야(bevel_factor 가 먹게) — 각 스플라인을 열고 끝점을 위 단 시작점 근처로
        for sp in sps: sp.use_cyclic_u = False
        total = float(n + overlap - 1)
        def prog(f):
            if f <= a: return 0.0
            if f >= b: return 1.0
            t = (f - a) / float(b - a); return t * t * (3 - 2 * t)
        # 스플라인별 bevel 은 없어서 → 단마다 물체를 나눠야 한다. 단 수만큼 커브 물체로 쪼갠다(성능: 31단이면 31물체, 괜찮음)
        parts = []
        for i, sp in enumerate(sps):
            c2 = bpy.data.curves.new(f"{o.name}_row{i:03d}", "CURVE"); c2.dimensions = "3D"
            c2.bevel_depth = cd.bevel_depth; c2.bevel_resolution = cd.bevel_resolution; c2.use_fill_caps = True; c2.bevel_factor_mapping_end = "RESOLUTION"
            s2 = c2.splines.new("POLY"); s2.points.add(len(sp.points) - 1)
            for j, p in enumerate(sp.points): s2.points[j].co = p.co
            po = bpy.data.objects.new(c2.name, c2); _link(po); po.parent = o; po.matrix_parent_inverse = o.matrix_world.inverted()
            for m in o.data.materials: c2.materials.append(m)
            po.color = o.color; parts.append(po)
        cd.bevel_depth = 0.0; o.hide_render = True   # 원본 통 커브는 숨기고 단 물체들만 보임
        # 단 i(order 순서 k)의 풀림: 전체 진행 p 에 대해 k 번째 단은 (p*total - k)/overlap 만큼 풀린다
        last_end = {}
        for f in range(a - 2, b + 3, 2):
            p = prog(f)
            for k, i in enumerate(order):
                q = min(1.0, max(0.0, (p * total - k) / overlap)); fac = 1.0 - q
                c2 = parts[i].data; c2.bevel_factor_end = fac; c2.keyframe_insert("bevel_factor_end", frame=f)
                last_end[i] = fac
        for i in order:
            parts[i].hide_render = False; parts[i].keyframe_insert("hide_render", frame=a - 3)
        # 다 풀린 단은 숨김
        for k, i in enumerate(order):
            fh = None
            for f in range(a, b + 3):
                if (prog(f) * total - k) / overlap >= 1.0: fh = f; break
            if fh: parts[i].hide_render = False; parts[i].keyframe_insert("hide_render", frame=fh - 1); parts[i].hide_render = True; parts[i].keyframe_insert("hide_render", frame=fh)
        out = _NS(); out.rows = parts; out.tail = None
        if tail:
            td = bpy.data.curves.new(o.name + "_Tail", "CURVE"); td.dimensions = "3D"
            ts = td.splines.new("POLY"); nt = 40; ts.points.add(nt - 1)
            for k2 in range(nt):
                t = k2 / (nt - 1)
                ts.points[k2].co = (0.35 * _math.sin(t * 9) * t, -0.3 * t + 0.25 * _math.sin(t * 6), t * tail_len + 0.6 * _math.sin(t * 4) * t, 1.0)
            td.bevel_depth = cd.bevel_depth or 0.08; td.bevel_resolution = 4; td.use_fill_caps = True
            tl = bpy.data.objects.new(td.name, td); _link(tl)
            for m in o.data.materials: td.materials.append(m)
            tl.color = o.color
            tl.hide_render = True; tl.keyframe_insert("hide_render", frame=a - 4); tl.hide_render = False; tl.keyframe_insert("hide_render", frame=a - 3)
            for f in range(a - 4, s.frame_end + 1, 2):
                p = prog(f)
                # 지금 풀리고 있는 단과 그 안의 위치
                kk = min(n - 1, int(p * total)); i = order[kk]; q = min(1.0, max(0.0, (p * total - kk) / overlap))
                sp = sps[i]; m_ = len(sp.points); idx = min(m_ - 1, max(0, int(round((1.0 - q) * (m_ - 1)))))
                pt = _Vec(sp.points[idx].co[:3]); pv = _Vec(sp.points[max(0, idx - 3)].co[:3])
                d = pt - pv; d.z = 0; yaw = _math.atan2(d.y, d.x) if d.length > 1e-6 else 0.0
                tl.location = o.matrix_world @ pt
                tl.rotation_euler = (sway * _math.sin(f * 0.13) + 0.25, sway * 0.7 * _math.cos(f * 0.09), yaw + 0.5)
                _key(tl, "location", f); _key(tl, "rotation_euler", f)
            K.noise(tl, "rotation_euler", strength=0.12, scale=6.0)
            tl.hide_render = False; tl.keyframe_insert("hide_render", frame=b + 5); tl.hide_render = True; tl.keyframe_insert("hide_render", frame=b + 6)   # 다 풀리면 꼬리도 사라짐
            out.tail = tl
        print(f"[arife.fx.unravel] {o.name}: {n} rows, {a}→{b}f, top_down={top_down}")
        return out
    K.fx.unravel = fx_unravel

    return K


def _exec_code(code):
    buf = _io.StringIO()
    kit = _make_kit()
    try:
        import sys as _sys; _sys.modules["arife"] = kit   # 두뇌가 `import arife` 라고 써도 되게
    except Exception:
        pass
    g = {"bpy": bpy, "arife": kit, "math": _math, "__name__": "__arife_ai__", "__builtins__": __builtins__}
    t0 = time.time()
    try:
        with _ctx.redirect_stdout(buf):
            exec(compile(code, "<arife-ai>", "exec"), g)
        out = buf.getvalue()
        return True, {"stdout": out[-20000:], "elapsed": round(time.time() - t0, 2), "truncated": len(out) > 20000}
    except Exception as e:
        return False, {"error": f"{type(e).__name__}: {e}", "traceback": _tb.format_exc()[-6000:], "stdout": buf.getvalue()[-8000:]}
    finally:
        _heal_module()


def _heal_module():
    """v0.9.9 — 두뇌 코드가 이 애드온의 함수를 바꿔치기(몽키패치)했으면 되돌린다(실측: _render_now 를 감싸 프레임 범위를 120~300 으로 강제해 렌더가 잘못 나옴).
    exec 된 코드의 함수는 파일 이름이 '<arife-ai>' 라서 구분된다."""
    try:
        import sys as _sys, types as _types
        m = _sys.modules.get(__name__)
        if m is None: return
        orig = getattr(m, "_ORIG_FUNCS", None)
        if orig is None: return
        fixed = []
        for n, f in list(vars(m).items()):
            if isinstance(f, _types.FunctionType) and f.__code__.co_filename == "<arife-ai>" and n in orig:
                setattr(m, n, orig[n]); fixed.append(n)
        if fixed: print("[ARIFE] 두뇌 코드가 바꾼 애드온 함수를 되돌림:", fixed)
    except Exception as e:
        print("[ARIFE] heal:", e)


def _render_out_path(filename):
    base = os.path.dirname(bpy.data.filepath) if bpy.data.filepath else os.path.join(tempfile.gettempdir(), "arife")
    os.makedirs(base, exist_ok=True)
    return os.path.join(base, f"{filename}.mp4")


def _render_now(payload):
    """워크벤치(또는 EEVEE) 로 mp4 렌더. Blender 의 보통 렌더처럼 동기로 돈다 — 돌아오면 파일이 닫혀 있다."""
    s = bpy.context.scene
    r = s.render
    if payload.get("frame_start") is not None: s.frame_start = int(payload["frame_start"])
    if payload.get("frame_end") is not None: s.frame_end = int(payload["frame_end"])
    r.fps = int(payload.get("fps") or 24)
    r.resolution_x = int(payload.get("width") or 1280)
    r.resolution_y = int(payload.get("height") or 720)
    r.resolution_percentage = 100
    # v0.9.0 — 재질 색만 준 물체(에이전트 코드가 diffuse_color 로 색을 줌)는 obj.color 로 옮겨 워크벤치(OBJECT 색)에서도 보이게
    for o in bpy.data.objects:
        try:
            if o.type == "MESH" and tuple(round(c, 3) for c in o.color) == (1.0, 1.0, 1.0, 1.0) and o.data.materials and o.data.materials[0]:
                dc = o.data.materials[0].diffuse_color
                if tuple(round(c, 3) for c in dc[:3]) != (0.8, 0.8, 0.8): o.color = (dc[0], dc[1], dc[2], 1.0)
        except Exception:
            pass
    if payload.get("shading") == "eevee":
        r.engine = "BLENDER_EEVEE_NEXT" if hasattr(bpy.types, "SceneEEVEE") and bpy.app.version >= (4, 2, 0) else "BLENDER_EEVEE"
        try:
            s.eevee.taa_render_samples = int(payload.get("samples") or 16)
        except Exception:
            pass
        try:   # 빛·하늘이 없으면 기본을 깔아 준다(검은 화면 방지)
            if not any(o.type == "LIGHT" for o in bpy.data.objects):
                d = bpy.data.lights.new("ARIFE_Sun", "SUN"); d.energy = 3.0
                sun = bpy.data.objects.new("ARIFE_Sun", d); s.collection.objects.link(sun); sun.rotation_euler = (_math.radians(50), 0.0, _math.radians(30))
            if s.world is None:
                w = bpy.data.worlds.new("ARIFE_World"); s.world = w; w.use_nodes = True
                bg = w.node_tree.nodes.get("Background")
                if bg: bg.inputs[0].default_value = (0.55, 0.65, 0.8, 1.0)
        except Exception as e:
            print("[ARIFE] eevee 기본 조명:", e)
    else:
        r.engine = "BLENDER_WORKBENCH"
        sh = s.display.shading
        sh.light = "STUDIO"; sh.color_type = "OBJECT"; sh.show_shadows = True; sh.show_cavity = True
        # v0.9.4 — 블록아웃이 너무 어둡게 나오던 것(AgX 톤맵 + 어두운 색): 표준 뷰 변환 · 밝은 스튜디오 라이트 · 윤곽선 · 하늘색 배경 · 너무 검은 색은 살짝 올림
        try:
            s.view_settings.view_transform = "Standard"; s.view_settings.look = "None"; s.view_settings.exposure = 0.0
        except Exception:
            pass
        for sl in ("outdoor.sl", "studio.sl", "DEFAULT"):
            try:
                sh.studio_light = sl; break
            except Exception:
                continue
        try:
            sh.show_object_outline = True; sh.object_outline_color = (0.02, 0.02, 0.03)
            sh.cavity_type = "BOTH"; sh.curvature_ridge_factor = 1.0; sh.curvature_valley_factor = 1.0
            sh.background_type = "VIEWPORT"; sh.background_color = (0.62, 0.72, 0.86)
            sh.shadow_intensity = 0.35
        except Exception as e:
            print("[ARIFE] workbench 옵션:", e)
        for o in bpy.data.objects:
            try:
                if o.type == "MESH":
                    c = o.color; lum = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
                    if lum < 0.06: k = 0.06 / max(lum, 1e-4); o.color = (min(1, c[0] * k + 0.03), min(1, c[1] * k + 0.03), min(1, c[2] * k + 0.03), 1.0)
            except Exception:
                pass
    # Blender 5.x — image_settings.media_type(IMAGE/VIDEO) 가 생겨 먼저 VIDEO 로 두지 않으면
    # file_format 에 FFMPEG 가 없다(사장님 실측: enum "FFMPEG" not found in ('AVIF')).
    try:
        if hasattr(r.image_settings, "media_type"):
            r.image_settings.media_type = "VIDEO"
    except Exception:
        pass
    r.image_settings.file_format = "FFMPEG"
    try:
        r.ffmpeg.format = "MPEG4"; r.ffmpeg.codec = "H264"; r.ffmpeg.constant_rate_factor = "MEDIUM"
    except Exception as e:
        print("[ARIFE] ffmpeg 설정:", e)
    out = _render_out_path(payload.get("filename") or "arife_blockout")
    if os.path.exists(out):
        try: os.remove(out)
        except OSError: pass
    r.filepath = out
    t0 = time.time()
    bpy.ops.render.render(animation=True)
    if not os.path.isfile(out):
        raise RuntimeError("렌더 파일이 생기지 않았습니다: " + out)
    s.arife_render_file = out
    frames = s.frame_end - s.frame_start + 1
    return {"file": out, "frames": frames, "seconds": round(frames / r.fps, 2), "width": r.resolution_x, "height": r.resolution_y, "render_sec": round(time.time() - t0, 1)}


_RENDER_NOW_ORIG = _render_now   # v0.9.9 — 원본 보관(exec 코드가 _render_now 이름을 바꿔치기해도 렌더는 이걸로)


def _upload_render_file(auth, filepath, title):
    """스레드에서: 파일을 보관함(Storage)에 올리고 media_assets 줄을 넣은 뒤 공개 주소를 돌려준다."""
    a = _ensure_token(auth)
    uid = _jwt_sub(a["access_token"])
    base = os.path.splitext(os.path.basename(filepath))[0] or "render"
    object_path = f"{uid}/blender/{time.strftime('%Y%m%d-%H%M%S')}-{base}.mp4"
    quoted = urllib.parse.quote(object_path)
    _upload_file_with_progress(
        SUPABASE_URL + f"/storage/v1/object/{UPLOAD_BUCKET}/{quoted}", filepath,
        headers={"apikey": a["anon"], "Authorization": "Bearer " + a["access_token"], "Content-Type": "video/mp4", "x-upsert": "true"},
        progress_cb=lambda p: _state.__setitem__("upload_progress", p))
    _state["upload_progress"] = -1.0
    try:
        _http("POST", SUPABASE_URL + "/rest/v1/media_assets",
              body={"user_id": uid, "bucket_id": UPLOAD_BUCKET, "object_path": object_path, "kind": "output_video",
                    "mime_type": "video/mp4", "title": title, "source_tool": "blender", "byte_size": os.path.getsize(filepath)},
              headers={"apikey": a["anon"], "Authorization": "Bearer " + a["access_token"], "Prefer": "return=minimal"})
    except Exception as e:
        print("[ARIFE·AI] media_assets 기록 실패:", e)
    return SUPABASE_URL + f"/storage/v1/object/public/{UPLOAD_BUCKET}/{quoted}"


def _execute_command(cmd):
    """메인 스레드. 결과를 _agent_out 에 넣는다."""
    cid, kind, payload = cmd["id"], cmd["kind"], cmd.get("payload") or {}
    _agent["current"] = kind + (" · " + str(payload.get("note")) if payload.get("note") else "")
    _bar["pulse"] = time.time()
    try:
        if kind == "ping":
            _agent_out.put((cid, True, {"pong": True, "blender": bpy.app.version_string}))
        elif kind == "scene":
            _agent_out.put((cid, True, _scene_summary()))
        elif kind == "run":
            ok, res = _exec_code(str(payload.get("code") or ""))
            _agent_log(("run ✓ " if ok else "run ✗ ") + str(payload.get("note") or "")[:40] + (f" ({res.get('elapsed')}s)" if ok else ""))
            _agent_out.put((cid, ok, res))
        elif kind == "block":
            # 사이트(아리페 페이지)에서 온 요청 — 블록 컴퓨터를 돌리고, 렌더를 올린 주소까지 돌려준다(실사화는 사이트가 한다)
            if _block["busy"]:
                _agent_out.put((cid, False, {"error": "아리페가 이미 진행 중입니다."})); return
            s = bpy.context.scene
            prompt = str(payload.get("prompt") or "").strip()
            if not prompt:
                _agent_out.put((cid, False, {"error": "프롬프트가 비었습니다."})); return
            s.arife_ai_prompt = prompt
            if payload.get("model"): s.arife_ai_model = str(payload["model"])
            if payload.get("resolution") in ("480p", "720p"): s.arife_ai_resolution = payload["resolution"]
            try: seconds = float(payload.get("duration") or 5)
            except Exception: seconds = 5.0
            fps = s.render.fps or 24
            frames = max(24, int(round(seconds * fps)))
            w, h = (1280, 720) if s.arife_ai_resolution == "720p" else (960, 540)
            p = {"fps": fps, "frames": frames, "seconds": seconds, "w": w, "h": h, "model": s.arife_ai_model,
                 "prompt": prompt, "confirm": True, "keep": bool(payload.get("keep")), "from_web": True}
            _block.update(busy="plan", steps=[], step_i=0, await_confirm=False, log=[], video_prompt="", title="", web_result=None)
            _agent["block_cmd"] = cid; _agent["block_sent"] = 0
            _block_log("사이트에서 요청 — 계획 짜는 중 …")
            auth = _snapshot_auth()
            threading.Thread(target=ARIFE_OT_block_run._work, args=(auth, p), daemon=True).start()
            # 결과는 스레드 쪽(2-b)이 적는다 — 여기서는 running 그대로 둔다
            return
        elif kind == "render":
            _agent["busy_render"] = True
            _agent_log("render 시작 …")
            info = _RENDER_NOW_ORIG(payload)   # v0.9.9 — 두뇌 코드가 _render_now 를 몽키패치해도(실측: 프레임 범위 120~300 강제) 원본으로 렌더
            _agent_log(f"render ✓ {info['frames']}f {info['render_sec']}s → 업로드")
            # 업로드는 스레드에서 — 결과는 스레드가 완성한다
            _agent_out.put((cid, "upload", info))
        else:
            _agent_out.put((cid, False, {"error": "모르는 명령: " + kind}))
    except Exception as e:
        _agent_log("✗ " + str(e)[:80])
        _agent_out.put((cid, False, {"error": str(e), "traceback": _tb.format_exc()[-4000:]}))
    finally:
        _agent["busy_render"] = False
        _agent["current"] = ""
        _agent["last_cmd"] = time.time()


# ── 스레드: 심장박동 · 명령 가져오기 · 결과 올리기 ─────────────
def _agent_loop(auth):
    hdr = lambda a: {**_rest_headers(a), "Content-Type": "application/json"}
    while not _agent["stop"]:
        try:
            a = _ensure_token(auth)
            uid = _jwt_sub(a["access_token"])
            now = time.time()
            # 1) 심장박동
            if now - _agent["last_beat"] >= AGENT_BEAT_SEC:
                info = dict(_agent["info"])
                _http("POST", SUPABASE_URL + "/rest/v1/blender_agents",
                      body={"user_id": uid, "last_seen": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "enabled": True,
                            "addon_version": AGENT_VERSION, "blender_version": info.get("blender", ""), "file_name": info.get("file", ""), "scene_name": info.get("scene", "")},
                      headers={**hdr(a), "Prefer": "resolution=merge-duplicates,return=minimal"})
                _agent["last_beat"] = now
            # 2) 완료된 결과 올리기 (렌더 업로드 포함)
            while True:
                try:
                    cid, ok, res = _agent_out.get_nowait()
                except queue.Empty:
                    break
                if ok == "upload":
                    try:
                        url = _upload_render_file(a, res["file"], f"AI 블록아웃 렌더 {os.path.basename(res['file'])}")
                        res = {**res, "video_url": url}
                        ok = True
                        _agent_log("업로드 ✓")
                    except Exception as e:
                        ok, res = False, {**res, "error": "업로드 실패: " + str(e)}
                _http("PATCH", SUPABASE_URL + f"/rest/v1/blender_commands?id=eq.{cid}",
                      body={"status": "done" if ok else "error", "result": res, "finished_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
                      headers={**hdr(a), "Prefer": "return=minimal"})
            # 3) 새 명령 하나 (렌더 중이면 쉬기)
            # 2-b) 사이트에서 온 block 명령의 진행 로그를 그 줄에 적는다(2초마다), 끝나면 done/error
            if _agent["block_cmd"]:
                cid = _agent["block_cmd"]
                steps = [{"note": str(st.get("note") or ""), "code": _strip_fences(str(st.get("code") or ""))[:6000]} for st in (_block["steps"] or [])]
                step_done = (max(0, _block["step_i"] - 1) if _block["busy"] == "build" else len(steps))
                if _block["busy"]:
                    if len(_block["log"]) != _agent["block_sent"] or int(now) % 4 == 0:
                        _agent["block_sent"] = len(_block["log"])
                        _http("PATCH", SUPABASE_URL + f"/rest/v1/blender_commands?id=eq.{cid}",
                              body={"result": {"progress": _block["log"][-8:], "status_text": _block["status"], "stage": _block["busy"], "steps": steps, "step_done": step_done, "title": _block["title"]}},
                              headers={**hdr(a), "Prefer": "return=minimal"})
                else:
                    res = _block.get("web_result") or {"error": _block["status"] or "실패"}
                    ok = "error" not in res
                    _http("PATCH", SUPABASE_URL + f"/rest/v1/blender_commands?id=eq.{cid}",
                          body={"status": "done" if ok else "error", "result": {**res, "progress": _block["log"][-8:], "steps": steps, "step_done": len(steps), "title": _block["title"]}, "finished_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
                          headers={**hdr(a), "Prefer": "return=minimal"})
                    _agent["block_cmd"] = ""
            if not _agent["busy_render"] and not _agent["current"] and not _agent["block_cmd"]:
                rows = _http("GET", SUPABASE_URL + "/rest/v1/blender_commands?select=id,kind,payload&status=eq.queued&source=in.(mcp,web)&order=created_at.asc&limit=1",
                             headers=_rest_headers(a))
                if isinstance(rows, list) and rows:
                    cmd = rows[0]
                    _http("PATCH", SUPABASE_URL + f"/rest/v1/blender_commands?id=eq.{cmd['id']}&status=eq.queued",
                          body={"status": "running", "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
                          headers={**hdr(a), "Prefer": "return=minimal"})
                    _agent["current"] = cmd["kind"]
                    _run_on_main(lambda c=cmd: _execute_command(c))
            _agent["error"] = ""
        except urllib.error.HTTPError as e:
            _agent["error"] = f"서버 {e.code}: {_auth_error_text(e)[:80]}"
            time.sleep(3)
        except Exception as e:
            _agent["error"] = str(e)[:100]
            time.sleep(3)
        time.sleep(AGENT_POLL_SEC)
    # 끄기: 마지막으로 enabled=false
    try:
        a = _ensure_token(auth)
        uid = _jwt_sub(a["access_token"])
        _http("PATCH", SUPABASE_URL + f"/rest/v1/blender_agents?user_id=eq.{uid}", body={"enabled": False},
              headers={**hdr(a), "Prefer": "return=minimal"})
    except Exception:
        pass


def _agent_start():
    if _agent["on"]: return
    auth = _snapshot_auth()
    if not auth["refresh_token"] and not auth["access_token"]:
        raise RuntimeError("Sign in first.")
    _agent.update(on=True, stop=False, last_beat=0.0, error="", current="")
    _agent_log("AI 조종 켜짐")
    t = threading.Thread(target=_agent_loop, args=(auth,), daemon=True)
    _agent["thread"] = t
    t.start()


def _agent_stop():
    if not _agent["on"]: return
    _agent["stop"] = True
    _agent["on"] = False
    _agent_log("AI 조종 꺼짐")


def _agent_tick():
    """메인 스레드 타이머(1초): 스레드가 읽을 씬 정보를 갱신하고 화면을 다시 그린다."""
    try:
        s = bpy.context.scene
        _agent["info"] = {"blender": bpy.app.version_string, "file": os.path.basename(bpy.data.filepath) if bpy.data.filepath else "", "scene": s.name if s else ""}
    except Exception:
        pass
    try:
        wm = bpy.context.window_manager
        # v0.9.11 — 렌더·창 전환 뒤 모달 오퍼레이터가 죽었는데 플래그만 True 로 남아 바가 클릭을 안 받던 것(실측: 로고·화살표 눌러도 안 닫힘) → 실제 살아 있는지 확인
        if _bar["modal"]:
            try:
                alive = any(op.bl_idname == "ARIFE_OT_bar_modal" for w in wm.windows for op in getattr(w, "modal_operators", []))
                if not alive: _bar["modal"] = False
            except Exception:
                pass
        if not _bar["modal"] and wm.windows and (getattr(wm, "arife_bar_show", True) or getattr(wm, "arife_bar_symbol", True)):
            win = wm.windows[0]
            with bpy.context.temp_override(window=win, screen=win.screen):
                bpy.ops.arife.bar_modal("INVOKE_DEFAULT")
    except Exception:
        pass
    if _agent["on"] or _agent["current"]:
        _redraw()
    return 1.0


class ARIFE_OT_ai_toggle(bpy.types.Operator):
    bl_idname = "arife.ai_toggle"
    bl_label = "AI 조종 켜기/끄기"
    bl_description = "ARIFE 커넥터로 붙은 Claude · Codex · Cursor 가 이 Blender 를 조종할 수 있게 한다. 켜기 전에 파일을 저장하세요"

    def execute(self, context):
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network)."); return {"CANCELLED"}
        try:
            if _agent["on"]: _agent_stop()
            else:
                _agent_start()
                if bpy.data.is_dirty:
                    self.report({"WARNING"}, "저장 안 된 변경이 있습니다 — AI 가 파일을 고치기 전에 Ctrl+S 를 권합니다")
                if not _bar["modal"]:
                    bpy.ops.arife.bar_modal("INVOKE_DEFAULT")
        except Exception as e:
            self.report({"ERROR"}, str(e)); return {"CANCELLED"}
        return {"FINISHED"}


class ARIFE_OT_ask_ai(bpy.types.Operator):
    bl_idname = "arife.ask_ai"
    bl_label = "AI 블록아웃 짜기"
    bl_description = "지금 프롬프트·설정을 연결된 AI(Claude 등)에게 보낸다. 채팅에서 \"Blender 에서 보낸 요청 봐줘\" 하면 AI 가 blender_inbox 로 받아 씬을 짠다"

    def execute(self, context):
        s = context.scene
        prompt = (s.arife_ai_prompt or "").strip()
        if not prompt:
            self.report({"ERROR"}, "프롬프트를 먼저 적어 주세요."); return {"CANCELLED"}
        if not _agent["on"]:
            self.report({"ERROR"}, "AI 조종을 먼저 켜 주세요."); return {"CANCELLED"}
        auth = _snapshot_auth()
        payload = {"prompt": prompt, "model": s.arife_ai_model, "resolution": s.arife_ai_resolution,
                   "duration": _ai_duration(s), "fps": s.render.fps, "frame_start": s.frame_start, "frame_end": s.frame_end,
                   "file": bpy.data.filepath or "", "wants": "blockout+render"}
        def work():
            try:
                a = _ensure_token(auth); uid = _jwt_sub(a["access_token"])
                _http("POST", SUPABASE_URL + "/rest/v1/blender_commands",
                      body={"user_id": uid, "kind": "ask", "source": "addon", "payload": payload, "status": "queued"},
                      headers={**_rest_headers(a), "Content-Type": "application/json", "Prefer": "return=minimal"})
                _bar["msg"] = "AI 에게 보냈습니다 — 채팅에서 「Blender 에서 보낸 요청 봐줘」"
                _agent_log("ask → AI 에게 보냄")
            except Exception as e:
                _bar["msg"] = "보내기 실패: " + str(e)[:60]
        threading.Thread(target=work, daemon=True).start()
        return {"FINISHED"}


def _ai_duration(s):
    m = s.arife_ai_model
    return {"seedance25": s.arife_ai_duration_sd, "geminiOmni": s.arife_ai_duration_gm, "vaceDepth": s.arife_ai_duration_vace,
            "vacePose": s.arife_ai_duration_vace, "wan30": s.arife_ai_duration_wan, "hailuo3": s.arife_ai_duration_h3}.get(m, s.arife_ai_duration)


class ARIFE_OT_local_render(bpy.types.Operator):
    bl_idname = "arife.local_render"
    bl_label = "워크벤치 렌더"
    bl_description = "현재 씬을 워크벤치(회색)로 mp4 렌더하고 「렌더 파일」칸에 넣는다 (크레딧 0)"

    def execute(self, context):
        try:
            info = _render_now({"fps": context.scene.render.fps, "width": context.scene.render.resolution_x or 1280, "height": context.scene.render.resolution_y or 720, "filename": "arife_blockout"})
            self.report({"INFO"}, f"렌더 완료 {info['frames']}프레임 · {info['file']}")
            _bar["msg"] = f"렌더 완료 · {info['seconds']}초 · ↑ 로 AI 영상 만들기"
        except Exception as e:
            self.report({"ERROR"}, str(e)); return {"CANCELLED"}
        return {"FINISHED"}


def _edit_start(context):
    """v0.9.14 — 바의 프롬프트 칸을 그 자리에서 편집 상태로 (팝업 없음).
    영문·숫자·기호만 — 블렌더는 한글 IME 를 모달 오퍼레이터에 넘겨 주지 않는다(한글이 필요하면 N 패널 칸이나 사이트에서)."""
    t = (context.scene.arife_ai_prompt or "")
    _bar["edit"] = {"text": t, "caret": len(t), "scroll": 0}
    try:
        bpy.app.timers.register(_edit_tick, first_interval=0.0)
    except Exception:
        pass
    _redraw()


def _edit_stop(context, apply=True):
    ed = _bar.get("edit")
    _bar["edit"] = None
    if ed is not None and apply:
        try:
            context.scene.arife_ai_prompt = ed["text"].strip()
        except Exception:
            pass
    _redraw()


def _open_prompt_at_field(context):
    """v0.9.13 — 프롬프트 칸 "그 자리"에 글상자를 띄운다.
    블렌더 팝업은 무는 자리(마우스)에서 열리고 cursor_warp 는 다음 마우스 이벤트부터 반영되므로,
    커서를 칸 왼쪽으로 옮긴 뒤 아주 짧은 타이머(0.03초)로 연다 — 그러면 글상자가 칸을 덮어 "칸에 바로 쓰는" 모양이 된다.
    (gpu 로 그린 바 안에서 직접 타이핑은 블렌더가 한글 IME 를 모달 오퍼레이터에 넘겨 주지 않아 불가능 — 진짜 글상자만 한글이 된다.)"""
    rect = _bar.get("hit_abs")
    try:
        if rect:
            x, y, w, h = rect
            _bar["prompt_w"] = int(w)
            context.window.cursor_warp(int(x + 10), int(y + h * 0.5))
    except Exception:
        pass

    def _go():
        try:
            wm = bpy.context.window_manager
            win = wm.windows[0]
            with bpy.context.temp_override(window=win, screen=win.screen):
                bpy.ops.arife.bar_prompt("INVOKE_DEFAULT")
        except Exception as e:
            print("[ARIFE] 프롬프트 글상자:", e)
        return None

    try:
        bpy.app.timers.register(_go, first_interval=0.03)
    except Exception:
        _go()


class ARIFE_OT_bar_prompt(bpy.types.Operator):
    bl_idname = "arife.bar_prompt"
    bl_label = "프롬프트"
    bl_description = "완성 화면을 그림 그리듯: 무엇이, 어떤 재질·빛, 카메라, 시간 순서"
    text: bpy.props.StringProperty(name="프롬프트", default="")

    def invoke(self, context, event):
        # v0.9.13 — 칸과 같은 너비로 열고, 글상자는 씬 속성에 바로 쓴다(Enter 확정 · Esc 되돌림 — OK/취소 단추 없음)
        wm = context.window_manager
        try:
            scale = float(context.preferences.system.ui_scale) or 1.0
        except Exception:
            scale = 1.0
        w = int(max(320, _bar.get("prompt_w", 560)) / max(0.5, scale))
        try:
            return wm.invoke_popup(self, width=w)
        except TypeError:
            return wm.invoke_popup(self)

    def draw(self, context):
        col = self.layout.column()
        col.activate_init = True            # 뜨자마자 커서가 글상자 안에 — 클릭 없이 바로 타이핑
        col.prop(context.scene, "arife_ai_prompt", text="", placeholder="무엇이, 어떤 재질·빛, 카메라, 시간 순서 — Enter")

    def execute(self, context):
        _redraw()
        return {"FINISHED"}


class ARIFE_OT_bar_connect(bpy.types.Operator):
    bl_idname = "arife.bar_connect"
    bl_label = "블렌더 연결 — 연결 코드 붙여넣기"
    bl_description = "arifeart.kr › 마이페이지 › 블렌더 연결에서 복사한 1회용 코드를 붙여넣고 「연결」"
    bl_options = {"REGISTER", "INTERNAL"}
    code: bpy.props.StringProperty(name="연결 코드", default="")

    def invoke(self, context, event):
        # 클립보드에 방금 복사한 코드가 있으면 미리 채워 준다 — 붙여넣기 한 번을 아낀다.
        try:
            clip = (context.window_manager.clipboard or "").strip()
            if clip.startswith("http") and "supabase" in clip:
                self.code = clip
        except Exception:
            pass
        wm = context.window_manager
        try:
            return wm.invoke_props_dialog(self, width=520, confirm_text="연결", title="아리페 — 블렌더 연결")
        except TypeError:
            return wm.invoke_props_dialog(self, width=520)

    def draw(self, context):
        col = self.layout.column(align=True)
        col.label(text="① 브라우저: arifeart.kr › 마이페이지 › 블렌더 연결 › 「연결 코드 발급」", icon="URL")
        col.label(text="② 「복사」 를 누르면 코드가 복사됩니다 (1회용 · 몇 분 안에 써야 함)", icon="COPYDOWN")
        col.label(text="③ 아래 칸을 누르고 Ctrl+V 로 붙여넣은 뒤 「연결」 을 누르세요", icon="PASTEDOWN")
        col.separator()
        row = col.row(); row.scale_y = 1.35
        try:
            row.prop(self, "code", text="", placeholder="여기에 붙여넣기 (https://… 로 시작)")
        except TypeError:
            row.prop(self, "code", text="")
        code = (self.code or "").strip()
        if code and not code.startswith("http"):
            col.label(text="연결 코드는 https:// 로 시작합니다 — 사이트에서 「복사」 를 다시 눌러 주세요", icon="ERROR")
        elif code:
            col.label(text="코드가 들어왔습니다 — 「연결」 을 누르세요", icon="CHECKMARK")
        else:
            col.label(text="아직 비어 있습니다 — 사이트에서 발급한 코드를 붙여넣어 주세요", icon="INFO")

    def execute(self, context):
        code = (self.code or "").strip()
        if not code:
            self.report({"ERROR"}, "연결 코드가 비어 있습니다. 사이트 › 마이페이지 › 블렌더 연결에서 발급해 주세요.")
            return {"CANCELLED"}
        if not code.startswith("http"):
            self.report({"ERROR"}, "연결 코드는 https:// 로 시작합니다. 사이트에서 「복사」 를 다시 눌러 주세요.")
            return {"CANCELLED"}
        context.window_manager.arife_connect_code = code
        return bpy.ops.arife.connect()


class ARIFE_OT_bar_pick(bpy.types.Operator):
    bl_idname = "arife.bar_pick"
    bl_label = "설정"
    which: bpy.props.StringProperty(default="model")

    def invoke(self, context, event):
        return context.window_manager.invoke_props_dialog(self, width=360)

    def draw(self, context):
        s = context.scene
        col = self.layout.column()
        if self.which == "model":
            col.prop(s, "arife_ai_model", text="")
        elif self.which == "res":
            col.prop(s, "arife_ai_resolution", expand=True)
        elif self.which == "dur":
            m = s.arife_ai_model
            prop = {"seedance25": "arife_ai_duration_sd", "geminiOmni": "arife_ai_duration_gm", "vaceDepth": "arife_ai_duration_vace",
                    "vacePose": "arife_ai_duration_vace", "wan30": "arife_ai_duration_wan", "hailuo3": "arife_ai_duration_h3"}.get(m, "arife_ai_duration")
            col.prop(s, prop, text="")
        elif self.which == "refs":
            for g, label in REF_GROUPS:
                sub = col.column(align=True)
                sub.label(text=label)
                for i in (1, 2, 3):
                    sub.prop(s, f"arife_ref_{g}{i}", text="")
        elif self.which == "render":
            col.prop(s, "arife_render_file", text="렌더 파일")
            col.label(text="비워 두면 출력 경로에서 자동으로 찾는다", icon="INFO")
        elif self.which == "url":
            col.prop(s, "arife_media_url", text="영상 주소")
            col.operator("arife.import_url", icon="IMPORT")
        elif self.which == "extra":
            col.prop(s, "arife_ai_anchor")
            col.prop(s, "arife_ai_refine")
        elif self.which == "bar":
            wm = context.window_manager
            col.label(text=AGENT_NAME, icon="COMMUNITY")
            col.prop(wm, "arife_block_confirm")
            col.prop(wm, "arife_block_keep")
            col.separator()
            col.prop(wm, "arife_bar_symbol")
            col.prop(wm, "arife_bar_show")
            col.separator()
            col.operator("arife.ai_toggle", text=("AI 조종 끄기" if _agent["on"] else "AI 조종 켜기 (Claude·Codex·Cursor)"), icon="COMMUNITY", depress=_agent["on"])
            col.label(text="계획·코드는 무료(Gemini), 실사화만 모델 요금", icon="INFO")
            for line in _block["log"][-4:]:
                col.label(text=line[:60])

    def execute(self, context):
        return {"FINISHED"}


# ── 오버레이 그리기 (gpu) ──────────────────────────────────────
_draw_handle = None
_font_id = 0


def _load_font():
    """한글이 나오는 글꼴. Blender 에 든 국제 글꼴을 찾고, 없으면 기본(0)."""
    global _font_id
    try:
        import blf
        base = bpy.utils.system_resource("DATAFILES", path="fonts")
        for name in os.listdir(base):
            low = name.lower()
            if ("cjk" in low or "droid" in low or "noto" in low) and low.endswith((".ttf", ".otf", ".ttc")):
                fid = blf.load(os.path.join(base, name))
                if fid >= 0:
                    _font_id = fid; return
    except Exception:
        pass
    _font_id = 0


def _rect(x, y, w, h, color, radius=0):
    import gpu
    from gpu_extras.batch import batch_for_shader
    pts = []
    if radius > 0:
        import math
        r = min(radius, w / 2, h / 2)
        for cx, cy, a0 in ((x + w - r, y + h - r, 0), (x + r, y + h - r, 90), (x + r, y + r, 180), (x + w - r, y + r, 270)):
            for k in range(0, 91, 15):
                ang = math.radians(a0 + k)
                pts.append((cx + r * math.cos(ang), cy + r * math.sin(ang)))
    else:
        pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
    idx = [(0, i, i + 1) for i in range(1, len(pts) - 1)]
    sh = gpu.shader.from_builtin("UNIFORM_COLOR")
    gpu.state.blend_set("ALPHA")
    b = batch_for_shader(sh, "TRIS", {"pos": pts}, indices=idx)
    sh.bind(); sh.uniform_float("color", color); b.draw(sh)


def _outline(x, y, w, h, color, radius=0):
    import gpu, math
    from gpu_extras.batch import batch_for_shader
    pts = []
    r = min(radius, w / 2, h / 2) if radius else 0
    if r:
        for cx, cy, a0 in ((x + w - r, y + h - r, 0), (x + r, y + h - r, 90), (x + r, y + r, 180), (x + w - r, y + r, 270)):
            for k in range(0, 91, 15):
                ang = math.radians(a0 + k); pts.append((cx + r * math.cos(ang), cy + r * math.sin(ang)))
    else:
        pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
    sh = gpu.shader.from_builtin("UNIFORM_COLOR")
    gpu.state.blend_set("ALPHA"); gpu.state.line_width_set(1.2)
    b = batch_for_shader(sh, "LINE_LOOP", {"pos": pts})
    sh.bind(); sh.uniform_float("color", color); b.draw(sh)


def _text(x, y, s, size=12, color=TXT):
    import blf
    blf.size(_font_id, size)
    blf.color(_font_id, *color)
    blf.position(_font_id, x, y, 0)
    blf.draw(_font_id, s)


def _text_w(s, size=12):
    import blf
    blf.size(_font_id, size)
    return blf.dimensions(_font_id, s)[0]


def _symbol(x, y, size, color):
    import gpu
    from gpu_extras.batch import batch_for_shader
    from mathutils.geometry import tessellate_polygon
    sh = gpu.shader.from_builtin("UNIFORM_COLOR")
    gpu.state.blend_set("ALPHA")
    for poly in _SYM:
        pts = [(x + px * size, y + py * size) for px, py in poly]
        tris = tessellate_polygon([[(p[0], p[1], 0.0) for p in pts]])
        b = batch_for_shader(sh, "TRIS", {"pos": pts}, indices=tris)
        sh.bind(); sh.uniform_float("color", color); b.draw(sh)


# ── v0.9.1 바 디자인: 유리판 · 가는 선 · 벡터 아이콘 · 호버 ─────────────────
#   사장님 요청(2026-09-10): 패널 라인·투명화·아이콘. 유니코드 기호(🌐🎞🔑…)는 블렌더 글꼴에
#   없어 네모로 나오던 것 → 전부 gpu 로 그리는 작은 선 아이콘으로 바꿨다.
GLASS = (0.043, 0.059, 0.078, 0.70)        # 판 바탕(투명)
GLASS_HI = (1.0, 1.0, 1.0, 0.06)           # 윗변 하이라이트
GLASS_LINE = (0.780, 0.667, 0.522, 0.22)   # 판 테두리(골드, 옅게)
CARD = (1.0, 1.0, 1.0, 0.030)              # 구역 카드
CARD_LINE = (1.0, 1.0, 1.0, 0.070)
PILL = (1.0, 1.0, 1.0, 0.045)
PILL_LINE = (1.0, 1.0, 1.0, 0.10)
PILL_HOV = (1.0, 1.0, 1.0, 0.10)
PILL_ON = (0.780, 0.667, 0.522, 0.16)
PILL_ON_LINE = (0.780, 0.667, 0.522, 0.60)
CREAM = (0.914, 0.851, 0.761, 1.0)

# 아이콘 — 0..1 좌표(y 위), 선(polyline) 목록. "fill" 은 채우는 다각형.
_ICONS = {
    "play":     {"fill": [[(0.22, 0.1), (0.22, 0.9), (0.88, 0.5)]]},
    "refresh":  {"arc": (0.5, 0.5, 0.36, 40, 320), "fill": [[(0.86, 0.62), (0.92, 0.32), (0.64, 0.42)]]},
    "chat":     {"line": [[(0.12, 0.32), (0.12, 0.86), (0.88, 0.86), (0.88, 0.32), (0.44, 0.32), (0.28, 0.14), (0.28, 0.32), (0.12, 0.32)]]},
    "pencil":   {"line": [[(0.14, 0.14), (0.16, 0.36), (0.72, 0.92), (0.92, 0.72), (0.36, 0.16), (0.14, 0.14)], [(0.6, 0.8), (0.8, 0.6)]]},
    "film":     {"line": [[(0.1, 0.2), (0.9, 0.2), (0.9, 0.8), (0.1, 0.8), (0.1, 0.2)], [(0.3, 0.2), (0.3, 0.8)], [(0.7, 0.2), (0.7, 0.8)], [(0.1, 0.5), (0.9, 0.5)]]},
    "model":    {"line": [[(0.5, 0.92), (0.9, 0.7), (0.9, 0.3), (0.5, 0.08), (0.1, 0.3), (0.1, 0.7), (0.5, 0.92)], [(0.5, 0.92), (0.5, 0.5), (0.9, 0.3)], [(0.5, 0.5), (0.1, 0.3)]]},
    "expand":   {"line": [[(0.1, 0.4), (0.1, 0.1), (0.4, 0.1)], [(0.6, 0.1), (0.9, 0.1), (0.9, 0.4)], [(0.9, 0.6), (0.9, 0.9), (0.6, 0.9)], [(0.4, 0.9), (0.1, 0.9), (0.1, 0.6)]]},
    "clock":    {"arc": (0.5, 0.5, 0.4, 0, 360), "line": [[(0.5, 0.5), (0.5, 0.8)], [(0.5, 0.5), (0.72, 0.4)]]},
    "refs":     {"line": [[(0.1, 0.1), (0.66, 0.1), (0.66, 0.66), (0.1, 0.66), (0.1, 0.1)], [(0.34, 0.66), (0.34, 0.9), (0.9, 0.9), (0.9, 0.34), (0.66, 0.34)]]},
    "plus":     {"line": [[(0.5, 0.14), (0.5, 0.86)], [(0.14, 0.5), (0.86, 0.5)]]},
    "link":     {"line": [[(0.42, 0.3), (0.26, 0.3), (0.12, 0.44), (0.12, 0.56), (0.26, 0.7), (0.42, 0.7)], [(0.58, 0.3), (0.74, 0.3), (0.88, 0.44), (0.88, 0.56), (0.74, 0.7), (0.58, 0.7)], [(0.34, 0.5), (0.66, 0.5)]]},
    "logout":   {"line": [[(0.5, 0.1), (0.12, 0.1), (0.12, 0.9), (0.5, 0.9)], [(0.4, 0.5), (0.9, 0.5)], [(0.72, 0.32), (0.9, 0.5), (0.72, 0.68)]]},
    "key":      {"arc": (0.3, 0.5, 0.2, 0, 360), "line": [[(0.5, 0.5), (0.92, 0.5)], [(0.78, 0.5), (0.78, 0.3)], [(0.9, 0.5), (0.9, 0.34)]]},
    "external": {"line": [[(0.5, 0.1), (0.1, 0.1), (0.1, 0.9), (0.9, 0.9), (0.9, 0.5)], [(0.5, 0.9), (0.9, 0.9)], [(0.55, 0.45), (0.9, 0.1)], [(0.62, 0.1), (0.9, 0.1), (0.9, 0.38)]]},
    "spark":    {"fill": [[(0.5, 0.05), (0.62, 0.38), (0.95, 0.5), (0.62, 0.62), (0.5, 0.95), (0.38, 0.62), (0.05, 0.5), (0.38, 0.38)]]},
    "upload":   {"line": [[(0.5, 0.2), (0.5, 0.88)], [(0.25, 0.63), (0.5, 0.88), (0.75, 0.63)], [(0.12, 0.08), (0.88, 0.08)]]},
    "download": {"line": [[(0.5, 0.85), (0.5, 0.2)], [(0.25, 0.45), (0.5, 0.2), (0.75, 0.45)], [(0.12, 0.08), (0.88, 0.08)]]},
    "power":    {"arc": (0.5, 0.46, 0.36, 120, 420), "line": [[(0.5, 0.95), (0.5, 0.5)]]},
    "inbox":    {"line": [[(0.1, 0.55), (0.1, 0.15), (0.9, 0.15), (0.9, 0.55)], [(0.1, 0.55), (0.3, 0.55), (0.4, 0.4), (0.6, 0.4), (0.7, 0.55), (0.9, 0.55)], [(0.5, 0.9), (0.5, 0.6)]]},
    "hourglass": {"line": [[(0.2, 0.1), (0.8, 0.1), (0.2, 0.9), (0.8, 0.9), (0.2, 0.1)]]},
    "gear":     {"arc": (0.5, 0.5, 0.18, 0, 360), "line": [[(0.5, 0.12), (0.5, 0.26)], [(0.5, 0.74), (0.5, 0.88)], [(0.12, 0.5), (0.26, 0.5)], [(0.74, 0.5), (0.88, 0.5)], [(0.23, 0.23), (0.33, 0.33)], [(0.67, 0.67), (0.77, 0.77)], [(0.23, 0.77), (0.33, 0.67)], [(0.67, 0.33), (0.77, 0.23)]]},
    "chevron":  {"line": [[(0.2, 0.62), (0.5, 0.32), (0.8, 0.62)]]},
    "caret":    {"fill": [[(0.2, 0.7), (0.8, 0.7), (0.5, 0.3)]]},
    "arrow_up": {"line": [[(0.5, 0.12), (0.5, 0.88)], [(0.2, 0.58), (0.5, 0.88), (0.8, 0.58)]]},
    "check":    {"line": [[(0.15, 0.5), (0.4, 0.25), (0.85, 0.8)]]},
    "dot":      {"fill": [[(0.5, 0.2), (0.76, 0.35), (0.76, 0.65), (0.5, 0.8), (0.24, 0.65), (0.24, 0.35)]]},
}


def _icon(name, x, y, size=12, color=TXT, width=1.4):
    """작은 선 아이콘. x,y 는 왼쪽 아래."""
    import gpu, math
    from gpu_extras.batch import batch_for_shader
    from mathutils.geometry import tessellate_polygon
    spec = _ICONS.get(name)
    if not spec: return
    sh = gpu.shader.from_builtin("UNIFORM_COLOR")
    gpu.state.blend_set("ALPHA"); gpu.state.line_width_set(width)
    sh.bind(); sh.uniform_float("color", color)
    for poly in spec.get("fill", []):
        pts = [(x + px * size, y + py * size) for px, py in poly]
        tris = tessellate_polygon([[(p[0], p[1], 0.0) for p in pts]])
        batch_for_shader(sh, "TRIS", {"pos": pts}, indices=tris).draw(sh)
    for line in spec.get("line", []):
        pts = [(x + px * size, y + py * size) for px, py in line]
        batch_for_shader(sh, "LINE_STRIP", {"pos": pts}).draw(sh)
    arc = spec.get("arc")
    if arc:
        cx, cy, r, a0, a1 = arc
        pts = [(x + (cx + r * math.cos(math.radians(a))) * size, y + (cy + r * math.sin(math.radians(a))) * size) for a in range(int(a0), int(a1) + 1, 12)]
        batch_for_shader(sh, "LINE_STRIP", {"pos": pts}).draw(sh)
    gpu.state.line_width_set(1.0)


def _pill(x, y, label, active=False, key=None, w=None, icon=None, tone=None):
    """유리 알약. icon 은 _ICONS 이름. tone='gold' 면 골드 채움(주 동작)."""
    pad = 12
    tw = _text_w(label, 12)
    iw = 18 if icon else 0
    w = w or (tw + iw + pad * 2)
    hov = key is not None and _bar["hover"] == key
    if tone == "gold":
        fill = (GOLD[0], GOLD[1], GOLD[2], 0.95 if hov else 0.85); line = (GOLD[0], GOLD[1], GOLD[2], 1.0); fg = (0.07, 0.08, 0.1, 1.0)
    elif active:
        fill = (PILL_ON[0], PILL_ON[1], PILL_ON[2], 0.24 if hov else PILL_ON[3]); line = PILL_ON_LINE; fg = CREAM
    else:
        fill = PILL_HOV if hov else PILL; line = (1, 1, 1, 0.18) if hov else PILL_LINE; fg = TXT if hov else (TXT[0], TXT[1], TXT[2], 0.9)
    _rect(x, y, w, 28, fill, radius=14)
    _outline(x, y, w, 28, line, radius=14)
    if icon: _icon(icon, x + pad - 1, y + 8, 12, (GOLD if (active or hov) and tone != "gold" else fg))
    _text(x + pad + iw, y + 8, label, 12, fg)
    if key: _bar["hits"][key] = (x, y, w, 28)
    return w


def _badge_num(x, y, n, color=GOLD):
    """구역 번호(작은 골드 원)."""
    _rect(x, y, 16, 16, (color[0], color[1], color[2], 0.18), radius=8)
    _outline(x, y, 16, 16, (color[0], color[1], color[2], 0.7), radius=8)
    _text(x + 5, y + 3, str(n), 10, CREAM)


def _hline(x, y, w, color=CARD_LINE):
    _rect(x, y, w, 1, color)


def _vline(x, y, h, color=CARD_LINE):
    _rect(x, y, 1, h, color)


def _draw_overlay():
    ctx = bpy.context
    region = ctx.region
    if not region or ctx.area.type != "VIEW_3D": return
    wm = ctx.window_manager
    W, H = region.width, region.height
    try:
        _bar["hits"] = {}
        # 심볼 배지 — 뷰포트 왼쪽 아래(타임라인 바로 위). 타임라인을 키우면 뷰포트 바닥이 올라가므로 같이 올라간다.
        # 바가 열리면 바 위로 올라앉는다(바와 같은 애니메이션 진행률 pos 를 쓴다).
        if getattr(wm, "arife_bar_symbol", True):
            import math
            size = 30
            pos0 = _bar_progress()
            _s = ctx.scene
            _prefs_ = _prefs()
            _logged = bool(_prefs_.access_token)
            _tab = _bar["tab"] if _logged else "connect"
            _assets_n = len(wm.arife_assets) if _logged else 0
            bar_h = {"make": 152, "connect": 118, "library": 96 + 24 * max(1, min(6, _assets_n))}[_tab]
            left = 22
            try:
                if bpy.context.preferences.system.use_region_overlap:
                    for rg in ctx.area.regions:
                        if rg.type == "TOOLS" and rg.width > 1: left = rg.width + 14
            except Exception:
                pass
            sx = left + 8
            sy = 16 + 8 + int(pos0 * (bar_h + 14))
            pulse = time.time() - _bar["pulse"] < 2.0 or bool(_agent["current"]) or bool(_block["busy"])
            hov = _bar["hover"] == "symbol"
            opened = _bar["target"] > 0.5
            # 둥근 배지(사장님 그림): 어두운 원판 + 가는 원형 테두리. 테두리 색이 상태를 말한다 —
            #   평소 회색 · 마우스 올림/열림 골드 · 실행 중 골드 맥박 · 오류 빨강 · AI 조종 켜짐 초록 점
            grow = 3 if hov else 0
            dia = size + 22 + grow * 2
            px, py = sx + size / 2 - dia / 2, sy + size / 2 - dia / 2
            _rect(px, py, dia, dia, (INK[0], INK[1], INK[2], 0.80 if hov else 0.62), radius=dia / 2)
            if _agent["error"] or (_block["status"].startswith("✗") if _block["status"] else False):
                ring = (0.86, 0.33, 0.33, 0.95)
            elif pulse:
                ring = (GOLD[0], GOLD[1], GOLD[2], 0.45 + 0.5 * abs(math.sin(time.time() * 3)))
            elif hov or opened:
                ring = (GOLD[0], GOLD[1], GOLD[2], 0.95)
            else:
                ring = (0.55, 0.58, 0.62, 0.75)
            _outline(px, py, dia, dia, ring, radius=dia / 2)
            _symbol(sx - grow / 2, sy - grow / 2, size + grow, GOLD if (hov or opened or pulse) else (GOLD[0], GOLD[1], GOLD[2], 0.8))
            if _agent["on"]:
                _rect(px + dia - 12, py + dia - 12, 8, 8, GREEN, radius=4)
            if hov:
                cap = AGENT_NAME + (" — close" if opened else " — open")
                cw = _text_w(cap, 10)
                _rect(px + dia + 8, py + dia / 2 - 9, cw + 16, 18, (INK[0], INK[1], INK[2], 0.9), radius=9)
                _text(px + dia + 16, py + dia / 2 - 4, cap, 10, (0.914, 0.851, 0.761, 1))
            _bar["hits"]["symbol"] = (px, py, dia, dia)
        pos = _bar_progress()
        if pos <= 0.001: return
        # 입력 바 (아래) — v0.9.1 유리판: 그림자 → 판 → 윗변 하이라이트 → 가는 골드 테두리
        s = ctx.scene
        prefs = _prefs()
        logged = bool(prefs.access_token)
        tab = _bar["tab"] if logged else "connect"
        assets = list(wm.arife_assets)[:6] if logged else []
        mx = 22
        bh = {"make": 152, "connect": 118, "library": 96 + 24 * max(1, len(assets))}[tab]
        bx, bw = mx, W - mx * 2
        by = 16 - int((1.0 - pos) * (bh + 24))
        if bw < 560: return
        import gpu as _gpu
        _gpu.state.blend_set("ALPHA")
        fade = max(0.0, min(1.0, pos))
        hovk = _bar["hover"]
        _rect(bx - 3, by - 6, bw + 6, bh + 6, (0, 0, 0, 0.22 * fade), radius=20)
        _rect(bx, by, bw, bh, (GLASS[0], GLASS[1], GLASS[2], GLASS[3] * fade), radius=18)
        _rect(bx + 18, by + bh - 1.5, bw - 36, 1, (1, 1, 1, GLASS_HI[3] * fade))
        _outline(bx, by, bw, bh, (GLASS_LINE[0], GLASS_LINE[1], GLASS_LINE[2], GLASS_LINE[3] * fade), radius=18)
        # 윗줄: 심볼 · 이름 · 탭(분절 컨트롤) · 상태 · 설정 · 접기
        top = by + bh - 30
        _symbol(bx + 16, top + 2, 18, GOLD)
        _text(bx + 40, top + 5, "ARIFE", 11, GOLD)
        _text(bx + 40 + _text_w("ARIFE", 11) + 6, top + 5, AGENT_NAME, 11, (GOLD[0], GOLD[1], GOLD[2], 0.7))
        tx = bx + 150
        segs = [(k, l, _text_w(l, 11) + 20) for k, l in (("make", "Make"), ("connect", "Connect"), ("library", "Library"))]
        segw = sum(w for _, _, w in segs) + 4
        _rect(tx, top - 3, segw, 24, (1, 1, 1, 0.035), radius=12)
        _outline(tx, top - 3, segw, 24, (1, 1, 1, 0.07), radius=12)
        sx2 = tx + 2
        for key, label, tw in segs:
            on = key == tab; hv = hovk == "tab:" + key
            if on: _rect(sx2, top - 1, tw, 20, (GOLD[0], GOLD[1], GOLD[2], 0.22), radius=10)
            elif hv: _rect(sx2, top - 1, tw, 20, (1, 1, 1, 0.06), radius=10)
            _text(sx2 + 10, top + 5, label, 11, CREAM if on else (TXT if hv else DIM))
            _bar["hits"]["tab:" + key] = (sx2, top - 3, tw, 26)
            sx2 += tw
        tx += segw + 14
        cr = _state["credits"]
        who = (prefs.email.split("@")[0][:3] + "***@" + prefs.email.split("@")[-1]) if "@" in prefs.email else prefs.email
        st = (who + "  ·  credits " + (f"{cr:,}" if cr is not None else "—") + ("  ·  AI control ON" if _agent["on"] else "")) if logged else "Sign in required"
        dotc = GREEN if (logged and _agent["on"]) else ((GOLD[0], GOLD[1], GOLD[2], 0.8) if logged else (0.86, 0.33, 0.33, 0.9))
        _rect(tx, top + 8, 6, 6, dotc, radius=3)
        _text(tx + 12, top + 5, st, 11, DIM)
        right = _agent["current"] or _block["status"] or _state["ai_status"] or _bar["msg"] or _state["status"] or (_agent["error"] and ("⚠ " + _agent["error"])) or ""
        if right:
            right = right[:70]
            tw = _text_w(right, 11)
            _rect(bx + bw - tw - 100, top - 2, tw + 22, 22, (GOLD[0], GOLD[1], GOLD[2], 0.12), radius=11)
            _outline(bx + bw - tw - 100, top - 2, tw + 22, 22, (GOLD[0], GOLD[1], GOLD[2], 0.3), radius=11)
            _text(bx + bw - tw - 89, top + 4, right, 11, CREAM)
        for key, icon, ox in (("opts", "gear", 58), ("close", "chevron", 32)):
            hv = hovk == key
            if hv: _rect(bx + bw - ox - 3, top - 3, 24, 24, (1, 1, 1, 0.08), radius=12)
            _icon(icon, bx + bw - ox + 3, top + 3, 12, TXT if hv else DIM, 1.3)
            _bar["hits"][key] = (bx + bw - ox - 3, top - 3, 24, 24)
        _hline(bx + 14, top - 9, bw - 28, (1, 1, 1, 0.06))

        if tab == "connect":
            y = by + 46
            if logged:
                _text(bx + 16, y + 26, prefs.email + "  ·  credits " + (f"{cr:,}" if cr is not None else "—"), 12, TXT)
                x = bx + 14; y2 = by + 10
                x += _pill(x, y2, "Refresh credits", key="credits", icon="refresh") + 8
                x += _pill(x, y2, "Ping server", key="ping", icon="link") + 8
                x += _pill(x, y2, "Sign out", key="logout", icon="logout") + 8
                x += _pill(x, y2, "AI control — " + ("ON (click to stop)" if _agent["on"] else "OFF (click to start)"), active=_agent["on"], key="agent", icon="power") + 8
                x += _pill(x, y2, "Agent on the website", key="renderreal", icon="chat") + 8
                x += _pill(x, y2, "Send request to MCP inbox", key="ask", icon="inbox") + 8
            else:
                _text(bx + 16, y + 26, "1) Get code -> the website issues a connect code, copy it   2) Paste code -> Ctrl+V -> Connect", 12, TXT)
                x = bx + 14; y2 = by + 10
                x += _pill(x, y2, "1) Get code (website)", key="connectpage", icon="external") + 8
                x += _pill(x, y2, "2) Paste connect code", active=True, key="connect", icon="key") + 8
                x += _pill(x, y2, "Ping server", key="ping", icon="link") + 8
            return

        if tab == "library":
            y = by + bh - 58
            _text(bx + 16, y + 6, "Recent outputs — click to import into the VSE", 11, DIM)
            for i, it in enumerate(assets):
                ry = y - 24 * (i + 1) + 2
                title = it.title if len(it.title) <= 60 else it.title[:59] + "…"
                hv = hovk == "asset:" + str(i)
                _rect(bx + 14, ry - 2, bw - 28, 22, (1, 1, 1, 0.07 if hv else 0.03), radius=8)
                _icon("play" if "video" in (it.mime_type or "") else "refs", bx + 26, ry + 3, 11, GOLD if hv else DIM, 1.2)
                _text(bx + 44, ry + 4, title, 11, TXT)
                _icon("download", bx + bw - 132, ry + 3, 11, BLUE, 1.2)
                _text(bx + bw - 116, ry + 4, "Import to VSE", 11, BLUE)
                _bar["hits"]["asset:" + str(i)] = (bx + 14, ry - 2, bw - 28, 22)
            if not assets:
                _text(bx + 26, y - 18, "Empty — press Refresh", 11, DIM)
            x = bx + 14; y2 = by + 10
            x += _pill(x, y2, "Refresh", key="libfetch", icon="refresh") + 8
            x += _pill(x, y2, "Website library", key="libpage", icon="external") + 8
            x += _pill(x, y2, "Import from URL", key="liburl", icon="download") + 8
            x += _pill(x, y2, "Upload render only", key="uploadonly", icon="upload") + 8
            return

        # ── 만들기 탭 ──  프롬프트 칸(유리) → ① 장면 → ② 실사화
        py, ph = by + 80, 34
        phov = hovk == "prompt"
        _rect(bx + 14, py, bw - 28, ph, (1, 1, 1, 0.06 if phov else 0.04), radius=10)
        _outline(bx + 14, py, bw - 28, ph, (GOLD[0], GOLD[1], GOLD[2], 0.35) if phov else (1, 1, 1, 0.09), radius=10)
        ed = _bar.get("edit")
        editing = ed is not None
        if editing:   # v0.9.14 — 바 안에서 바로 타이핑(영문). 골드 테두리로 편집 중임을 보인다
            _outline(bx + 14, py, bw - 28, ph, (GOLD[0], GOLD[1], GOLD[2], 0.85), radius=10)
        prompt = ed["text"] if editing else (s.arife_ai_prompt or "").strip()
        maxw = bw - 80
        if editing:
            start = min(ed.get("scroll", 0), ed["caret"])
            while _text_w(prompt[start:ed["caret"]], 12) > maxw and start < ed["caret"]:
                start += 1
            ed["scroll"] = start
            shown = prompt[start:]
            while _text_w(shown, 12) > maxw and len(shown) > 1:
                shown = shown[:-1]
            caret_x = _text_w(prompt[start:ed["caret"]], 12)
        else:
            shown = prompt if prompt else PROMPT_HINT
            while _text_w(shown, 12) > maxw and len(shown) > 8:
                shown = shown[:-4] + "…"
        _icon("pencil", bx + 26, py + 11, 12, GOLD if (prompt or editing) else DIM, 1.2)
        _text(bx + 46, py + 11, shown, 12, TXT if (prompt or editing) else DIM)
        if editing and time.time() % 1.0 < 0.55:
            _rect(bx + 46 + caret_x, py + 8, 1.5, 18, CREAM)
        _bar["hits"]["prompt"] = (bx + 14, py, bw - 28, ph)
        busy_block = bool(_block["busy"])
        # 둘째 줄: ① 장면 — 아리페가 장면·움직임을 짜고 워크벤치 렌더까지 (무료)
        x = bx + 14; y = by + 44
        _badge_num(x, y + 6, 1); x += 22
        _text(x, y + 8, "Scene", 11, DIM); x += _text_w("Scene", 11) + 10
        _vline(x, y + 4, 20); x += 12
        x += _pill(x, y, ((_block["status"] or "working")[:26]) if busy_block else AGENT_NAME + " build — scene · motion · render", active=True, key="block", icon="hourglass" if busy_block else "play") + 8
        x += _pill(x, y, "Workbench render only", key="render", icon="refresh") + 8
        x += _pill(x, y, "Chat on the website", key="renderreal", icon="chat") + 8
        x += _pill(x, y, "Polish prompt", key="upgrade", icon="spark") + 8
        # 셋째 줄: ② 실사화 — 렌더 파일 · 모델 · 해상도 · 길이 · 참조 · 옵션 · 크레딧 · ↑
        x = bx + 14; y = by + 10
        _badge_num(x, y + 6, 2); x += 22
        _text(x, y + 8, "Realize", 11, DIM); x += _text_w("Realize", 11) + 10
        _vline(x, y + 4, 20); x += 12
        found = _resolve_render_file(s)
        rf = os.path.basename(found) if found else "no render"
        if len(rf) > 22: rf = rf[:21] + "…"
        x += _pill(x, y, rf + "  ▾", key="renderfile", icon="film") + 8
        model_label = {"ltxRender": "LTX render->real", "seedance25": "Seedance 2.5", "geminiOmni": "Gemini edit", "vaceDepth": "VACE depth", "vacePose": "VACE pose", "wan30": "Wan 3.0", "hailuo3": "Hailuo 3"}.get(s.arife_ai_model, s.arife_ai_model)
        x += _pill(x, y, model_label + "  ▾", key="model", icon="model") + 8
        x += _pill(x, y, s.arife_ai_resolution + "  ▾", key="res", icon="expand") + 8
        x += _pill(x, y, str(_ai_duration(s)) + "s  ▾", key="dur", icon="clock") + 8
        refs = len(_collect_refs(s)) if s.arife_ai_model != "geminiOmni" else 0
        x += _pill(x, y, f"refs {refs}/{MULTI_REF.get(s.arife_ai_model, 1) if s.arife_ai_model in MULTI_REF else 1}  ▾", key="refs", icon="refs") + 8
        extra = []
        if s.arife_ai_anchor: extra.append("first frame")
        if s.arife_ai_refine: extra.append("HQ")
        x += _pill(x, y, (" · ".join(extra) if extra else "Options") + "  ▾", key="extra", icon="plus") + 8
        q = _state["ai_quote"]
        qtxt = (f"~{q} credits · Realize") if q is not None else "credits ? (click) · Realize"
        sw = 34
        shov = hovk == "send"
        _rect(bx + bw - 14 - sw, y - 3, sw, sw, (GOLD[0], GOLD[1], GOLD[2], 1.0 if shov else 0.9), radius=17)
        if shov: _outline(bx + bw - 16 - sw, y - 5, sw + 4, sw + 4, (GOLD[0], GOLD[1], GOLD[2], 0.45), radius=19)
        _icon("arrow_up", bx + bw - 14 - sw + 10, y + 4, 14, (0.07, 0.08, 0.1, 1.0), 1.8)
        _bar["hits"]["send"] = (bx + bw - 14 - sw, y - 3, sw, sw)
        qw = _text_w(qtxt, 12)
        if x + 8 > bx + bw - 14 - sw - 12 - qw:   # 좁은 창 — 글이 알약과 겹치면 짧게
            qtxt = (f"{q} cr" if q is not None else "cr ?"); qw = _text_w(qtxt, 12)
        _text(bx + bw - 14 - sw - 12 - qw, y + 8, qtxt, 12, CREAM if hovk == "quote" else TXT)
        _bar["hits"]["quote"] = (bx + bw - 14 - sw - 12 - qw, y, qw, 28)
        if _state["upload_progress"] >= 0:
            pw = int((bw - 28) * max(0.0, min(1.0, _state["upload_progress"])))
            _rect(bx + 14, by + 2, bw - 28, 3, (1, 1, 1, 0.08), radius=1)
            _rect(bx + 14, by + 2, pw, 3, GOLD, radius=1)
    except Exception as e:
        print("[ARIFE·bar] draw:", e)


class ARIFE_OT_bar_modal(bpy.types.Operator):
    """입력 바의 클릭을 받는다. 바가 켜져 있는 동안만 산다."""
    bl_idname = "arife.bar_modal"
    bl_label = "ARIFE 입력 바"
    bl_options = {"INTERNAL"}

    def invoke(self, context, event):
        if _bar["modal"]: return {"CANCELLED"}
        _bar["modal"] = True
        context.window_manager.modal_handler_add(self)
        return {"RUNNING_MODAL"}

    def _hit(self, context, event):
        for area in context.window.screen.areas:
            if area.type != "VIEW_3D": continue
            for region in area.regions:
                if region.type != "WINDOW": continue
                lx, ly = event.mouse_x - region.x, event.mouse_y - region.y
                if not (0 <= lx < region.width and 0 <= ly < region.height): continue
                for key, (x, y, w, h) in list(_bar["hits"].items()):
                    if x <= lx <= x + w and y <= ly <= y + h:
                        _bar["hit_abs"] = (region.x + x, region.y + y, w, h)   # v0.9.13 — 팝업을 그 칸 자리에 띄우려고
                        return key
        return None

    def modal(self, context, event):
        wm = context.window_manager
        if not (getattr(wm, "arife_bar_show", True) or getattr(wm, "arife_bar_symbol", True)):
            _bar["modal"] = False
            return {"FINISHED"}
        ed = _bar.get("edit")
        if ed is not None:                      # v0.9.14 — 편집 중: 키를 바가 먹는다(뷰포트 단축키로 새지 않게)
            t, v = event.type, event.value
            if t == "LEFTMOUSE" and v == "PRESS":
                if self._hit(context, event) != "prompt":
                    _edit_stop(context, apply=True)          # 칸 밖을 누르면 확정하고 그 클릭은 아래로
                else:
                    return {"RUNNING_MODAL"}
            elif v == "PRESS":
                if t in ("RET", "NUMPAD_ENTER"):
                    _edit_stop(context, apply=True); return {"RUNNING_MODAL"}
                if t == "ESC":
                    _edit_stop(context, apply=False); return {"RUNNING_MODAL"}
                if t == "BACK_SPACE":
                    c = ed["caret"]
                    if event.ctrl:
                        i = c
                        while i > 0 and ed["text"][i - 1] == " ": i -= 1
                        while i > 0 and ed["text"][i - 1] != " ": i -= 1
                        ed["text"] = ed["text"][:i] + ed["text"][c:]; ed["caret"] = i
                    elif c > 0:
                        ed["text"] = ed["text"][:c - 1] + ed["text"][c:]; ed["caret"] = c - 1
                    _redraw(); return {"RUNNING_MODAL"}
                if t == "DEL":
                    c = ed["caret"]
                    if c < len(ed["text"]): ed["text"] = ed["text"][:c] + ed["text"][c + 1:]
                    _redraw(); return {"RUNNING_MODAL"}
                if t == "LEFT_ARROW":
                    ed["caret"] = 0 if event.ctrl else max(0, ed["caret"] - 1); _redraw(); return {"RUNNING_MODAL"}
                if t == "RIGHT_ARROW":
                    ed["caret"] = len(ed["text"]) if event.ctrl else min(len(ed["text"]), ed["caret"] + 1); _redraw(); return {"RUNNING_MODAL"}
                if t == "HOME":
                    ed["caret"] = 0; _redraw(); return {"RUNNING_MODAL"}
                if t == "END":
                    ed["caret"] = len(ed["text"]); _redraw(); return {"RUNNING_MODAL"}
                if t == "V" and event.ctrl:
                    clip = (wm.clipboard or "").replace("\n", " ").replace("\r", "")
                    c = ed["caret"]; ed["text"] = ed["text"][:c] + clip + ed["text"][c:]; ed["caret"] = c + len(clip)
                    _redraw(); return {"RUNNING_MODAL"}
                if t == "C" and event.ctrl:
                    try: wm.clipboard = ed["text"]
                    except Exception: pass
                    return {"RUNNING_MODAL"}
                if t == "A" and event.ctrl:
                    ed["caret"] = len(ed["text"]); _redraw(); return {"RUNNING_MODAL"}
                ch = event.unicode or ""
                if ch and ch.isprintable():
                    c = ed["caret"]; ed["text"] = ed["text"][:c] + ch + ed["text"][c:]; ed["caret"] = c + len(ch)
                    _redraw(); return {"RUNNING_MODAL"}
                if not t.startswith(("MOUSE", "WHEEL", "INBETWEEN", "TRACKPAD", "TIMER")) and t not in ("MIDDLEMOUSE", "RIGHTMOUSE"):
                    return {"RUNNING_MODAL"}                  # 그 밖의 키(글쇠)는 삼킨다
            elif v == "RELEASE" and not t.startswith(("MOUSE", "WHEEL", "INBETWEEN", "TRACKPAD", "TIMER")) and t not in ("MIDDLEMOUSE", "RIGHTMOUSE", "LEFTMOUSE"):
                return {"RUNNING_MODAL"}
        if event.type == "LEFTMOUSE" and event.value == "PRESS":
            key = self._hit(context, event)
            if not key: return {"PASS_THROUGH"}
            if key not in ("symbol",) and _bar["pos"] < 0.95: return {"PASS_THROUGH"}   # 움직이는 중엔 누르지 않는다
            try:
                if key.startswith("tab:"):
                    _bar["tab"] = key[4:]
                    if _bar["tab"] == "library" and not len(wm.arife_assets):
                        try: bpy.ops.arife.fetch_library()
                        except Exception: pass
                elif key.startswith("asset:"):
                    bpy.ops.arife.import_asset(index=int(key[6:]))
                elif key == "connect": bpy.ops.arife.bar_connect("INVOKE_DEFAULT")
                elif key == "connectpage": bpy.ops.arife.open_connect_page()
                elif key == "ping": bpy.ops.arife.ping()
                elif key == "credits": bpy.ops.arife.refresh_credits()
                elif key == "logout": bpy.ops.arife.logout()
                elif key == "agent": bpy.ops.arife.ai_toggle()
                elif key == "renderreal": bpy.ops.arife.open_render_real()
                elif key == "libfetch": bpy.ops.arife.fetch_library()
                elif key == "libpage": bpy.ops.arife.open_library_page()
                elif key == "liburl": bpy.ops.arife.bar_pick("INVOKE_DEFAULT", which="url")
                elif key == "uploadonly": bpy.ops.arife.upload_render()
                elif key == "renderfile": bpy.ops.arife.bar_pick("INVOKE_DEFAULT", which="render")
                elif key == "extra": bpy.ops.arife.bar_pick("INVOKE_DEFAULT", which="extra")
                elif key == "upgrade": bpy.ops.arife.prompt_upgrade()
                elif key == "prompt": _edit_start(context)
                elif key == "block": bpy.ops.arife.block_run()
                elif key == "opts": bpy.ops.arife.bar_pick("INVOKE_DEFAULT", which="bar")
                elif key == "ask": bpy.ops.arife.ask_ai()
                elif key in ("model", "res", "dur", "refs"): bpy.ops.arife.bar_pick("INVOKE_DEFAULT", which=key)
                elif key == "render": bpy.ops.arife.local_render()
                elif key == "quote": bpy.ops.arife.ai_quote()
                elif key == "send":
                    if _state["busy"] or _block["busy"]: _bar["msg"] = "Another job is running"
                    elif _block["await_confirm"]: bpy.ops.arife.block_confirm()
                    elif _resolve_render_file(context.scene): bpy.ops.arife.ai_generate()
                    else: _bar["msg"] = "Run > " + AGENT_NAME + " build, or Workbench render first"
                elif key == "symbol":
                    _bar_set_open(_bar["target"] < 0.5)          # 심볼 = 바 열고 닫기
                    wm.arife_bar_show = _bar["target"] > 0.5
                elif key == "close":
                    _bar_set_open(False)
                    wm.arife_bar_show = False
            except Exception as e:
                _bar["msg"] = str(e)[:70]
            _redraw()
            return {"RUNNING_MODAL"}
        if event.type == "MOUSEMOVE":
            k = self._hit(context, event)
            if k != _bar["hover"]:
                _bar["hover"] = k or ""
                # v0.9.12 — 마우스 포인터는 블렌더 기본 화살표 그대로(손 모양으로 바꾸지 않음 — 사장님 요청)
                _redraw()
        return {"PASS_THROUGH"}


def _overlay_on():
    global _draw_handle
    if _draw_handle is None:
        _load_font()
        _draw_handle = bpy.types.SpaceView3D.draw_handler_add(_draw_overlay, (), "WINDOW", "POST_PIXEL")


def _overlay_off():
    global _draw_handle
    if _draw_handle is not None:
        try: bpy.types.SpaceView3D.draw_handler_remove(_draw_handle, "WINDOW")
        except Exception: pass
        _draw_handle = None


def _draw_ai_box(layout, context):
    """N 패널 「AI 조종」칸 — 기존 단계형 패널의 1(연결) 아래에 끼운다."""
    wm = context.window_manager
    prefs = _prefs()
    logged_in = bool(prefs.access_token)
    box = layout.box()
    head = box.row(align=True)
    head.label(text="AI", icon="BLANK1")
    head.label(text="블록 컴퓨터 · AI 조종", icon="COMMUNITY")
    head.label(text="Claude · Codex · Cursor")
    box.enabled = logged_in
    row = box.row(); row.scale_y = 1.3
    row.operator("arife.ai_toggle", text=("● AI 조종 켜짐 — 끄기" if _agent["on"] else "AI 조종 켜기"), icon=("PAUSE" if _agent["on"] else "PLAY"), depress=_agent["on"])
    if _agent["on"]:
        ago = time.time() - _agent["last_cmd"] if _agent["last_cmd"] else None
        box.label(text=("실행 중: " + _agent["current"]) if _agent["current"] else ("대기 중" + (f" · 마지막 명령 {int(ago)}초 전" if ago else "")), icon="RADIOBUT_ON" if _agent["current"] else "CHECKMARK")
        if _agent["error"]: box.label(text=_agent["error"], icon="ERROR")
    box.prop(wm, "arife_bar_show")
    box.prop(wm, "arife_bar_symbol")
    box.label(text="AI가 이 파일을 직접 고칩니다 — 먼저 저장(Ctrl+S)하세요", icon="ERROR")
    if _agent["log"]:
        col = box.column(align=True)
        for line in list(_agent["log"])[-5:]:
            col.label(text=line[:60])
    box.operator("arife.ask_ai", icon="LIGHT_SUN")


def _bar_toggle_update(self, context):
    _bar_set_open(bool(getattr(self, "arife_bar_show", False)))
    if getattr(self, "arife_bar_show", True) or getattr(self, "arife_bar_symbol", True):
        _overlay_on()
        try:
            if not _bar["modal"]: bpy.ops.arife.bar_modal("INVOKE_DEFAULT")
        except Exception:
            pass
    else:
        _overlay_off()
    _redraw()


V07_CLASSES = (ARIFE_OT_ai_toggle, ARIFE_OT_ask_ai, ARIFE_OT_local_render, ARIFE_OT_bar_prompt, ARIFE_OT_bar_connect, ARIFE_OT_bar_pick, ARIFE_OT_bar_modal)


def _v07_register():
    for c in V07_CLASSES:
        bpy.utils.register_class(c)
    bpy.types.WindowManager.arife_bar_show = bpy.props.BoolProperty(name="아래 입력 바(그린 것)", description="3D 뷰포트 아래에 ARIFE 입력 바를 띄운다 — 위쪽 헤더 줄이 기본이라 보통 끈다", default=False, update=_bar_toggle_update)
    bpy.types.WindowManager.arife_bar_symbol = bpy.props.BoolProperty(name="뷰포트 심볼", description="뷰포트 왼쪽 아래(타임라인 위) ARIFE 배지 — 클릭하면 입력 바가 열리고 닫힌다", default=True, update=_bar_toggle_update)
    if not bpy.app.timers.is_registered(_agent_tick):
        bpy.app.timers.register(_agent_tick, first_interval=1.0, persistent=True)
    # 오버레이는 첫 타이머에서 켠다(등록 시점엔 창 문맥이 없다)
    def _late():
        try: _overlay_on()
        except Exception as e: print("[ARIFE·bar] overlay:", e)
        # 이미 로그인된 상태로 블렌더를 켰으면 아리페(AI 조종)도 같이 켠다 (인터넷 접근이 막혀 있으면 건너뜀)
        try:
            p = _prefs()
            if (p.refresh_token or p.access_token) and not _online_blocked() and not _agent["on"]:
                _agent_start()
        except Exception as e:
            print("[ARIFE] auto agent at start:", e)
        return None
    bpy.app.timers.register(_late, first_interval=0.5)


def _v07_unregister():
    _agent_stop()
    _overlay_off()
    if bpy.app.timers.is_registered(_agent_tick):
        bpy.app.timers.unregister(_agent_tick)
    for c in reversed(V07_CLASSES):
        bpy.utils.unregister_class(c)
    for n in ("arife_bar_show", "arife_bar_symbol"):
        if hasattr(bpy.types.WindowManager, n): delattr(bpy.types.WindowManager, n)



# ═════════════════════════════════════════════════════════════
# v0.8 — 블록 컴퓨터 (ARIFE 안에서 스스로 짜고 · 렌더하고 · AI 영상까지) + 위쪽 헤더 줄(글자 바로 입력)
# ─────────────────────────────────────────────────────────────
# 사장님 요청(2026-09-07): ① 심볼은 블렌더킷 바 아래 ② 프롬프트는 창 없이 바로 입력 ③ 렌더를 따로 안 돌려도
# 영상까지 ④ 힉스필드 "슈퍼컴퓨터"처럼 — 이름은 「블록 컴퓨터」.
#
# 어떻게
#   · 위쪽 줄은 Blender 의 진짜 위젯(VIEW3D_HT_tool_header 에 붙임)이라 한글 입력·드롭다운이 그대로 된다.
#     그려서 흉내 내는 아래 바(v0.7)는 선택으로 남겨 두고 기본은 끈다.
#   · 블록 컴퓨터 = 애드온 안의 작은 에이전트. ARIFE Gemini(text 액션, 무료)에게 bpy 코드를 단계별 JSON 으로 받아
#     이 Blender 에서 실행하고(오류면 고쳐 달라고 다시 물음 ≤2회), 워크벤치로 렌더한 뒤, 기존 「렌더 → AI 영상 만들기」
#     길(arife.ai_generate)로 실사화한다. Claude 가 없어도 Blender 하나로 끝난다. Claude 로 조종하는 길(AI 조종)은 그대로.
#   · 크레딧: 계획·코드는 0, 실사화만 모델 요금. 「생성 전 확인」이 켜져 있으면 렌더 뒤 멈추고 예상 크레딧을 보여 준다.
# ═════════════════════════════════════════════════════════════
BLOCK_VERSION = "0.8.11"
# v0.9.14 — 바(오버레이) 글자는 영어로: 블렌더가 한글 IME 를 모달 오퍼레이터에 안 넘겨 줘 바 안에서 한글 타이핑이 불가능하다.
#   영어로 통일하면 칸을 눌러 그 자리에서 바로 칠 수 있다(프롬프트는 어차피 서버가 영어로 옮겨 보낸다).
PROMPT_HINT = "What, materials & light, camera, order in time — click and type"
AGENT_NAME = "Agent"   # 화면에 보이는 이름 — 여기만 바꾸면 심볼 아래 글자 · 바 제목 · 알약이 같이 바뀐다
_ICON_B64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEXklEQVR42u2XT2gdVRTGv3PvzJt5eUmeEsIrVRStCBrRhVVLtXS60BQhC9GZTaGtq9pCpS6qUdDJENPqwoUgat0qqdyXqgtXLnwBq0lruqpR0GUF0UKpzcu8eTP33uPipRCk+fNCqpue1SzOvfc7v3PPPWeAW3bLNm60wvd/Zz+puDQ3d8rdrCi6WcMzkxM1dngnEVHWzL/d82JydSMCRLcLGo1YAoCVfJAY94P5Ua/ivgAArJS86QKmp2GZY+EI8QqIroGpIOA1AMD8PN9UAY04dpIkseem3JE7twzUGPgbgq7cURu4b7Y+sYuSxKouKXQlYBqwzEzW8qg2lgnWgLkQRGwtXgeAMOyOgrNeR1ZKUhSZ4SFvr+95OxZbGdiSBTEvpBm5jhyeqZ94guiN80opGUWR2VwCS5GxtaMEgJkhQAUImi3DdaUw1o4CYKC+uSlQSkmixP44NfFk2S/tbrXzQggBI40mQAtJaLVy7bly5PvPJx4Ko7pVKpSbSKATUWF41HU6VQhmEJNmiAIMgKB7fE+C7XECGAg3h4BSoYyiup09c/IR13WebaZtS0SSAUgSBQGaO9zlwmLGbsmJZtX4PWEUWY5jsQkEQgBgU+hXy15JgNkCTMwMw9CwVgMAMYgtm0rZ9y3EMQJ4eh37r+rAcSzCKLJnvxjf5rrO8820xSBIdCIGBDRJaDADHe5yMc1YEh2cmZyoBWOJidegsLrCoSEigEnjWKXse2xhOr2AwMwwmg24kwIAIAIZY01fb7nfSnuYCBwE2JiAOI4FwsieU/EWR8j9zTRjxlL0DGIGyEKTgGZmMJiWWpVYbLWZiF6a/TTuD4Ixw8zUtYAggCACa7iH+yrlfmus6aBnA4JmZus60EazYYYlhmawAUC6MKbaW6lZr3SAiHh6ekx21Y7jOBZjAC4+5lfTFv8qpbi90NpKId0evwQAkFLg6sLCw0TuwMBtlUa73anGVqsNw5x7ruMUhb5k4T/wO2bzMFSWiHhdBEZGtkpKEtts6rEevzRgjJXV3h6XwNdaWfvLNCuOpK32rvai+S3jbK650ArSdv5yluVfA0irveVSXmjRU/buhkmPR1HdXLjwibMuAkrFpShK8u8mk6erfZVvhBDI2vklAB9ZKj57/Lk3L612qc5PnbyXiA4AfKhS9mtpO+fFNN2xe19yfm7ulLt9+6FizRTMnB4P/B6/AcKfeZ5/YGz+4c4ouXL9YRocfJAuX/6Zo6huAJBSSgwOzlMQwBIlFgBmJidq5IqjvuccsZbLeZ49tSN668KKBOI4FiND5b4Cep9Xcg85jnPmjyvXPh7en/y1NAk5QTBmbpTHf9+fIIDYsyfRAHB2Mt5arfYfzQu9N8+L9/tQTNXnkSZJRyiulxUAnD09vm22fuKZH9R75eVDyGpltOIjxkzLR7Rfvnq379zU28Nz6p27lp+52uy3oYNvJKTRiJ11OysVyjXVbUgIlvbm/+cf4patZP8Az89A4UgdaVgAAAAASUVORK5CYII="
_icons = None
_block = {"busy": "", "status": "", "steps": [], "step_i": 0, "video_prompt": "", "await_confirm": False, "log": [], "title": "", "web_result": None}

BLOCK_SYSTEM = """You are ARIFE Block Computer, a Blender 3.6-4.x scene-blockout planner. The user describes a short video shot.
Produce ROUGH BLOCKOUT geometry only (cubes, cylinders, spheres, planes, bezier paths) — never detailed modeling, materials, lights or textures.
Return ONLY a JSON object, no prose, no markdown fences:
{"title": "...", "steps": [{"note": "short Korean note", "code": "python"}], "video_prompt": "English prompt for the video AI"}
Rules for code (each step <= 80 lines, total steps <= 6, each step independently re-runnable after the previous ones):
- Only `import bpy, bmesh, math, random` and `from mathutils import Vector, Euler`. No file/network access. No bpy.ops.render.
- Step 1 must clear the scene (delete all objects) unless the user says to keep it, then set scene.frame_start=1, frame_end=FRAMES, render.fps=FPS.
- Give every moving/important object a distinct bright obj.color (RGBA) — workbench renders object color; the video AI tells things apart by color.
- Terrain: subdivided plane + Displace modifier with a Clouds texture; walls/buildings: scaled cubes. Roads/paths: bezier curve; vehicles/characters follow it with a Follow Path constraint (use_fixed_location=True, keyframe offset_factor 0->1 over the frame range) or with location keyframes.
- Always create a camera, set scene.camera, lens 24-35mm, and animate it (Follow Path with offset, or parent to the lead object with an offset, plus a Track To constraint). Camera must see the action.
- Use obj.keyframe_insert(data_path=..., frame=...). Keep everything within ~400m of origin. Do not call bpy.ops in a loop more than 50 times.
- End every step with print() of a checkable fact (object count, camera name, frame range).
Scene parameters: FPS={fps}, FRAMES={frames} ({seconds}s), RESOLUTION={w}x{h}. Model for the video pass: {model}.
video_prompt: describe subjects, materials, light, time of day, camera; end with "keep the camera motion and object placement exactly as in the reference video"."""

BLOCK_FIX = """The following Blender python step failed. Return ONLY the corrected python code for this step (no JSON, no fences).
Step note: {note}
Code:
{code}
Error:
{error}"""


def _block_log(line):
    _block["log"].append(time.strftime("%H:%M:%S ") + line)
    _block["log"] = _block["log"][-8:]
    _block["status"] = line
    print("[ARIFE·블록컴퓨터]", line)


def _icon_id():
    global _icons
    try:
        if _icons is None:
            import bpy.utils.previews
            _icons = bpy.utils.previews.new()
            p = os.path.join(tempfile.gettempdir(), "arife_symbol_32.png")
            if not os.path.exists(p):
                with open(p, "wb") as f:
                    f.write(base64.b64decode(_ICON_B64))
            _icons.load("arife", p, "IMAGE")
        return _icons["arife"].icon_id
    except Exception:
        return 0


def _gemini_text(auth, prompt, system):
    a = _ensure_token(auth)
    data = _http("POST", GEMINI_URL, body={"action": "text", "prompt": prompt, "system": system},
                 headers={"apikey": a["anon"], "Authorization": "Bearer " + a["access_token"]}, timeout=120)
    if not isinstance(data, dict) or not data.get("ok"):
        raise RuntimeError((data or {}).get("error", "Gemini 응답 없음") if isinstance(data, dict) else "Gemini 응답 없음")
    return data.get("text") or ""


def _parse_plan(text):
    t = text.strip()
    t = re.sub(r"^```(?:json)?\s*|\s*```$", "", t, flags=re.S)
    i, j = t.find("{"), t.rfind("}")
    if i < 0 or j < 0:
        raise RuntimeError("계획을 JSON 으로 읽지 못했습니다.")
    plan = json.loads(t[i:j + 1])
    steps = [s for s in plan.get("steps", []) if isinstance(s, dict) and s.get("code")]
    if not steps:
        raise RuntimeError("계획에 실행할 단계가 없습니다.")
    return {"title": str(plan.get("title") or "")[:80], "steps": steps[:8], "video_prompt": str(plan.get("video_prompt") or "")}


def _strip_fences(code):
    return re.sub(r"^```(?:python)?\s*|\s*```$", "", code.strip(), flags=re.S)


class ARIFE_OT_block_run(bpy.types.Operator):
    bl_idname = "arife.block_run"
    bl_label = "블록 컴퓨터 실행"
    bl_description = "프롬프트대로 블록아웃을 짜고(무료) → 워크벤치 렌더 → AI 영상까지. 생성 전 확인이 켜져 있으면 렌더 뒤 멈춘다"

    def execute(self, context):
        s = context.scene
        prompt = (s.arife_ai_prompt or "").strip()
        if not prompt:
            self.report({"ERROR"}, "Type the scene in the field above first."); return {"CANCELLED"}
        if _block["busy"] or _state["busy"]:
            self.report({"WARNING"}, "이미 진행 중입니다."); return {"CANCELLED"}
        if _online_blocked():
            self.report({"ERROR"}, "Blender preferences: internet access is off (System > Network)."); return {"CANCELLED"}
        auth = _snapshot_auth()
        if not auth["refresh_token"] and not auth["access_token"]:
            self.report({"ERROR"}, "Sign in first (N panel > ARIFE)."); return {"CANCELLED"}
        if bpy.data.is_dirty:
            self.report({"WARNING"}, "저장 안 된 변경이 있습니다 — 씬을 새로 짜기 전에 Ctrl+S 를 권합니다")
        wm = context.window_manager
        try:
            seconds = float(_ai_duration(s))
        except Exception:
            seconds = 5.0
        fps = s.render.fps or 24
        frames = max(24, int(round(seconds * fps)))
        w, h = (1280, 720) if s.arife_ai_resolution == "720p" else (960, 540)
        params = {"fps": fps, "frames": frames, "seconds": seconds, "w": w, "h": h, "model": s.arife_ai_model,
                  "prompt": prompt, "confirm": bool(wm.arife_block_confirm), "keep": bool(wm.arife_block_keep)}
        _block.update(busy="plan", steps=[], step_i=0, await_confirm=False, log=[], video_prompt="", title="")
        _block_log("계획 짜는 중 …")
        threading.Thread(target=self._work, args=(auth, params), daemon=True).start()
        return {"FINISHED"}

    @staticmethod
    def _work(auth, p):
        try:
            system = BLOCK_SYSTEM
            for k in ("fps", "frames", "seconds", "w", "h", "model"):
                system = system.replace("{" + k + "}", str(p[k]))   # .format 은 JSON 예시의 중괄호와 충돌한다(실측)
            user = p["prompt"] + ("\n(Keep the existing objects; do not delete them.)" if p["keep"] else "")
            plan = _parse_plan(_gemini_text(auth, user, system))
            _block["steps"] = plan["steps"]; _block["video_prompt"] = plan["video_prompt"]; _block["title"] = plan["title"]
            _block_log(f"계획 {len(plan['steps'])}단계 · {plan['title'] or ''}")
            # 단계 실행 — 메인 스레드에서 하나씩, 결과를 큐로 받는다
            for i, st in enumerate(plan["steps"]):
                _block["step_i"] = i + 1; _block["busy"] = "build"
                code = _strip_fences(str(st["code"]))
                note = str(st.get("note") or f"{i + 1}단계")
                ok, res = ARIFE_OT_block_run._run_main(code)
                tries = 0
                while not ok and tries < 2:
                    tries += 1
                    _block_log(f"{i + 1}/{len(plan['steps'])} 오류 → 고치는 중 ({tries})")
                    fix_prompt = BLOCK_FIX.replace("{note}", note).replace("{code}", code).replace("{error}", res.get("error", "") + "\n" + (res.get("traceback") or "")[-1500:])
                    code = _strip_fences(_gemini_text(auth, fix_prompt, "Return only python code."))
                    ok, res = ARIFE_OT_block_run._run_main(code)
                if not ok:
                    raise RuntimeError(f"{i + 1}단계 실패: {res.get('error')}")
                _block_log(f"{i + 1}/{len(plan['steps'])} ✓ {note[:30]}")
            # 렌더
            _block["busy"] = "render"; _block_log("워크벤치 렌더 중 …")
            q = queue.Queue()
            def do_render():
                try: q.put((True, _render_now({"fps": p["fps"], "width": p["w"], "height": p["h"], "filename": "arife_block", "frame_start": 1, "frame_end": p["frames"]})))
                except Exception as e: q.put((False, {"error": str(e)}))
            _run_on_main(do_render)
            ok, info = q.get(timeout=1800)
            if not ok: raise RuntimeError("렌더 실패: " + info.get("error", ""))
            _block_log(f"렌더 ✓ {info['frames']}프레임 · {info['seconds']}초")
            if p.get("from_web"):
                _block_log("보관함에 올리는 중 …")
                url = _upload_render_file(auth, info["file"], f"아리페 블록아웃 {_block['title'] or ''}".strip())
                _block["web_result"] = {"video_url": url, "frames": info["frames"], "seconds": info["seconds"], "width": info["width"], "height": info["height"],
                                        "video_prompt": _block["video_prompt"], "title": _block["title"], "model": p["model"], "resolution": ("720p" if p["w"] >= 1280 else "480p")}
                _block_log("완료 — 사이트에서 AI 영상을 만들 수 있습니다")
                _block["busy"] = ""
                return
            # 영상 프롬프트를 씬에 넣는다(원문은 보관)
            def set_prompt():
                sc = bpy.context.scene
                if _block["video_prompt"]:
                    if not sc.arife_ai_prompt_orig: sc.arife_ai_prompt_orig = sc.arife_ai_prompt
                    sc.arife_ai_prompt = _block["video_prompt"][:1900]
                try: bpy.ops.arife.ai_quote()
                except Exception: pass
            _run_on_main(set_prompt)
            time.sleep(2.5)
            if p["confirm"]:
                _block["busy"] = ""; _block["await_confirm"] = True
                qv = _state["ai_quote"]
                _block_log(f"렌더 끝 — 예상 {qv if qv is not None else '?'} 크레딧 · ↑ 를 누르면 AI 영상을 만듭니다")
                return
            _block["busy"] = "video"; _block_log("AI 영상 만드는 중 …")
            def go():
                try: bpy.ops.arife.ai_generate()
                except Exception as e: _block_log("생성 시작 실패: " + str(e)[:60])
            _run_on_main(go)
            time.sleep(1.0)
            _block["busy"] = ""
        except Exception as e:
            _block["web_result"] = {"error": str(e)[:200]}
            _block["busy"] = ""
            _block_log("✗ " + str(e)[:90])

    @staticmethod
    def _run_main(code):
        q = queue.Queue()
        _run_on_main(lambda: q.put(_exec_code(code)))
        try:
            return q.get(timeout=600)
        except queue.Empty:
            return False, {"error": "시간 초과(10분)"}


class ARIFE_OT_block_confirm(bpy.types.Operator):
    bl_idname = "arife.block_confirm"
    bl_label = "AI 영상 만들기"
    bl_description = "렌더한 블록아웃으로 AI 영상을 만든다(크레딧 차감)"

    def execute(self, context):
        _block["await_confirm"] = False
        if not _resolve_render_file(context.scene):
            self.report({"ERROR"}, "렌더 파일이 없습니다 — 먼저 ▶ 만들기 또는 렌더."); return {"CANCELLED"}
        return bpy.ops.arife.ai_generate()


def _draw_block_header(self, context):
    """3D 뷰 도구 헤더(블렌더킷 바가 있는 줄)에 붙는 블록 컴퓨터 줄."""
    try:
        if context.area.type != "VIEW_3D": return
        layout = self.layout
        s = context.scene
        wm = context.window_manager
        prefs = _prefs()
        logged = bool(prefs.access_token)
        layout.separator_spacer()
        row = layout.row(align=True)
        row.label(text="", icon_value=_icon_id())
        row.label(text="블록 컴퓨터")
        sub = row.row(align=True)
        sub.ui_units_x = 24
        try:
            sub.prop(s, "arife_ai_prompt", text="", placeholder="장면을 적으세요 — 무엇이 · 빛 · 카메라 · 순서")
        except TypeError:
            sub.prop(s, "arife_ai_prompt", text="")
        row.prop(s, "arife_ai_model", text="")
        dur = {"seedance25": "arife_ai_duration_sd", "geminiOmni": "arife_ai_duration_gm", "vaceDepth": "arife_ai_duration_vace",
               "vacePose": "arife_ai_duration_vace", "wan30": "arife_ai_duration_wan", "hailuo3": "arife_ai_duration_h3"}.get(s.arife_ai_model, "arife_ai_duration")
        row.prop(s, dur, text="")
        row.prop(s, "arife_ai_resolution", text="")
        busy = bool(_block["busy"])
        if _block["await_confirm"]:
            q = _state["ai_quote"]
            row.operator("arife.block_confirm", text=f"↑ AI 영상 만들기 ({q}cr)" if q is not None else "↑ AI 영상 만들기", icon="PLAY")
        else:
            b = row.row(align=True); b.enabled = logged and not busy and not bool(_state["busy"])
            b.operator("arife.block_run", text="▶ 만들기", icon="PLAY", depress=busy)
        row.operator("arife.local_render", text="", icon="RENDER_ANIMATION")
        row.operator("arife.ai_toggle", text="", icon="COMMUNITY", depress=_agent["on"])
        row.popover(panel="ARIFE_PT_block_opts", text="", icon="PREFERENCES")
        st = _block["status"] or (scene_status(s) if logged else "로그인: N 패널 › ARIFE")
        if st:
            layout.label(text=st[:70])
    except Exception as e:
        print("[ARIFE·블록컴퓨터] header:", e)


def scene_status(s):
    if _state["ai_status"]: return _state["ai_status"]
    if _state["busy"]: return "작업 중: " + _state["busy"]
    return ""


class ARIFE_PT_block_opts(bpy.types.Panel):
    bl_label = "블록 컴퓨터 설정"
    bl_space_type = "VIEW_3D"
    bl_region_type = "HEADER"

    def draw(self, context):
        wm = context.window_manager
        col = self.layout.column()
        col.prop(wm, "arife_block_confirm")
        col.prop(wm, "arife_block_keep")
        col.separator()
        col.prop(wm, "arife_bar_symbol")
        col.prop(wm, "arife_bar_show")
        col.separator()
        col.label(text="계획·코드는 무료(Gemini), 실사화만 모델 요금", icon="INFO")
        if _block["log"]:
            for line in _block["log"][-6:]:
                col.label(text=line[:60])


def _block_register():
    for c in (ARIFE_OT_block_run, ARIFE_OT_block_confirm, ARIFE_PT_block_opts):
        bpy.utils.register_class(c)
    bpy.types.WindowManager.arife_block_confirm = bpy.props.BoolProperty(name="생성 전 확인 (렌더 뒤 멈춤)", description="꺼 두면 힉스필드처럼 렌더가 끝나는 대로 AI 영상까지 한 번에 간다(크레딧 자동 차감)", default=True)
    bpy.types.WindowManager.arife_block_keep = bpy.props.BoolProperty(name="기존 물체 남기기", description="켜면 씬을 비우지 않고 위에 덧붙인다", default=False)
    # v0.8.2 — 위쪽 헤더 줄은 뺐다(사장님: "요 부분 빼고 로고는 가운데 상단"). 함수는 남겨 두되 붙이지 않는다.


def _block_unregister():
    global _icons
    try: bpy.types.VIEW3D_HT_tool_header.remove(_draw_block_header)
    except Exception: pass
    for n in ("arife_block_confirm", "arife_block_keep"):
        if hasattr(bpy.types.WindowManager, n): delattr(bpy.types.WindowManager, n)
    for c in (ARIFE_PT_block_opts, ARIFE_OT_block_confirm, ARIFE_OT_block_run):
        try: bpy.utils.unregister_class(c)
        except Exception: pass
    if _icons is not None:
        try: bpy.utils.previews.remove(_icons)
        except Exception: pass
        _icons = None


class ARIFE_PT_panel(bpy.types.Panel):
    bl_label = "ARIFE"
    bl_space_type = "SEQUENCE_EDITOR"
    bl_region_type = "UI"
    bl_category = "ARIFE"

    def draw(self, context):
        layout = self.layout
        wm = context.window_manager
        scene = context.scene
        prefs = _prefs()
        logged_in = bool(prefs.access_token)
        found = _resolve_render_file(scene)
        model = scene.arife_ai_model
        is_sd = model == "seedance25"
        is_gm = model == "geminiOmni"
        multi = model in MULTI_REF          # v0.6 — 참조 그림 여러 장(시댄스 · Wan 3.0 · Hailuo 3)

        def step(num, title, icon, done=False, on=True):
            box = layout.box()
            head = box.row(align=True)
            head.label(text=f"{num}", icon="CHECKMARK" if done else "BLANK1")
            head.label(text=title, icon=icon)
            box.enabled = on
            return box

        # ── 1 · 연결 ──
        box = step(1, "연결", "LINKED", done=logged_in)
        if logged_in:
            row = box.row(align=True)
            row.label(text=prefs.email, icon="USER")
            row.operator("arife.logout", text="", icon="QUIT")
            credits = _state["credits"]
            row = box.row(align=True)
            row.label(text="보유 크레딧: —" if credits is None else f"보유 크레딧: {credits:,}")
            row.operator("arife.refresh_credits", text="", icon="FILE_REFRESH")
        else:
            if not _anon_key():
                box.label(text="환경 설정에 공개 키(anon)가 없습니다.", icon="ERROR")
            box.prop(wm, "arife_connect_code", text="연결 코드")
            row = box.row(align=True)
            row.operator("arife.connect", icon="LINKED")
            row.operator("arife.open_connect_page", text="코드 받기", icon="URL")
            box.label(text="사이트 › 마이페이지 › 블렌더 연결에서 코드를 복사하세요", icon="INFO")
        box.operator("arife.ping", text="서버 연결 확인", icon="URL")

        # ── AI · 조종 (v0.7) ──
        _draw_ai_box(layout, context)

        # ── 2 · 렌더 ──
        box = step(2, "렌더 고르기", "RENDER_ANIMATION", done=bool(found), on=logged_in)
        box.prop(scene, "arife_render_file", text="렌더 파일")
        if found:
            box.label(text=os.path.basename(found), icon="CHECKMARK")
        else:
            box.label(text="Ctrl+F12 로 렌더(mp4)하거나 위에서 파일을 고르세요", icon="ERROR")
        box.label(text="워크벤치 회색 렌더면 충분합니다 — 카메라·움직임만 씁니다", icon="INFO")

        # ── 3 · 프롬프트 ──
        has_prompt = bool(scene.arife_ai_prompt.strip())
        box = step(3, "프롬프트", "TEXT", done=has_prompt, on=logged_in)
        box.prop(scene, "arife_ai_prompt", text="")
        if is_gm:
            box.label(text="짧은 지시 한 줄 — 예: 하늘을 노을로 바꿔", icon="INFO")
        else:
            box.label(text="무엇이 · 어떤 재질 · 어떤 빛 · 무슨 일이 일어나는지, 시간 순서로", icon="INFO")
        up_text = "원문으로 되돌리기" if scene.arife_ai_prompt_orig else "프롬프트 업그레이드"
        up_icon = "LOOP_BACK" if scene.arife_ai_prompt_orig else "SHADERFX"
        box.operator("arife.prompt_upgrade", text=up_text, icon=up_icon)

        # ── 4 · 참조 그림 (역할별) ──
        refs = _collect_refs(scene) if not is_gm else []
        box = step(4, f"참조 그림  {len(refs)}/{MULTI_REF.get(model, 9)}" if multi else "참조 그림 (선택)", "IMAGE_DATA", done=bool(refs), on=logged_in and not is_gm)
        if is_gm:
            box.label(text="제미나이 편집은 참조 그림을 받지 않습니다", icon="INFO")
        else:
            if not multi:
                box.label(text="이 모델은 첫 그림 1장만 기준(앵커)으로 씁니다 — 여러 장은 시댄스 2.5 · Wan 3.0 · Hailuo 3", icon="INFO")
            for g, label in REF_GROUPS:
                col = box.column(align=True)
                col.label(text=label, icon={"char": "OUTLINER_OB_ARMATURE", "bg": "WORLD", "fx": "SHADERFX"}[g])
                shown_next = True
                for i in (1, 2, 3):
                    prop = f"arife_ref_{g}{i}"
                    if not shown_next:
                        break
                    col.prop(scene, prop, text=f"{i}")
                    shown_next = bool(getattr(scene, prop, "")) and multi
            if refs:
                box.label(text="캐릭터 → 배경 → 효과 순으로 Image 번호가 매겨져 프롬프트에 자동 설명됩니다", icon="INFO")
            else:
                box.prop(scene, "arife_ai_anchor")

        # ── 5 · 설정 ──
        box = step(5, "모델 · 길이 · 화질", "PREFERENCES", done=True, on=logged_in)
        box.prop(scene, "arife_ai_model", text="")
        row = box.row(align=True)
        dur_prop = {"seedance25": "arife_ai_duration_sd", "geminiOmni": "arife_ai_duration_gm",
                    "vaceDepth": "arife_ai_duration_vace", "vacePose": "arife_ai_duration_vace",
                    "wan30": "arife_ai_duration_wan", "hailuo3": "arife_ai_duration_h3"}.get(model, "arife_ai_duration")
        row.prop(scene, dur_prop, text="길이")
        if not is_gm:
            row.prop(scene, "arife_ai_resolution", text="")
        if is_sd:
            box.label(text="시댄스는 한 번에 최대 30초 — 더 길면 컷을 나눠 이어붙이세요", icon="INFO")
        elif model in ("vaceDepth", "vacePose"):
            box.label(text="VACE 는 렌더의 구조만 따라갑니다 — 회색 박스가 무엇인지 프롬프트에 자리별로 적으세요", icon="INFO")
        elif model == "wan30":
            box.label(text="Wan 3.0 — 렌더는 Video 1, 참조 그림은 Image 1·2… 로 자동 지목됩니다 (최대 30초)", icon="INFO")
        elif model == "hailuo3":
            box.label(text="Hailuo 3 — 480p 는 768p, 720p 는 2K 로 나갑니다. 소리가 함께 만들어집니다", icon="INFO")
        elif not is_gm:
            box.label(text="기본 모델은 최대 5초 — 길게 뽑으려면 시댄스 2.5", icon="INFO")
        if model == "ltxRender":
            box.prop(scene, "arife_ai_refine")
            if scene.arife_ai_refine:
                box.label(text="다듬기를 켜면 크레딧이 4배 듭니다", icon="ERROR")
        if scene.arife_ai_anchor and not is_gm and not refs:
            box.label(text=f"첫 프레임을 그림으로 확정 후 시작 (+{ANCHOR_COST}크레딧)", icon="IMAGE_DATA")

        # ── 6 · 만들기 ──
        ready = logged_in and bool(found) and has_prompt and not _state["busy"]
        box = step(6, "만들기", "PLAY", done=False, on=logged_in)
        row = box.row(align=True)
        quote = _state["ai_quote"]
        if quote is not None:
            plus = f" +{ANCHOR_COST}(첫 장면)" if (scene.arife_ai_anchor and not is_gm and not refs) else ""
            row.label(text=f"예상 크레딧: {quote}{plus}")
        else:
            row.label(text="예상 크레딧: 확인을 누르세요")
        row.operator("arife.ai_quote", text="확인", icon="FILE_REFRESH")
        big = box.row()
        big.scale_y = 1.6
        big.enabled = ready
        big.operator("arife.ai_generate", text="렌더 → AI 영상 만들기", icon="PLAY")
        if not ready and logged_in and not _state["busy"]:
            missing = []
            if not found: missing.append("렌더 파일")
            if not has_prompt: missing.append("프롬프트")
            if missing:
                box.label(text="먼저: " + " · ".join(missing), icon="INFO")
        if scene.arife_ai_status:
            box.label(text=scene.arife_ai_status, icon="INFO")
        if _state["upload_progress"] >= 0:
            box.label(text=f"업로드 중… {int(scene.arife_upload_progress * 100)}%")

        # ── 7 · 결과 ──
        box = step(7, "결과", "ASSET_MANAGER", done=bool(_state["upload_done"]), on=logged_in)
        box.label(text="완성 영상은 VSE 와 사이트 보관함에 함께 저장됩니다", icon="CHECKMARK")
        row = box.row(align=True)
        row.operator("arife.fetch_library", text="보관함 새로고침", icon="FILE_REFRESH")
        row.operator("arife.open_library_page", text="사이트에서 보기", icon="URL")
        if logged_in:
            coll = wm.arife_assets
            for i, item in enumerate(coll[:8]):
                r = box.row(align=True)
                title = item.title if len(item.title) <= 26 else item.title[:25] + "…"
                r.label(text=title, icon="SEQUENCE")
                op = r.operator("arife.import_asset", text="", icon="IMPORT")
                op.index = i
        sub = box.column(align=True)
        sub.prop(scene, "arife_media_url", text="주소로 가져오기")
        sub.operator("arife.import_url", text="URL 영상 가져오기", icon="IMPORT")
        # 렌더만 따로 사이트로 보내고 싶을 때
        box.operator("arife.upload_render", text="렌더만 사이트로 올리기", icon="EXPORT")
        if _state["upload_done"]:
            box.operator("arife.open_render_real", icon="WORLD")

        if _state["status"]:
            layout.separator()
            layout.label(text=_state["status"], icon="INFO")


class ARIFE_PT_panel_3d(ARIFE_PT_panel):
    bl_idname = "ARIFE_PT_panel_3d"
    bl_space_type = "VIEW_3D"


CLASSES = (
    ArifePrefs,
    ArifeAssetItem,
    ARIFE_OT_ping,
    ARIFE_OT_connect,
    ARIFE_OT_open_connect_page,
    ARIFE_OT_logout,
    ARIFE_OT_refresh_credits,
    ARIFE_OT_fetch_library,
    ARIFE_OT_import_asset,
    ARIFE_OT_import_url,
    ARIFE_OT_upload_render,
    ARIFE_OT_open_render_real,
    ARIFE_OT_ai_quote,
    ARIFE_OT_prompt_upgrade,
    ARIFE_OT_open_library_page,
    ARIFE_OT_ai_generate,
    ARIFE_PT_panel,
    ARIFE_PT_panel_3d,
)


import types as _types_mod
_ORIG_FUNCS = {n: f for n, f in list(globals().items()) if isinstance(f, _types_mod.FunctionType) and f.__module__ == __name__}

def register():
    for c in CLASSES:
        bpy.utils.register_class(c)
    bpy.types.Scene.arife_media_url = bpy.props.StringProperty(
        name="ARIFE 영상 URL", description="ARIFE 보관함에서 복사한 결과 주소", default=""
    )
    bpy.types.Scene.arife_upload_progress = bpy.props.FloatProperty(
        name="업로드 진행률", default=0.0, min=0.0, max=1.0
    )
    # AI 영상 원버튼 설정 — 씬에 저장돼 .blend 파일과 함께 남는다.
    bpy.types.Scene.arife_ai_prompt = bpy.props.StringProperty(
        name="프롬프트",
        description="완성 화면을 그림 그리듯 자세히: 무엇이 나오고, 재질·색·조명·배경·분위기까지. 짧고 막연하면(예: 광고영상) 렌더 모습이 거의 그대로 나옵니다. 짧게 적고 「프롬프트 업그레이드」를 눌러도 됩니다",
        default="",
    )
    bpy.types.Scene.arife_ai_prompt_orig = bpy.props.StringProperty(
        # 프롬프트 업그레이드 전의 원문 — 「원문으로 되돌리기」가 쓴다.
        name="프롬프트 원문", default="", options={"HIDDEN"},
    )
    # v0.5 — 참조 그림을 역할별 묶음으로: 캐릭터 · 배경 · 효과, 각 3장 (시댄스 상한 9장).
    #   예전 arife_ref_image / 2 / 3 은 캐릭터 1·2·3 으로 그대로 이어진다(.blend 호환).
    _REF_DESC = {
        "char": "인물·캐릭터 시트: 얼굴·의상·비율을 이 그림대로 유지한다",
        "bg":   "배경·공간·시대감: 건물·거리·하늘·질감의 기준",
        "fx":   "효과·분위기: 폭발·비·레이저·빛번짐·색감 등 장면에 얹을 느낌",
    }
    for _g, _label in (("char", "캐릭터"), ("bg", "배경"), ("fx", "효과")):
        for _i in (1, 2, 3):
            setattr(bpy.types.Scene, f"arife_ref_{_g}{_i}", bpy.props.StringProperty(
                name=f"{_label} {_i}", subtype="FILE_PATH", default="", description=_REF_DESC[_g]))
    # 옛 이름 → 캐릭터 슬롯(호환용 별칭이 아니라 같은 값을 가리키는 얕은 게이트).
    bpy.types.Scene.arife_ref_image  = bpy.props.StringProperty(name="참조 그림", subtype="FILE_PATH", default="", options={"HIDDEN"})
    bpy.types.Scene.arife_ref_image2 = bpy.props.StringProperty(name="참조 그림 2", subtype="FILE_PATH", default="", options={"HIDDEN"})
    bpy.types.Scene.arife_ref_image3 = bpy.props.StringProperty(name="참조 그림 3", subtype="FILE_PATH", default="", options={"HIDDEN"})
    bpy.types.Scene.arife_ai_anchor = bpy.props.BoolProperty(
        name=f"첫 장면 먼저 그리기 (+{ANCHOR_COST}크레딧)",
        description="시작 프레임을 프롬프트대로 그림 한 장으로 먼저 확정하고, 그 그림을 기준(앵커)으로 영상을 그린다. 색감·재질이 훨씬 안정된다 — 웹 페이지의 「첫 장면 그리기」와 같다",
        default=False,
    )
    bpy.types.Scene.arife_render_file = bpy.props.StringProperty(
        name="렌더 파일", subtype="FILE_PATH",
        description="AI로 만들 렌더 영상 파일(mp4). 비워 두면 출력 경로에서 자동으로 찾는다",
        default="",
    )
    bpy.types.Scene.arife_ai_model = bpy.props.EnumProperty(
        name="모델",
        description="렌더 영상을 통째로 받아 카메라·움직임을 유지한 채 다시 그리는 모델들",
        items=[
            ("ltxRender", "렌더 그리기 (기본·저렴)",
             "LTX 렌더→실사. 1~5초, 720p 5초 = 39크레딧. 짧은 컷에 알맞다"),
            ("seedance25", "시댄스 2.5 (고품질)",
             "Seedance 2.5 영상 참조. 4~30초, 720p 5초 약 199크레딧 — 값이 약 5배지만 품질·프롬프트 이해가 좋다"),
            ("geminiOmni", "제미나이 편집 (영상 고치기)",
             "완성 영상을 지시 한 줄로 고친다. 3~10초, 5초 85크레딧 — 렌더보다 이미 만든 영상 손보기에 알맞다"),
            ("vaceDepth", "구조 따라 그리기 — 깊이 (VACE · 저렴)",
             "렌더의 깊이(공간) 구조를 그대로 따라 다시 그린다. 5~15초, 720p 5초 60크레딧 — 워크벤치 박스 렌더에 가장 잘 맞는다"),
            ("vacePose", "구조 따라 그리기 — 사람 뼈대 (VACE)",
             "렌더 속 사람의 뼈대(포즈)를 따라 다시 그린다. 사람 애니메이션이 있을 때. 5~15초, 720p 5초 60크레딧"),
            ("wan30", "Wan 3.0 (참조 영상 · 그림 9장 · 30초)",
             "렌더를 참조 영상(Video 1)으로 넣고 그림을 9장까지 함께. 2~30초, 720p 5초 75크레딧, 소리 포함"),
            ("hailuo3", "Hailuo 3 (2K · 참조 영상)",
             "렌더를 Video 1 로 참조, 그림 9장. 5~15초, 2K 5초 약 90크레딧, 소리 포함. 해상도 480p→768p, 720p→2K 로 나간다"),
        ],
        default="ltxRender",
    )
    bpy.types.Scene.arife_ai_duration = bpy.props.EnumProperty(
        name="길이",
        description="완성 영상 길이(초)",
        items=[(str(n), f"{n}초", "") for n in range(1, 6)],
        default="5",
    )
    bpy.types.Scene.arife_ai_duration_sd = bpy.props.EnumProperty(
        name="길이",
        description="시댄스 2.5 는 최소 4초부터. 길수록 크레딧이 는다",
        items=[(str(n), f"{n}초", "") for n in (4, 5, 6, 8, 10, 12, 15, 20, 30)],
        default="5",
    )
    bpy.types.Scene.arife_ai_duration_vace = bpy.props.EnumProperty(
        name="길이", description="VACE 는 5~15초",
        items=[(str(n), f"{n}초", "") for n in (5, 6, 7, 8, 9, 10, 12, 15)], default="5",
    )
    bpy.types.Scene.arife_ai_duration_wan = bpy.props.EnumProperty(
        name="길이", description="Wan 3.0 은 2~30초",
        items=[(str(n), f"{n}초", "") for n in (2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30)], default="5",
    )
    bpy.types.Scene.arife_ai_duration_h3 = bpy.props.EnumProperty(
        name="길이", description="Hailuo 3 는 5~15초",
        items=[(str(n), f"{n}초", "") for n in (5, 6, 8, 10, 12, 15)], default="5",
    )
    bpy.types.Scene.arife_ai_duration_gm = bpy.props.EnumProperty(
        name="길이",
        description="제미나이 편집은 3~10초. 원본 영상 길이에 맞추면 된다",
        items=[(str(n), f"{n}초", "") for n in range(3, 11)],
        default="5",
    )
    bpy.types.Scene.arife_ai_resolution = bpy.props.EnumProperty(
        name="해상도",
        items=[("480p", "480p", "빠르고 저렴"), ("720p", "720p", "기본 화질")],
        default="720p",
    )
    bpy.types.Scene.arife_ai_refine = bpy.props.BoolProperty(
        name="고화질 다듬기 (크레딧 4배)",
        description="결과를 한 번 더 다듬는다 — 값이 4배로 든다",
        default=False,
    )
    bpy.types.Scene.arife_ai_status = bpy.props.StringProperty(
        # 진행 문구는 스레드가 _state에 쓰고, 펌프 타이머가 여기로 복사한다.
        name="AI 진행 상태", default="", options={"HIDDEN"},
    )
    # WindowManager 속성은 파일·환경설정에 저장되지 않는다 — 연결 코드는
    # 1회용 계정 열쇠라 디스크에 남기지 않고 여기에만 담는다.
    bpy.types.WindowManager.arife_connect_code = bpy.props.StringProperty(
        name="연결 코드",
        description="arifeart.kr → 내 계정 → 블렌더 연결에서 복사한 코드",
        default="", subtype="PASSWORD",
    )
    bpy.types.WindowManager.arife_assets = bpy.props.CollectionProperty(type=ArifeAssetItem)
    # 스레드 결과를 메인 스레드로 나르는 펌프 타이머.
    # persistent=True 로 파일을 새로 열어도 계속 돈다.
    if not bpy.app.timers.is_registered(_pump):
        bpy.app.timers.register(_pump, first_interval=0.2, persistent=True)
    _v07_register()
    _block_register()


def unregister():
    _block_unregister()
    _v07_unregister()
    if bpy.app.timers.is_registered(_pump):
        bpy.app.timers.unregister(_pump)
    for c in reversed(CLASSES):
        bpy.utils.unregister_class(c)
    del bpy.types.Scene.arife_media_url
    del bpy.types.Scene.arife_upload_progress
    del bpy.types.Scene.arife_ai_prompt
    del bpy.types.Scene.arife_ai_prompt_orig
    del bpy.types.Scene.arife_ai_anchor
    del bpy.types.Scene.arife_ref_image
    del bpy.types.Scene.arife_ref_image2
    del bpy.types.Scene.arife_ref_image3
    for _g in ("char", "bg", "fx"):
        for _i in (1, 2, 3):
            if hasattr(bpy.types.Scene, f"arife_ref_{_g}{_i}"):
                delattr(bpy.types.Scene, f"arife_ref_{_g}{_i}")
    del bpy.types.Scene.arife_render_file
    del bpy.types.Scene.arife_ai_model
    del bpy.types.Scene.arife_ai_duration
    del bpy.types.Scene.arife_ai_duration_sd
    del bpy.types.Scene.arife_ai_duration_gm
    del bpy.types.Scene.arife_ai_duration_vace
    del bpy.types.Scene.arife_ai_duration_wan
    del bpy.types.Scene.arife_ai_duration_h3
    del bpy.types.Scene.arife_ai_resolution
    del bpy.types.Scene.arife_ai_refine
    del bpy.types.Scene.arife_ai_status
    del bpy.types.WindowManager.arife_connect_code
    del bpy.types.WindowManager.arife_assets


if __name__ == "__main__":
    register()
