# -*- coding: utf-8 -*-
"""
CARRYYOO AI Short-Drama Pro Studio - Windows Desktop App (single file)
======================================================================

Premium dark studio (Apple-style obsidian glass). 中文 / English UI.
Built with CustomTkinter for Windows 11.

Tabs
  1. 剧本智能分镜 / Script -> Shots (offline, bilingual, model presets, CSV/TSV/TXT export)
  2. 批量视频处理 / Batch Video (9:16 vertical blur fill, -14 LUFS; offline)
  3. MiniMax 视频流水线 / MiniMax Pipeline (your own API key + group id)
  4. 全球资产与支持 / Global Assets & Support (Freesound, on-demand connectivity, CarryVPN)

PRO / Lite: honest activation UI (machine code + license key entry). Activation is a
pending integration: real license validation requires a secure issuer / verifier and is
NOT performed here. Entering an arbitrary string never unlocks PRO. Payment details shown
are user-provided.

Privacy / honesty
  - Local features never make network calls and stay fully usable offline.
  - Online actions (connectivity probe, Freesound, MiniMax) run ONLY on explicit user action.
  - Secrets (API key) are kept in memory, masked, never logged or hardcoded.
  - Connectivity uses an already-running local proxy (direct/proxy modes) - no built-in VPN,
    no guarantee of access.
"""

import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from urllib.request import ProxyHandler, build_opener
from tkinter import filedialog
from tkinter import messagebox

APP_NAME = "CARRYYOO AI Short-Drama Pro Studio"
VERSION = "1.0.0"
CONFIG_NAME = "carryyoo_config.json"

# ---------------------------------------------------------------------------
# Theme - Apple-style obsidian dark
# ---------------------------------------------------------------------------
BG = "#161618"
PANEL = "#202024"
RAISED = "#2C2C30"
BORDER = "#3A3A40"
INK = "#F5F5F7"
MUTED = "#A1A1A6"
BLUE = "#0A84FF"
BLUE_DIM = "#0A5CBF"
PURPLE = "#AF52DE"
GOOD = "#30D158"
WARN = "#FFD60A"
ERR = "#FF453A"

CN_FONT = "Microsoft YaHei UI"
MONO_FONT = "Cascadia Mono"

import customtkinter as ctk

ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")

# ---------------------------------------------------------------------------
# Language engine
# ---------------------------------------------------------------------------
LANG = "zh"


def _t(zh, en):
    return en if LANG == "en" else zh


def config_path():
    return Path(__file__).resolve().parent / CONFIG_NAME


def load_config():
    defaults = {
        "proxy_host": "", "proxy_port": "", "proxy_type": "HTTP",
        "network_mode": "auto", "lang": "zh", "freesound_token": "",
    }
    try:
        if config_path().exists():
            data = json.loads(config_path().read_text(encoding="utf-8"))
            defaults.update({k: data.get(k, v) for k, v in defaults.items()})
    except Exception:
        pass
    return defaults


def save_config(cfg):
    try:
        config_path().write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
        return True
    except Exception as e:
        return str(e)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _app_base_dir():
    return getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))


def _find_tool(name):
    exe = name + (".exe" if os.name == "nt" else "")
    roots = [_app_base_dir(), os.path.dirname(sys.executable)]
    for root in roots:
        for sub in ("", "bin", name):
            p = os.path.join(root, sub, exe)
            if p and os.path.isfile(p):
                return p
    return shutil.which(name)


def find_ffmpeg():
    return _find_tool("ffmpeg")


def find_ffprobe():
    return _find_tool("ffprobe")


def is_windows():
    return platform.system() == "Windows"


def open_link(url):
    import webbrowser
    webbrowser.open_new_tab(url)


def ts_now():
    return time.strftime("%H:%M:%S")


def _machine_code():
    try:
        mid = uuid.getnode()
    except Exception:
        mid = 0
    raw = f"{platform.node()}|{platform.processor()}|{platform.machine()}|{mid}"
    h = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:24].upper()
    return "-".join(h[i:i + 4] for i in range(0, 24, 4))


# ---------------------------------------------------------------------------
# RSA-2048 public key for PRO license verification (PKCS#1 v1.5 SHA-256)
# Private key is NOT embedded here - kept in a secure issuer environment.
# ---------------------------------------------------------------------------
RSA_PUBLIC_E = 65537
RSA_PUBLIC_N_HEX = (
    "0x6aee6e562168e62b73d8ea289c09f4d21a1b1aabc242963e984c53c4d9851c734"
    "c3aff68ee50d5d5bcb3c42ffe7666d98b081b76ce606882d00893f4582c7d5bf54"
    "fcecc5172cef54fc5547283e576e6d7471cb2951763b8aae189c209cd71952aa25"
    "07b204a2dc4fa3fdbb6174945a539231c92bb05b3868cf7c6dc862f4db9070771c"
    "dc817f46e65193900cbf1182f6a0d481557c4ce3aa18efa3f806cb9a50f96eb8d0"
    "ec7f1f7a9627c3a51056d6789320254c31ed9e40c62f33c0d4eb95cd05939ebf5a"
    "ba8ae26f79e3649d8def493cd161fd2e722193a7e60206aac6393a4875400389c0"
    "630c413c3c953d042426993db1c769fa46cbab49793d0dde549"
)

LICENSE_FILE = os.path.join(os.path.expanduser("~"), ".carryyoo_studio_license.json")


def clean_hwid(hwid):
    return (hwid or "").replace("-", "").replace(" ", "").upper()


def emsa_pkcs1_v15(digest, k):
    """SHA-256 DigestInfo prefix + padding."""
    t = bytes.fromhex("3031300d060960864801650304020105000420") + digest
    ps = b"\xff" * (k - len(t) - 3)
    return b"\x00\x01" + ps + b"\x00" + t


def verify_license(hwid, sig_b64):
    """Pure-Python RSA PKCS#1 v1.5 verification with embedded public key."""
    if not sig_b64:
        return False
    try:
        n = int(RSA_PUBLIC_N_HEX, 16)
        e = RSA_PUBLIC_E
        k = (n.bit_length() + 7) // 8
        sig = base64.b64decode(sig_b64.strip())
        if len(sig) != k:
            return False
        m = pow(int.from_bytes(sig, "big"), e, n)
        em = m.to_bytes(k, "big")
        expected = emsa_pkcs1_v15(hashlib.sha256(clean_hwid(hwid).encode("utf-8")).digest(), k)
        return em == expected
    except Exception:
        return False


# ---------------------------------------------------------------------------
# Main App
# ---------------------------------------------------------------------------
class CarryyooStudio(ctk.CTk):
    def __init__(self):
        super().__init__()
        global LANG
        self.cfg = load_config()
        LANG = self.cfg.get("lang", "zh") or "zh"

        self.title(APP_NAME)
        self.geometry("1320x860")
        self.minsize(1120, 720)
        self.configure(fg_color=BG)

        self._batch_lock = threading.Lock()
        self._batch_done = 0
        self._batch_total = 0
        self._minimax_key = ""
        self.tr_widgets = []
        self._last_mode_key = "auto"
        self.pro_active = self._load_activation()

        self._build_sidebar()
        self._build_main()
        self._build_statusbar()
        self._apply_lang()
        self._update_pro_status()
        self._switch_tab("shots")
        threading.Thread(target=self._probe_environment, daemon=True).start()

    # ------------------------------------------------------------ i18n
    def _tr(self, widget, kind, zh, en):
        self.tr_widgets.append({"w": widget, "kind": kind, "zh": zh, "en": en})

    def _apply_lang(self):
        for item in self.tr_widgets:
            text = item["en"] if LANG == "en" else item["zh"]
            try:
                if item["kind"] == "text":
                    item["w"].configure(text=text)
                elif item["kind"] == "placeholder":
                    item["w"].configure(placeholder_text=text)
            except Exception:
                pass
        self._sync_lang_seg()
        self._refresh_netmode()
        self._render_tutorial()

    def _sync_lang_seg(self):
        try:
            self.lang_seg.set(_t("中文", "English"))
        except Exception:
            pass

    def _set_lang(self, value):
        global LANG
        LANG = "en" if value == "English" else "zh"
        self.cfg["lang"] = LANG
        self._apply_lang()
        save_config(self.cfg)

    def _log(self, msg):
        def push():
            try:
                self.log_box.insert("end", f"[{ts_now()}] {msg}\n")
                self.log_box.see("end")
            except Exception:
                pass
        self.after(0, push)

    def _probe_environment(self):
        self._log(f"[env] system={platform.system()} python={platform.python_version()} lang={LANG}")
        ff = find_ffmpeg()
        fp = find_ffprobe()
        if ff and fp:
            self.ffmpeg_status.configure(text=f"FFmpeg ✓ {ff}", text_color=GOOD)
        elif ff:
            self.ffmpeg_status.configure(text=_t("FFmpeg ✓ (无 ffprobe)", "FFmpeg ✓ (no ffprobe)"), text_color=WARN)
        else:
            self.ffmpeg_status.configure(text=_t("FFmpeg ✗ 未安装", "FFmpeg ✗ not found"), text_color=ERR)
        self.status_line.configure(text=_t("就绪 · 请选择功能", "Ready · pick a feature"))

    # ------------------------------------------------------------ UI build
    def _build_sidebar(self):
        self.sidebar = ctk.CTkFrame(self, width=236, fg_color=PANEL, corner_radius=0, border_width=0)
        self.sidebar.pack(side="left", fill="y", padx=0)
        logo = ctk.CTkLabel(self.sidebar, text="CARRYYOO", font=ctk.CTkFont(family=CN_FONT, size=24, weight="bold"),
                            text_color=INK, anchor="w", padx=26, pady=12)
        logo.pack(fill="x")
        sub = ctk.CTkLabel(self.sidebar, text="AI SHORT-DRAMA PRO",
                           font=ctk.CTkFont(family=MONO_FONT, size=11), text_color=PURPLE, anchor="w", padx=26, pady=9)
        sub.pack(fill="x")

        self.nav_buttons = {}
        nav = [
            ("shots", ("剧本智能分镜", "Script to Shots")),
            ("batch", ("批量视频处理", "Batch Video")),
            ("minimax", ("MiniMax 视频流水线", "MiniMax Pipeline")),
            ("assets", ("全球资产与支持", "Assets & Support")),
            ("tutorial", ("新手教程", "Quick Start")),
        ]
        for key, (zh, en) in nav:
            b = ctk.CTkButton(self.sidebar, text=_t(zh, en), anchor="w", height=46, corner_radius=12,
                              fg_color="transparent", hover_color=RAISED, text_color=MUTED,
                              font=ctk.CTkFont(family=CN_FONT, size=14), command=lambda k=key: self._switch_tab(k))
            b.pack(fill="x", padx=12, pady=3)
            self.nav_buttons[key] = b
            self._tr(b, "text", zh, en)

        self.pro_btn = ctk.CTkButton(self.sidebar, text="PRO", height=40, corner_radius=12,
                                     fg_color=RAISED, hover_color=BLUE_DIM, text_color=PURPLE,
                                     border_width=1, border_color=BORDER, font=ctk.CTkFont(family=CN_FONT, size=14, weight="bold"),
                                     command=self._open_pro)
        self.pro_btn.pack(fill="x", padx=12, pady=12)
        self._tr(self.pro_btn, "text", "PRO · 激活", "PRO · Activate")

        version_lbl = ctk.CTkLabel(self.sidebar, text=f"v{VERSION}", font=ctk.CTkFont(family=MONO_FONT, size=11),
                                   text_color=MUTED, anchor="w", padx=26, pady=12)
        version_lbl.pack(side="bottom", fill="x")

    def _build_main(self):
        self.main = ctk.CTkFrame(self, fg_color=BG, corner_radius=0)
        self.main.pack(side="left", fill="both", expand=True)

        # top bar: language segmented control (top-right)
        topbar = ctk.CTkFrame(self.main, fg_color="transparent")
        topbar.pack(fill="x", padx=22, pady=10)
        lbl = ctk.CTkLabel(topbar, text=_t("界面语言", "Language"), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED)
        lbl.pack(side="right", padx=5)
        self.lang_seg = ctk.CTkSegmentedButton(
            topbar, values=["中文", "English"], width=180, corner_radius=10,
            fg_color=RAISED, selected_color=BLUE, selected_hover_color=BLUE_DIM,
            unselected_color=RAISED, text_color=INK, font=ctk.CTkFont(family=CN_FONT, size=13),
            command=self._set_lang)
        self.lang_seg.set(_t("中文", "English"))
        self.lang_seg.pack(side="right")

        self.tab_container = ctk.CTkFrame(self.main, fg_color="transparent")
        self.tab_container.pack(fill="both", expand=True)
        self.tabs = {}
        self.tabs["shots"] = self._tab_shots(self.tab_container)
        self.tabs["batch"] = self._tab_batch(self.tab_container)
        self.tabs["minimax"] = self._tab_minimax(self.tab_container)
        self.tabs["assets"] = self._tab_assets(self.tab_container)
        self.tabs["tutorial"] = self._tab_tutorial(self.tab_container)

    def _build_statusbar(self):
        bar = ctk.CTkFrame(self, fg_color=PANEL, corner_radius=0, height=36)
        bar.pack(side="bottom", fill="x")
        self.status_line = ctk.CTkLabel(bar, text=_t("检测环境中…", "Checking environment…"), anchor="w",
                                        font=ctk.CTkFont(family=MONO_FONT, size=11), text_color=MUTED, padx=16, pady=6)
        self.status_line.pack(side="left", fill="x", expand=True)
        self._tr(self.status_line, "text", "检测环境中…", "Checking environment…")
        self.ffmpeg_status = ctk.CTkLabel(bar, text=_t("FFmpeg · 检测中", "FFmpeg · checking"),
                                          font=ctk.CTkFont(family=MONO_FONT, size=11), text_color=MUTED, padx=16)
        self.ffmpeg_status.pack(side="right")

    def _switch_tab(self, key):
        for k, btn in self.nav_buttons.items():
            sel = k == key
            btn.configure(fg_color=BLUE if sel else "transparent",
                          text_color=INK if sel else MUTED)
        for k, frame in self.tabs.items():
            frame.pack_forget()
        self.tabs[key].pack(fill="both", expand=True, padx=22, pady=12)

    # ================================================================ TAB 1
    _MODELS = [
        ("MiniMax / Hailuo", "MiniMax/Hailuo cinematic, vertical 9:16, film grain, shallow depth of field"),
        ("Kling / 可灵", "Kling, vertical 9:16, vivid motion, natural lighting"),
        ("Runway Gen-3", "Runway Gen-3, vertical 9:16, photoreal, smooth camera"),
        ("Midjourney v6", "Midjourney v6, vertical 9:16, painterly, high contrast"),
    ]

    def _tab_shots(self, parent):
        frame = ctk.CTkFrame(parent, fg_color="transparent")
        frame.columnconfigure(0, weight=1)
        frame.rowconfigure(1, weight=1)

        head = ctk.CTkFrame(frame, fg_color="transparent")
        head.grid(row=0, column=0, sticky="ew", pady=6)
        h = ctk.CTkLabel(head, text=_t("剧本智能分镜", "Script to Shots"), font=ctk.CTkFont(family=CN_FONT, size=20, weight="bold"), text_color=INK)
        h.pack(side="left")
        self._tr(h, "text", "剧本智能分镜", "Script to Shots")

        sub = ctk.CTkFrame(frame, fg_color=PANEL, corner_radius=14)
        sub.grid(row=1, column=0, sticky="nsew")
        sub.columnconfigure(0, weight=3)
        sub.columnconfigure(1, weight=2)
        sub.rowconfigure(0, weight=1)

        # left: input
        left = ctk.CTkFrame(sub, fg_color="transparent")
        left.grid(row=0, column=0, sticky="nsew", padx=5)
        left.columnconfigure(0, weight=1)
        left.rowconfigure(1, weight=1)
        ll = ctk.CTkLabel(left, text=_t("剧本 / 小说内容", "Script / novel text"), font=ctk.CTkFont(family=CN_FONT, size=14),
                          text_color=MUTED, anchor="w")
        ll.grid(row=0, column=0, sticky="ew")
        self._tr(ll, "text", "剧本 / 小说内容", "Script / novel text")
        self.script_box = ctk.CTkTextbox(left, font=ctk.CTkFont(family=CN_FONT, size=14), fg_color=RAISED,
                                         border_color=BORDER, border_width=1, corner_radius=12, wrap="word")
        self.script_box.grid(row=1, column=0, sticky="nsew")
        self._set_script_example()

        ctrl = ctk.CTkFrame(left, fg_color="transparent")
        ctrl.grid(row=2, column=0, sticky="ew", pady=5)
        dl = ctk.CTkLabel(ctrl, text=_t("单镜头时长", "Shot duration"), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED)
        dl.pack(side="left")
        self._tr(dl, "text", "单镜头时长", "Shot duration")
        self.dur_slider = ctk.CTkSlider(ctrl, from_=3, to=8, number_of_steps=5, fg_color=RAISED, progress_color=BLUE)
        self.dur_slider.set(4)
        self.dur_slider.pack(side="left", padx=8, expand=True, fill="x")
        self.dur_value = ctk.CTkLabel(ctrl, text=_t("4 秒", "4 s"), font=ctk.CTkFont(family=MONO_FONT, size=13), text_color=BLUE, width=52)
        self.dur_value.pack(side="left")
        self.dur_slider.configure(command=lambda v: self.dur_value.configure(text=_t(f"{int(v)} 秒", f"{int(v)} s")))

        # model preset
        mrow = ctk.CTkFrame(left, fg_color="transparent")
        mrow.grid(row=3, column=0, sticky="ew", pady=5)
        ml = ctk.CTkLabel(mrow, text=_t("模型提示词预设", "Model preset"), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED)
        ml.pack(side="left")
        self._tr(ml, "text", "模型提示词预设", "Model preset")
        self.model_var = ctk.StringVar(value=self._MODELS[0][0])
        ctk.CTkOptionMenu(mrow, values=[m[0] for m in self._MODELS], variable=self.model_var, width=200,
                          corner_radius=10, fg_color=RAISED, button_color=BORDER, button_hover_color=BLUE_DIM,
                          text_color=INK, font=ctk.CTkFont(family=CN_FONT, size=13)).pack(side="left", padx=8)

        self.parse_btn = ctk.CTkButton(ctrl, text=_t("解析分镜", "Parse"), width=120, height=40, corner_radius=12,
                                       fg_color=BLUE, hover_color=BLUE_DIM, text_color="#FFFFFF",
                                       font=ctk.CTkFont(family=CN_FONT, size=15, weight="bold"), command=self._parse_shots)
        self.parse_btn.pack(side="right")
        self._tr(self.parse_btn, "text", "解析分镜", "Parse")

        # right: cards + export
        right = ctk.CTkFrame(sub, fg_color="transparent")
        right.grid(row=0, column=1, sticky="nsew", padx=5)
        right.columnconfigure(0, weight=1)
        right.rowconfigure(0, weight=1)
        self.shots_scroll = ctk.CTkScrollableFrame(right, fg_color=RAISED, corner_radius=12, border_width=1, border_color=BORDER)
        self.shots_scroll.grid(row=0, column=0, sticky="nsew")

        export = ctk.CTkFrame(right, fg_color="transparent")
        export.grid(row=1, column=0, sticky="ew", pady=5)
        for text, key in [(_t("导出 CSV", "Export CSV"), "csv"), (_t("导出 Excel (TSV)", "Export Excel (TSV)"), "tsv"),
                          (_t("导出 TXT", "Export TXT"), "txt"), (_t("清空", "Clear"), "clear")]:
            b = ctk.CTkButton(export, text=text, width=110, height=34, corner_radius=10, fg_color=RAISED, hover_color=BORDER,
                              text_color=INK, border_width=1, border_color=BORDER,
                              font=ctk.CTkFont(family=CN_FONT, size=13),
                              command=lambda k=key: self._export_prompts(k))
            b.pack(side="left", padx=3)
            if key == "clear":
                self._tr(b, "text", "清空", "Clear")
            else:
                self._tr(b, "text", {"csv": "导出 CSV", "tsv": "导出 Excel (TSV)", "txt": "导出 TXT"}[key],
                         {"csv": "Export CSV", "tsv": "Export Excel (TSV)", "txt": "Export TXT"}[key])

        self.shot_rows = []
        return frame

    def _set_script_example(self):
        if LANG == "en":
            self.script_box.delete("1.0", "end")
            self.script_box.insert("1.0", "[Example]\nA girl meets a stranger at a convenience store on a rainy night. He hands her a black umbrella.\n\nShe says: Thank you. He stays silent, waiting until the light turns green.\n\nThe camera pulls back as rain separates them into two silhouettes under the streetlight.")
        else:
            self.script_box.insert("1.0",
                                   "【示例】\n女孩在雨夜的便利店门口遇见一个陌生男人，他递来一把黑伞。\n\n她说：谢谢你。他没有回答，只是站在原地，直到红灯变绿。\n\n镜头拉远，两人在路灯下被细雨分隔成两个剪影。")

    def _split_sentences(self, text):
        # split into paragraphs first, then sentences
        parts = []
        for para in re.split(r"\n\s*\n", text.strip()):
            if not para.strip():
                continue
            sents = re.split(r"(?<=[。！？!?；;])\s*", para.strip())
            sents = [s.strip() for s in sents if s.strip()]
            parts.extend(sents or [para.strip()])
        return parts

    def _parse_shots(self):
        text = self.script_box.get("1.0", "end").strip()
        if not text:
            self._log(_t("[分镜] 剧本为空。", "[Shots] Script is empty."))
            return
        sentences = self._split_sentences(text)
        if not sentences:
            self._log(_t("[分镜] 未能识别句子。", "[Shots] No sentences found."))
            return
        duration = int(self.dur_slider.get())
        model = self.model_var.get()
        tag = dict(self._MODELS).get(model, self._MODELS[0][1])
        self._clear_shot_rows()
        for i, s in enumerate(sentences, 1):
            self._add_shot_row(i, s, duration, tag)
        self._log(_t(f"[分镜] 解析完成：{len(sentences)} 个镜头 · {model}", f"[Shots] Parsed {len(sentences)} shots · {model}"))

    def _add_shot_row(self, idx, text, duration, tag):
        card = ctk.CTkFrame(self.shots_scroll, fg_color=PANEL, corner_radius=12, border_width=1, border_color=BORDER)
        card.pack(fill="x", pady=4)
        card.columnconfigure(1, weight=1)
        num = ctk.CTkLabel(card, text=f"{idx:02d}", font=ctk.CTkFont(family=MONO_FONT, size=15, weight="bold"),
                           text_color=BLUE, width=46)
        num.grid(row=0, column=0, rowspan=2, padx=12, pady=8)
        meta = ctk.CTkLabel(card, text=_t(f"{duration} 秒 · shot_{idx:02d}", f"{duration}s · shot_{idx:02d}"),
                            font=ctk.CTkFont(family=MONO_FONT, size=11), text_color=MUTED, anchor="w")
        meta.grid(row=0, column=1, sticky="ew", padx=4, pady=4)
        prompt = ctk.CTkTextbox(card, height=60, font=ctk.CTkFont(family=CN_FONT, size=12), wrap="word",
                                fg_color=RAISED, border_color=BORDER, corner_radius=10)
        prompt.insert("1.0", f"{text}\n\n[{tag}]")
        prompt.grid(row=1, column=1, sticky="ew", padx=4, pady=5)
        self.shot_rows.append((idx, prompt, text))

    def _clear_shot_rows(self):
        for child in self.shots_scroll.winfo_children():
            child.destroy()
        self.shot_rows = []

    def _rows_data(self):
        data = []
        for idx, prompt, text in self.shot_rows:
            pt = prompt.get("1.0", "end").strip()
            data.append({"idx": idx, "text": text, "prompt": pt})
        return data

    def _export_prompts(self, kind):
        rows = self._rows_data()
        if not rows:
            self._log(_t("[分镜] 尚无分镜可导出。", "[Shots] Nothing to export."))
            return
        if kind == "clear":
            self._clear_shot_rows()
            self._log(_t("[分镜] 已清空。", "[Shots] Cleared."))
            return
        ext = {"csv": ".csv", "tsv": ".tsv", "txt": ".txt"}[kind]
        path = filedialog.asksaveasfilename(defaultextension=ext, filetypes=[("File", "*" + ext)],
                                            initialfile=f"storyboard_{kind}{ext}")
        if not path:
            return
        if kind == "csv":
            lines = ["idx,text,prompt"]
            for r in rows:
                lines.append(f'{r["idx"]},"{r["text"].replace(chr(34), chr(34)*2)}","{r["prompt"].replace(chr(34), chr(34)*2)}"')
            Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8-sig")
        elif kind == "tsv":
            lines = ["idx\ttext\tprompt"]
            for r in rows:
                lines.append(f'{r["idx"]}\t{r["text"]}\t{r["prompt"]}')
            Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8-sig")
        elif kind == "txt":
            blocks = [f"SHOT {r['idx']:02d} | {r['prompt']}" for r in rows]
            Path(path).write_text("\n\n".join(blocks) + "\n", encoding="utf-8")
        self._log(_t(f"[分镜] 已导出：{path}", f"[Shots] Exported: {path}"))

    # ================================================================ TAB 2
    def _tab_batch(self, parent):
        frame = ctk.CTkFrame(parent, fg_color="transparent")
        frame.columnconfigure(0, weight=1)
        frame.rowconfigure(1, weight=1)

        head = ctk.CTkFrame(frame, fg_color="transparent")
        head.grid(row=0, column=0, sticky="ew", pady=6)
        h = ctk.CTkLabel(head, text=_t("批量视频处理", "Batch Video"), font=ctk.CTkFont(family=CN_FONT, size=20, weight="bold"), text_color=INK)
        h.pack(side="left")
        self._tr(h, "text", "批量视频处理", "Batch Video")
        self.batch_folder_label = ctk.CTkLabel(head, text=_t("未选择文件夹", "No folder selected"),
                                               font=ctk.CTkFont(family=MONO_FONT, size=12), text_color=MUTED)
        self.batch_folder_label.pack(side="right")
        self._tr(self.batch_folder_label, "text", "未选择文件夹", "No folder selected")
        b_pick = ctk.CTkButton(head, text=_t("选择文件夹", "Choose folder"), width=120, height=34, corner_radius=10,
                               fg_color=RAISED, hover_color=BORDER, text_color=INK, border_width=1, border_color=BORDER,
                               font=ctk.CTkFont(family=CN_FONT, size=13), command=self._pick_folder)
        b_pick.pack(side="right", padx=6)
        self._tr(b_pick, "text", "选择文件夹", "Choose folder")

        body = ctk.CTkFrame(frame, fg_color=PANEL, corner_radius=14)
        body.grid(row=1, column=0, sticky="nsew")
        body.columnconfigure(0, weight=1)
        body.rowconfigure(1, weight=1)

        presets = ctk.CTkFrame(body, fg_color="transparent")
        presets.grid(row=0, column=0, sticky="ew", padx=16, pady=12)
        pl = ctk.CTkLabel(presets, text=_t("输出预设", "Output preset"), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED)
        pl.pack(side="left", padx=5)
        self._tr(pl, "text", "输出预设", "Output preset")
        self.preset_var = ctk.StringVar(value="Vertical Blur Fill")
        ctk.CTkOptionMenu(presets, values=["Vertical Blur Fill", "16:9", "Loudness normalize"], variable=self.preset_var,
                          width=210, corner_radius=10, fg_color=RAISED, button_color=BORDER, button_hover_color=BLUE_DIM,
                          text_color=INK, font=ctk.CTkFont(family=CN_FONT, size=13)).pack(side="left")
        self.lufs_var = ctk.IntVar(value=0)
        cb = ctk.CTkCheckBox(presets, text=_t("加 -14 LUFS 响度归一化", "Add -14 LUFS"),
                             variable=self.lufs_var, font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED,
                             fg_color=BLUE_DIM, hover_color=BLUE_DIM, corner_radius=6)
        cb.pack(side="left", padx=14)
        self._tr(cb, "text", "加 -14 LUFS 响度归一化", "Add -14 LUFS")
        self._log_box_frame = body
        self.log_box = ctk.CTkTextbox(body, font=ctk.CTkFont(family=MONO_FONT, size=12), fg_color=RAISED,
                                      border_color=BORDER, corner_radius=12)
        self.log_box.grid(row=1, column=0, sticky="nsew", padx=16, pady=4)

        self.batch_progress = ctk.CTkProgressBar(body, fg_color=RAISED, progress_color=BLUE, height=10, corner_radius=5)
        self.batch_progress.set(0)
        self.batch_progress.grid(row=2, column=0, sticky="ew", padx=16, pady=8)
        self.batch_count = ctk.CTkLabel(body, text="0 / 0", font=ctk.CTkFont(family=MONO_FONT, size=12), text_color=MUTED)
        self.batch_count.grid(row=3, column=0, sticky="ew", padx=16)

        b_check = ctk.CTkButton(body, text=_t("检测 FFmpeg", "Check FFmpeg"), width=130, height=40, corner_radius=12,
                                fg_color=RAISED, hover_color=BORDER, text_color=INK, border_width=1, border_color=BORDER,
                                font=ctk.CTkFont(family=CN_FONT, size=13), command=self._check_ffmpeg)
        b_check.place(relx=0.02, rely=0.92, anchor="sw")
        self._tr(b_check, "text", "检测 FFmpeg", "Check FFmpeg")
        self.batch_btn = ctk.CTkButton(body, text=_t("开始批量处理", "Start batch"), width=180, height=44, corner_radius=12,
                                       fg_color=BLUE, hover_color=BLUE_DIM, text_color="#FFFFFF",
                                       font=ctk.CTkFont(family=CN_FONT, size=15, weight="bold"), command=self._run_batch)
        self.batch_btn.place(relx=0.98, rely=0.92, anchor="se")
        self._tr(self.batch_btn, "text", "开始批量处理", "Start batch")
        return frame

    def _pick_folder(self):
        path = filedialog.askdirectory(title=_t("选择视频文件夹", "Choose video folder"))
        if path:
            self.batch_folder = Path(path)
            self.batch_folder_label.configure(text=f"{_t('已选择：', 'Selected: ')}{path}")

    def _check_ffmpeg(self):
        ff = find_ffmpeg()
        fp = find_ffprobe()
        if ff and fp:
            self.ffmpeg_status.configure(text=f"FFmpeg ✓ {ff}", text_color=GOOD)
            self._log(_t(f"[批量] FFmpeg 可用：{ff} / ffprobe：{fp}", f"[Batch] FFmpeg {ff} / ffprobe {fp}"))
        elif ff:
            self.ffmpeg_status.configure(text=_t("FFmpeg ✓ (无 ffprobe)", "FFmpeg ✓ (no ffprobe)"), text_color=WARN)
            self._show_ffmpeg_missing(silent=_t("FFmpeg 已找到，但缺少 ffprobe。", "ffmpeg found, but ffprobe missing."))
        else:
            self.ffmpeg_status.configure(text=_t("FFmpeg ✗ 未安装", "FFmpeg ✗ not found"), text_color=ERR)
            self._show_ffmpeg_missing()

    def _show_ffmpeg_missing(self, silent=""):
        win = ctk.CTkToplevel(self)
        win.title(_t("需要 FFmpeg", "FFmpeg required"))
        win.geometry("560x320")
        win.configure(fg_color=BG)
        win.grab_set()
        t1 = ctk.CTkLabel(win, text=_t("未完整检测到 FFmpeg / ffprobe", "FFmpeg / ffprobe not fully detected"),
                          font=ctk.CTkFont(family=CN_FONT, size=17, weight="bold"), text_color=INK, anchor="w")
        t1.pack(anchor="w", padx=22, pady=12)
        t2 = ctk.CTkLabel(win, text=silent or _t("批量视频处理依赖 FFmpeg + ffprobe，未找到可用二进制。",
                                                 "Batch processing needs FFmpeg + ffprobe, but none found."),
                          font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED, anchor="w", justify="left", wraplength=500)
        t2.pack(anchor="w", padx=22, pady=6)
        btns = ctk.CTkFrame(win, fg_color="transparent")
        btns.pack(anchor="w", padx=22, pady=10)
        bd = ctk.CTkButton(btns, text=_t("下载 FFmpeg (Gyan.dev)", "Download FFmpeg (Gyan.dev)"), width=210, height=38, corner_radius=10,
                           fg_color=BLUE, hover_color=BLUE_DIM, text_color="#FFFFFF", font=ctk.CTkFont(family=CN_FONT, size=13),
                           command=lambda: open_link("https://www.gyan.dev/ffmpeg/builds/"))
        bd.pack(side="left", padx=4)
        bg = ctk.CTkButton(btns, text=_t("安装指引", "Guide"), width=100, height=38, corner_radius=10,
                           fg_color=RAISED, hover_color=BORDER, text_color=INK, border_width=1, border_color=BORDER,
                           font=ctk.CTkFont(family=CN_FONT, size=13), command=self._ffmpeg_guidance)
        bg.pack(side="left", padx=8)
        bc = ctk.CTkButton(btns, text=_t("关闭", "Close"), width=80, height=38, corner_radius=10,
                           fg_color=RAISED, hover_color=BORDER, text_color=MUTED, border_width=1, border_color=BORDER,
                           font=ctk.CTkFont(family=CN_FONT, size=13), command=win.destroy)
        bc.pack(side="left", padx=8)

    def _ffmpeg_guidance(self):
        messagebox.showinfo(_t("安装 FFmpeg 指引", "Install FFmpeg guide"),
                            _t("三种方式：\n1) Gyan.dev 便携版：https://www.gyan.dev/ffmpeg/builds/\n   解压后把 bin 加入 PATH，或放到程序同目录 / bin\\ / ffmpeg\\。\n2) winget install ffmpeg\n3) ffmpeg.org/download.html",
                               "Three ways:\n1) Gyan.dev portable: https://www.gyan.dev/ffmpeg/builds/\n   add bin to PATH, or put next to app / bin\\ / ffmpeg\\.\n2) winget install ffmpeg\n3) ffmpeg.org/download.html"))

    def _preset_cmd(self, src, dst):
        preset = self.preset_var.get()
        ff = find_ffmpeg() or "ffmpeg"
        base = ["-y", "-hide_banner", "-loglevel", "error", "-i", str(src)]
        apply_lufs = preset == "Loudness normalize" or self.lufs_var.get() == 1
        if preset == "Vertical Blur Fill":
            vf = ("[0:v]split=2[bg][fg];[bg]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30[bgv];"
                  "[fg]scale=1080:1920:force_original_aspect_ratio=decrease[fgv];[bgv][fgv]overlay=(W-w)/2:(H-h)/2:format=auto,format=yuv420p[v]")
            args = base + ["-filter_complex", vf, "-map", "[v]", "-map", "0:a:0"]
        elif preset == "16:9":
            vf = "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p"
            args = base + ["-vf", vf, "-map", "0:a:0"]
        else:
            args = base + ["-c:v", "copy"]
        audio = ["-c:a", "copy"]
        if apply_lufs:
            args = args + ["-af", "loudnorm=I=-14:TP=-1.5:LRA=11"]
            audio = ["-c:a", "aac", "-b:a", "192k"]
        args = args + audio + ["-shortest", str(dst)]
        return [ff] + args

    def _run_batch(self):
        ff, fp = find_ffmpeg(), find_ffprobe()
        if not ff or not fp:
            self._log(_t("[批量] 未找到 FFmpeg / ffprobe。", "[Batch] FFmpeg / ffprobe not found."))
            self._show_ffmpeg_missing()
            return
        folder = getattr(self, "batch_folder", None)
        if not folder or not folder.exists():
            self._log(_t("[批量] 请先选择视频文件夹。", "[Batch] Choose a video folder first."))
            return
        files = [f for f in folder.iterdir() if f.suffix.lower() in (".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v")]
        if not files:
            self._log(_t("[批量] 文件夹中没有视频文件。", "[Batch] No video files in folder."))
            return
        with self._batch_lock:
            self._batch_done = 0
            self._batch_total = len(files)
        self._set_batch_count(0, len(files))
        self._log(_t(f"[批量] 开始处理 {len(files)} 个文件…", f"[Batch] Processing {len(files)}…"))
        threads = [threading.Thread(target=self._process_one, args=(f,), daemon=True) for f in files]
        for t in threads:
            t.start()
        threading.Thread(target=self._join_and_report, args=(threads,), daemon=True).start()

    def _join_and_report(self, threads):
        for t in threads:
            t.join()
        self._log(_t("[批量] 全部处理完成。", "[Batch] All done."))
        self.batch_btn.configure(state="normal")
        with self._batch_lock:
            done, total = self._batch_done, self._batch_total
        self._set_batch_count(done, total)

    def _set_batch_count(self, done, total):
        self.batch_progress.set(done / total if total else 0)
        self.batch_count.configure(text=f"{done} / {total}")

    def _process_one(self, src):
        self.batch_btn.configure(state="disabled")
        dst = src.with_name(src.stem + "_carryyoo.mp4")
        cmd = self._preset_cmd(src, dst)
        self._log(f"▶ {src.name}")
        try:
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800,
                               creationflags=subprocess.CREATE_NO_WINDOW if is_windows() else 0)
            if r.returncode == 0 and dst.exists():
                self._log(_t(f"✓ 完成：{dst.name}", f"✓ Done: {dst.name}"))
            else:
                short = (r.stderr or r.stdout or "").strip().splitlines()
                self._log(_t(f"✗ 失败：{src.name} - {(short[-1] if short else '?')}", f"✗ Failed: {src.name}"))
        except subprocess.TimeoutExpired:
            self._log(_t(f"✗ 超时：{src.name}", f"✗ Timeout: {src.name}"))
        except Exception as e:
            self._log(_t(f"✗ 异常：{src.name} - {e}", f"✗ Error: {src.name} - {e}"))
        finally:
            with self._batch_lock:
                self._batch_done += 1
                done, total = self._batch_done, self._batch_total
            self._set_batch_count(done, total)

    # ================================================================ TAB 3
    _MM_SUBMIT = "https://api.minimax.chat/v1/video_generation"
    _MM_QUERY = "https://api.minimax.chat/v1/query/video_generation"

    def _tab_minimax(self, parent):
        frame = ctk.CTkFrame(parent, fg_color="transparent")
        frame.columnconfigure(0, weight=1)
        frame.rowconfigure(1, weight=1)

        head = ctk.CTkFrame(frame, fg_color="transparent")
        head.grid(row=0, column=0, sticky="ew", pady=6)
        h = ctk.CTkLabel(head, text=_t("MiniMax 视频流水线", "MiniMax Video Pipeline"), font=ctk.CTkFont(family=CN_FONT, size=20, weight="bold"), text_color=INK)
        h.pack(side="left")
        self._tr(h, "text", "MiniMax 视频流水线", "MiniMax Video Pipeline")

        body = ctk.CTkFrame(frame, fg_color=PANEL, corner_radius=14)
        body.grid(row=1, column=0, sticky="nsew")
        body.columnconfigure(0, weight=1)
        body.rowconfigure(2, weight=1)

        # note
        note = ctk.CTkLabel(body, text=_t("使用你自己的 MiniMax API Key 与 Group ID。生成按你的账号计费，本程序不提供免费生成，也不代付算力。密钥仅保存在本次会话内存，不会写入日志。",
                                          "Uses your own MiniMax API Key & Group ID. Generation is billed to YOUR account - this app does not offer free generation nor pay for compute. The key stays in memory for this session and is never logged."),
                            font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED, anchor="w", justify="left", wraplength=1100, padx=18, pady=11)
        note.grid(row=0, column=0, sticky="ew")
        self._tr(note, "text",
                 "使用你自己的 MiniMax API Key 与 Group ID。生成按你的账号计费，本程序不提供免费生成，也不代付算力。密钥仅保存在本次会话内存，不会写入日志。",
                 "Uses your own MiniMax API Key & Group ID. Generation is billed to YOUR account - this app does not offer free generation nor pay for compute. The key stays in memory and is never logged.")

        creds = ctk.CTkFrame(body, fg_color="transparent")
        creds.grid(row=1, column=0, sticky="ew", padx=18)
        creds.columnconfigure(1, weight=1)
        k1 = ctk.CTkLabel(creds, text=_t("API Key", "API Key"), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED)
        k1.grid(row=0, column=0, sticky="w", padx=4, pady=4)
        self.mm_key = ctk.CTkEntry(creds, show="*", fg_color=RAISED, border_color=BORDER, corner_radius=10,
                                   font=ctk.CTkFont(family=MONO_FONT, size=13),
                                   placeholder_text=_t("sk-…（仅本次会话）", "sk-… (this session only)"))
        self.mm_key.grid(row=0, column=1, columnspan=2, sticky="ew", pady=4)
        self._tr(self.mm_key, "placeholder", "sk-…（仅本次会话）", "sk-… (this session only)")
        g1 = ctk.CTkLabel(creds, text=_t("Group ID", "Group ID"), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED)
        g1.grid(row=1, column=0, sticky="w", padx=4, pady=4)
        self.mm_group = ctk.CTkEntry(creds, fg_color=RAISED, border_color=BORDER, corner_radius=10,
                                     font=ctk.CTkFont(family=MONO_FONT, size=13), placeholder_text="xxxxxxxxxxxxxxxx")
        self.mm_group.grid(row=1, column=1, columnspan=2, sticky="ew", pady=4)
        p1 = ctk.CTkLabel(creds, text=_t("提示词", "Prompt"), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED)
        p1.grid(row=2, column=0, sticky="w", padx=4, pady=4)
        self.mm_prompt = ctk.CTkEntry(creds, fg_color=RAISED, border_color=BORDER, corner_radius=10,
                                      font=ctk.CTkFont(family=CN_FONT, size=13),
                                      placeholder_text=_t("一段镜头描述…", "One-shot description…"))
        self.mm_prompt.grid(row=2, column=1, sticky="ew", pady=4)
        self._tr(self.mm_prompt, "placeholder", "一段镜头描述…", "One-shot description…")
        btn = ctk.CTkButton(creds, text=_t("一次性测试", "One-shot test"), width=140, height=36, corner_radius=10,
                            fg_color=BLUE, hover_color=BLUE_DIM, text_color="#FFFFFF", font=ctk.CTkFont(family=CN_FONT, size=13),
                            command=self._mm_one_shot)
        btn.grid(row=2, column=2, padx=4, pady=4)
        self._tr(btn, "text", "一次性测试", "One-shot test")
        b2 = ctk.CTkButton(creds, text=_t("批量渲染", "Batch render"), width=140, height=36, corner_radius=10,
                           fg_color=RAISED, hover_color=BORDER, text_color=INK, border_width=1, border_color=BORDER,
                           font=ctk.CTkFont(family=CN_FONT, size=13), command=self._mm_batch)
        b2.grid(row=2, column=3, padx=4, pady=4)
        self._tr(b2, "text", "批量渲染", "Batch render")

        self.mm_progress = ctk.CTkProgressBar(body, fg_color=RAISED, progress_color=PURPLE, height=10, corner_radius=5)
        self.mm_progress.set(0)
        self.mm_progress.grid(row=3, column=0, sticky="ew", padx=18, pady=8)
        self.mm_status = ctk.CTkLabel(body, text=_t("就绪", "Ready"), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED, anchor="w", padx=18)
        self.mm_status.grid(row=4, column=0, sticky="ew")

        self.mm_log = ctk.CTkTextbox(body, font=ctk.CTkFont(family=MONO_FONT, size=12), fg_color=RAISED,
                                     border_color=BORDER, corner_radius=12)
        self.mm_log.grid(row=2, column=0, sticky="nsew", padx=18, pady=4)
        self._mm_log_box = self.mm_log
        return frame

    def _mm_log(self, msg):
        def push():
            self.mm_log.insert("end", f"[{ts_now()}] {msg}\n")
            self.mm_log.see("end")
        self.after(0, push)

    def _mm_creds(self):
        key = self.mm_key.get().strip()
        group = self.mm_group.get().strip()
        return key, group

    def _mm_submit(self, key, group, prompt):
        url = f"{self._MM_SUBMIT}?GroupId={urllib.parse.quote(group)}"
        body = json.dumps({"model": "MiniMax-VL-01", "prompt": prompt}).encode("utf-8")
        req = urllib.request.Request(url, data=body, method="POST",
                                     headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))

    def _mm_poll(self, key, group, file_id):
        url = f"{self._MM_QUERY}?GroupId={urllib.parse.quote(group)}"
        body = json.dumps({"file_id": file_id}).encode("utf-8")
        req = urllib.request.Request(url, data=body, method="POST",
                                     headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))

    def _mm_one_shot(self):
        key, group = self._mm_creds()
        prompt = self.mm_prompt.get().strip()
        if not key or not group or not prompt:
            messagebox.showwarning(_t("缺少参数", "Missing input"),
                                   _t("请填写 API Key、Group ID 与提示词。", "Fill in API Key, Group ID and prompt."))
            return
        self.mm_status.configure(text=_t("提交中…", "Submitting…"), text_color=BLUE)
        threading.Thread(target=self._mm_run, args=(key, group, [prompt]), daemon=True).start()

    def _mm_batch(self):
        key, group = self._mm_creds()
        if not key or not group:
            messagebox.showwarning(_t("缺少参数", "Missing input"), _t("请填写 API Key 与 Group ID。", "Fill in API Key and Group ID."))
            return
        rows = self._rows_data()
        prompts = [r["prompt"] for r in rows] if rows else []
        if not prompts:
            # fall back to current prompt field
            if self.mm_prompt.get().strip():
                prompts = [self.mm_prompt.get().strip()]
            else:
                messagebox.showwarning(_t("无提示词", "No prompts"), _t("请先在分镜页生成分镜，或填写提示词。", "Generate shots first, or fill a prompt."))
                return
        self.mm_status.configure(text=_t("批量渲染中…", "Batch rendering…"), text_color=PURPLE)
        threading.Thread(target=self._mm_run, args=(key, group, prompts), daemon=True).start()

    def _mm_run(self, key, group, prompts):
        out = Path(__file__).resolve().parent / "output"
        out.mkdir(exist_ok=True)
        total = len(prompts)
        for i, p in enumerate(prompts, 1):
            self._mm_log(_t(f"[MiniMax] 提交 {i}/{total}…", f"[MiniMax] Submit {i}/{total}…"))
            try:
                sub = self._mm_submit(key, group, p)
            except Exception as e:
                self._polite_net_error(_t(f"提交失败：{e}", f"Submit failed: {e}"))
                self._mm_log(_t(f"[MiniMax] 提交失败：{e}", f"[MiniMax] Submit failed: {e}"))
                continue
            file_id = None
            for k in ("file_id", "video_id", "id"):
                if sub.get(k):
                    file_id = sub[k]
                    break
            if not file_id:
                self._mm_log(_t(f"[MiniMax] 未取得任务 id：{sub}", f"[MiniMax] No task id: {sub}"))
                continue
            self._mm_log(_t(f"[MiniMax] 任务 {file_id} 渲染中…", f"[MiniMax] Rendering {file_id}…"))
            done = False
            for _ in range(60):
                time.sleep(4)
                try:
                    q = self._mm_poll(key, group, file_id)
                except Exception as e:
                    self._mm_log(_t(f"[MiniMax] 轮询错误：{e}", f"[MiniMax] Poll error: {e}"))
                    time.sleep(4)
                    continue
                status = str(q.get("status", "")).lower() or str(q.get("file_status", "")).lower()
                if status in ("success", "succeed", "ok", "completed"):
                    video_url = q.get("file", {}).get("download_url") or q.get("download_url") or q.get("video", "")
                    if video_url:
                        fname = f"minimax_{i:02d}.mp4"
                        dest = out / fname
                        try:
                            urllib.request.urlretrieve(video_url, dest)
                            self._mm_log(_t(f"[MiniMax] ✓ 已保存：{dest}", f"[MiniMax] ✓ Saved: {dest}"))
                        except Exception as e:
                            self._polite_net_error(_t(f"下载失败：{e}", f"Download failed: {e}"))
                    else:
                        self._mm_log(_t(f"[MiniMax] 成功但未返回下载地址：{q}", f"[MiniMax] Success but no URL: {q}"))
                    done = True
                    break
                if status in ("fail", "failed", "error"):
                    self._mm_log(_t(f"[MiniMax] 渲染失败：{q}", f"[MiniMax] Render failed: {q}"))
                    done = True
                    break
            if not done:
                self._mm_log(_t(f"[MiniMax] 任务 {file_id} 超时未完成。", f"[MiniMax] {file_id} timed out."))
            self.after(0, lambda i=i: self.mm_progress.set(i / total))
        self.after(0, lambda: self.mm_status.configure(text=_t("完成 · 输出到 output/ 目录", "Done · saved to output/ folder"), text_color=GOOD))
        self._log(_t(f"[MiniMax] 输出目录：{out}", f"[MiniMax] Output folder: {out}"))

    # ================================================================ TAB 4
    _MODE_MAP = {"自动检测": "auto", "本地直连 (海外环境)": "direct", "自定义代理 (国内环境)": "proxy"}
    _MODE_MAP_EN = {"Auto detect": "auto", "Direct (overseas)": "direct", "Custom proxy (domestic)": "proxy"}

    def _mode_options(self):
        return list(self._MODE_MAP_EN) if LANG == "en" else list(self._MODE_MAP)

    def _mode_key(self, display):
        m = self._MODE_MAP_EN if LANG == "en" else self._MODE_MAP
        return m.get(display, "auto")

    def _mode_label(self, key):
        m = {v: k for k, v in (self._MODE_MAP_EN if LANG == "en" else self._MODE_MAP).items()}
        return m.get(key, list(m)[0])

    def _refresh_netmode(self):
        try:
            self.network_mode_cb.configure(values=self._mode_options())
            self.network_mode_var.set(self._mode_label(self._last_mode_key))
            self._apply_net_mode_visibility()
        except Exception:
            pass

    def _tab_assets(self, parent):
        frame = ctk.CTkFrame(parent, fg_color="transparent")
        frame.columnconfigure(0, weight=2)
        frame.columnconfigure(1, weight=1)
        frame.rowconfigure(1, weight=1)

        head = ctk.CTkFrame(frame, fg_color="transparent")
        head.grid(row=0, column=0, columnspan=2, sticky="ew", pady=6)
        h = ctk.CTkLabel(head, text=_t("全球资产与支持", "Global Assets & Support"), font=ctk.CTkFont(family=CN_FONT, size=20, weight="bold"), text_color=INK)
        h.pack(side="left")
        self._tr(h, "text", "全球资产与支持", "Global Assets & Support")
        self.hub_status = ctk.CTkLabel(head, text=_t("网络状态：待检测", "Network: not tested"), font=ctk.CTkFont(family=CN_FONT, size=13),
                                       text_color=MUTED, padx=14, pady=4, corner_radius=8, fg_color=PANEL)
        self.hub_status.pack(side="right")

        left = ctk.CTkFrame(frame, fg_color="transparent")
        left.grid(row=1, column=0, sticky="nsew", padx=5)
        left.columnconfigure(0, weight=1)

        nc = ctk.CTkFrame(left, fg_color=PANEL, corner_radius=14)
        nc.grid(row=0, column=0, sticky="nsew")
        t = ctk.CTkLabel(nc, text=_t("网络模式与代理", "Network mode & proxy"), font=ctk.CTkFont(family=CN_FONT, size=15, weight="bold"),
                         text_color=INK, anchor="w", padx=16, pady=10)
        t.pack(fill="x")
        self._tr(t, "text", "网络模式与代理", "Network mode & proxy")
        nr = ctk.CTkFrame(nc, fg_color="transparent")
        nr.pack(fill="x", padx=14, pady=6)
        nl = ctk.CTkLabel(nr, text=_t("网络模式：", "Network mode:"), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED)
        nl.pack(side="left")
        self._tr(nl, "text", "网络模式：", "Network mode:")
        self._last_mode_key = self.cfg.get("network_mode", "auto") or "auto"
        self.network_mode_var = ctk.StringVar(value=self._mode_label(self._last_mode_key))
        self.network_mode_cb = ctk.CTkOptionMenu(nr, values=self._mode_options(), variable=self.network_mode_var, width=280,
                                                 corner_radius=10, fg_color=RAISED, button_color=BORDER, button_hover_color=BLUE_DIM,
                                                 text_color=INK, font=ctk.CTkFont(family=CN_FONT, size=13), command=self._on_net_mode_change)
        self.network_mode_cb.pack(side="left", padx=4)
        self.net_mode_context = ctk.CTkLabel(nr, text="", font=ctk.CTkFont(family=CN_FONT, size=12), text_color=BLUE, anchor="w")
        self.net_mode_context.pack(side="left", padx=8)

        self.proxy_frame = ctk.CTkFrame(nc, fg_color=RAISED, corner_radius=10)
        self.proxy_frame.pack(fill="x", padx=14, pady=6)
        prow = ctk.CTkFrame(self.proxy_frame, fg_color="transparent")
        prow.pack(fill="x", padx=12, pady=8)
        self.proxy_host = ctk.CTkEntry(prow, width=190, fg_color=PANEL, border_color=BORDER, corner_radius=10,
                                       font=ctk.CTkFont(family=MONO_FONT, size=13), placeholder_text="127.0.0.1")
        self.proxy_host.insert(0, self.cfg["proxy_host"])
        self.proxy_host.pack(side="left")
        self.proxy_port = ctk.CTkEntry(prow, width=80, fg_color=PANEL, border_color=BORDER, corner_radius=10,
                                       font=ctk.CTkFont(family=MONO_FONT, size=13), placeholder_text="10808")
        self.proxy_port.insert(0, self.cfg["proxy_port"])
        self.proxy_port.pack(side="left", padx=8)
        self.proxy_type = ctk.CTkOptionMenu(prow, values=["HTTP", "SOCKS5"], width=110, corner_radius=10,
                                            fg_color=PANEL, button_color=BORDER, button_hover_color=BLUE_DIM,
                                            text_color=INK, font=ctk.CTkFont(family=MONO_FONT, size=13))
        self.proxy_type.set(self.cfg["proxy_type"])
        self.proxy_type.pack(side="left", padx=4)
        bs = ctk.CTkButton(prow, text=_t("保存", "Save"), width=70, height=30, corner_radius=10, fg_color=BLUE,
                           hover_color=BLUE_DIM, text_color="#FFFFFF", font=ctk.CTkFont(family=CN_FONT, size=13), command=self._save_proxy)
        bs.pack(side="left")
        self._tr(bs, "text", "保存", "Save")

        diag = ctk.CTkFrame(left, fg_color=PANEL, corner_radius=14)
        diag.grid(row=1, column=0, sticky="ew", pady=5)
        t1 = ctk.CTkLabel(diag, text=_t("连通性检测（按需）", "Connectivity (on demand)"), font=ctk.CTkFont(family=CN_FONT, size=15, weight="bold"),
                          text_color=INK, anchor="w", padx=16, pady=8)
        t1.pack(fill="x")
        self._tr(t1, "text", "连通性检测（按需）", "Connectivity (on demand)")
        drow = ctk.CTkFrame(diag, fg_color="transparent")
        drow.pack(fill="x", padx=14, pady=6)
        self.diag_result_label = ctk.CTkLabel(drow, text=_t("尚未检测", "Not tested"), font=ctk.CTkFont(family=CN_FONT, size=13),
                                              text_color=MUTED, anchor="w", wraplength=420)
        self.diag_result_label.pack(side="left", fill="x", expand=True)
        self._tr(self.diag_result_label, "text", "尚未检测", "Not tested")
        bp = ctk.CTkButton(drow, text=_t("检测连通性", "Test connectivity"), width=140, height=30, corner_radius=10,
                           fg_color=BLUE, hover_color=BLUE_DIM, text_color="#FFFFFF", font=ctk.CTkFont(family=CN_FONT, size=13), command=self._run_probe)
        bp.pack(side="right")
        self._tr(bp, "text", "检测连通性", "Test connectivity")

        # Freesound
        fs = ctk.CTkFrame(left, fg_color=PANEL, corner_radius=14)
        fs.grid(row=2, column=0, sticky="nsew", pady=5)
        fs.columnconfigure(0, weight=1)
        t2 = ctk.CTkLabel(fs, text=_t("Freesound 搜索（公共 API）", "Freesound search (public API)"),
                          font=ctk.CTkFont(family=CN_FONT, size=15, weight="bold"), text_color=INK, anchor="w", padx=16, pady=8)
        t2.grid(row=0, column=0, columnspan=3, sticky="ew")
        self._tr(t2, "text", "Freesound 搜索（公共 API）", "Freesound search (public API)")
        self.fs_token = ctk.CTkEntry(fs, fg_color=RAISED, border_color=BORDER, corner_radius=10, show="*",
                                     font=ctk.CTkFont(family=MONO_FONT, size=12),
                                     placeholder_text=_t("Freesound API Token", "Freesound API token"))
        self.fs_token.grid(row=1, column=0, columnspan=3, sticky="ew", padx=14, pady=4)
        self._tr(self.fs_token, "placeholder", "Freesound API Token", "Freesound API token")
        if self.cfg.get("freesound_token"):
            self.fs_token.insert(0, self.cfg["freesound_token"])
        self.fs_query = ctk.CTkEntry(fs, fg_color=RAISED, border_color=BORDER, corner_radius=10,
                                     font=ctk.CTkFont(family=CN_FONT, size=13),
                                     placeholder_text=_t("例如：rain, ambient…", "e.g. rain, ambient…"))
        self.fs_query.grid(row=2, column=0, sticky="ew", padx=14, pady=4)
        self._tr(self.fs_query, "placeholder", "例如：rain, ambient…", "e.g. rain, ambient…")
        bf = ctk.CTkButton(fs, text=_t("搜索", "Search"), width=80, height=30, corner_radius=10, fg_color=BLUE,
                           hover_color=BLUE_DIM, text_color="#FFFFFF", font=ctk.CTkFont(family=CN_FONT, size=13), command=self._freesound_search)
        bf.grid(row=2, column=1, padx=6)
        self._tr(bf, "text", "搜索", "Search")
        self.fs_results = ctk.CTkTextbox(fs, font=ctk.CTkFont(family=MONO_FONT, size=11), fg_color=RAISED,
                                         border_color=BORDER, corner_radius=10, height=120)
        self.fs_results.grid(row=3, column=0, columnspan=3, sticky="ew", padx=14, pady=6)

        # right: support / CarryVPN
        right = ctk.CTkFrame(frame, fg_color="transparent")
        right.grid(row=1, column=1, sticky="nsew", padx=5)
        right.columnconfigure(0, weight=1)
        support = ctk.CTkFrame(right, fg_color=PANEL, corner_radius=14)
        support.grid(row=0, column=0, sticky="nsew")
        s1 = ctk.CTkLabel(support, text=_t("CarryVPN / 技术支持", "CarryVPN / Support"), font=ctk.CTkFont(family=CN_FONT, size=15, weight="bold"),
                          text_color=INK, anchor="w", padx=16, pady=8)
        s1.pack(fill="x")
        self._tr(s1, "text", "CarryVPN / 技术支持", "CarryVPN / Support")
        help_zh = ("提示：国内直连海外 AI 视频模型 (Hailuo Global/Runway) 及无损素材站常出现超时与丢包。"
                   "需要原生极速专线加速通道请联系官方获取 CarryVPN 配置与支持。")
        help_en = ("Note: direct connections to overseas AI video models (Hailuo Global/Runway) and lossless asset "
                   "sites often time out from mainland China. Contact us for CarryVPN setup & support.")
        hl = ctk.CTkLabel(support, text=_t(help_zh, help_en), font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED,
                          anchor="w", padx=16, wraplength=360, justify="left")
        hl.pack(fill="x", pady=4)
        self._tr(hl, "text", help_zh, help_en)
        c = ctk.CTkLabel(support, text="微信: HEIGUIYO · Telegram: @CARRYYOO", font=ctk.CTkFont(family=MONO_FONT, size=13),
                         text_color=BLUE, anchor="w", padx=16, pady=3)
        c.pack(fill="x")
        bc = ctk.CTkButton(support, text=_t("复制联系方式", "Copy contact"), width=140, height=32, corner_radius=10,
                           fg_color=RAISED, hover_color=BORDER, text_color=INK, border_width=1, border_color=BORDER,
                           font=ctk.CTkFont(family=CN_FONT, size=13), command=self._copy_contact)
        bc.pack(anchor="w", padx=16, pady=7)
        self._tr(bc, "text", "复制联系方式", "Copy contact")
        return frame

    def _on_net_mode_change(self, _=None):
        self._last_mode_key = self._mode_key(self.network_mode_var.get())
        self._apply_net_mode_visibility()

    def _apply_net_mode_visibility(self):
        mode_key = self._mode_key(self.network_mode_var.get())
        if mode_key == "proxy":
            self.proxy_frame.grid()
        else:
            self.proxy_frame.grid_remove()
        hints = {"自动检测": _t("先直连，失败后试代理", "Direct first, then proxy"),
                 "本地直连 (海外环境)": _t("仅直连，无需代理", "Direct only, no proxy"),
                 "自定义代理 (国内环境)": _t("经本地代理访问", "Via local proxy"),
                 "Auto detect": _t("先直连，失败后试代理", "Direct first, then proxy"),
                 "Direct (overseas)": _t("仅直连，无需代理", "Direct only, no proxy"),
                 "Custom proxy (domestic)": _t("经本地代理访问", "Via local proxy")}
        self.net_mode_context.configure(text=hints.get(self.network_mode_var.get(), ""))

    def _save_proxy(self):
        key = self._mode_key(self.network_mode_var.get())
        self._last_mode_key = key
        self.cfg["network_mode"] = key
        self.cfg["proxy_host"] = self.proxy_host.get().strip()
        self.cfg["proxy_port"] = self.proxy_port.get().strip()
        self.cfg["proxy_type"] = self.proxy_type.get()
        self.cfg["lang"] = LANG
        res = save_config(self.cfg)
        self._log(_t("[Hub] 代理/模式已保存。", "[Hub] Proxy/mode saved.") if res is True else _t(f"[Hub] 保存失败：{res}", f"[Hub] Save failed: {res}"))

    def _copy_contact(self):
        self.clipboard_clear()
        self.clipboard_append("微信: HEIGUIYO · Telegram: @CARRYYOO")
        self._log(_t("[Hub] 已复制联系方式。", "[Hub] Copied contact."))

    _GENERATE_204 = "https://www.google.com/generate_204"

    def _probe_204(self, opener, timeout=8):
        try:
            with opener.open(self._GENERATE_204, timeout=timeout) as resp:
                return resp.status < 400
        except Exception:
            return False

    def _polite_net_error(self, detail):
        msg_zh = ("网络连接受限：未能直连海外资产库。如需检索海外素材，请在下方配置本地代理节点，"
                  "或联系技术支持微信：HEIGUIYO (Telegram: @CARRYYOO) 获取海外专线与节点协助。")
        msg_en = ("Network restricted: could not reach overseas assets directly. To fetch overseas material, "
                  "configure a local proxy below, or contact support WeChat HEIGUIYO (Telegram: @CARRYYOO).")
        self.diag_result_label.configure(text=_t(msg_zh, msg_en), text_color=WARN, wraplength=560)
        self._log(f"{detail} · {_t(msg_zh, msg_en)}")

    def _run_probe(self):
        self.hub_status.configure(text=_t("网络状态：检测中…", "Network: checking…"), text_color=MUTED)
        self.diag_result_label.configure(text=_t("正在检测连通性…", "Testing…"), text_color=MUTED)
        threading.Thread(target=self._do_probe, args=(self.proxy_host.get().strip(), self.proxy_port.get().strip(), self.proxy_type.get()), daemon=True).start()

    def _set_probe_result(self, color, status_text, detail):
        self.hub_status.configure(text=status_text, text_color=color)
        self.diag_result_label.configure(text=detail, text_color=color)
        self._log(_t(f"[Diag] {status_text}", f"[Diag] {status_text}"))

    def _do_probe(self, host, port, ptype):
        mode_key = self._mode_key(self.network_mode_var.get())
        def direct_ok():
            return self._probe_204(build_opener())

        def proxy_check():
            if not host or not port:
                self._polite_net_error("proxy empty")
                return False
            opener = build_opener(ProxyHandler({"http": f"http://{host}:{port}", "https": f"http://{host}:{port}"}))
            return self._probe_204(opener)

        if mode_key == "direct":
            if direct_ok():
                self._set_probe_result(GOOD, _t("网络状态：直连畅通 (海外环境)", "Network: direct OK (overseas)"), _t("直连畅通", "Direct up"))
            else:
                self._polite_net_error(_t("直连失败", "Direct failed"))
            return
        if mode_key == "proxy":
            self._set_probe_result(GOOD, _t("海外专线已连接 (代理)", "Line connected (proxy)"), _t("经代理连通", "Via proxy")) if proxy_check() else self._polite_net_error("proxy failed")
            return
        # auto
        if direct_ok():
            self._set_probe_result(GOOD, _t("网络状态：直连畅通 (海外环境)", "Network: direct OK (overseas)"), _t("自动：直连成功", "Auto: direct OK"))
        elif not host or not port:
            self._polite_net_error(_t("自动：直连失败", "Auto: direct failed"))
        else:
            self._set_probe_result(GOOD, _t("海外专线已连接 (代理)", "Line connected (proxy)"), _t("自动：经代理连通", "Auto: via proxy")) if proxy_check() else self._polite_net_error("auto proxy failed")

    def _freesound_search(self):
        token = self.fs_token.get().strip()
        if not token:
            self._log(_t("[Freesound] 请填入你的 API Token。", "[Freesound] Enter your API token."))
            return
        query = self.fs_query.get().strip()
        if not query:
            return
        self.cfg["freesound_token"] = token
        save_config(self.cfg)
        threading.Thread(target=self._fs_do, args=(token, query), daemon=True).start()

    def _fs_do(self, token, query):
        try:
            url = "https://freesound.org/apiv2/search/text/?" + urllib.parse.urlencode(
                {"query": query, "filter": "duration:[1 TO 20]", "fields": "id,name,username,duration,previews", "page_size": "8"})
            req = urllib.request.Request(url, headers={"Authorization": f"Token {token}"})
            with urllib.request.urlopen(req, timeout=20) as resp:
                data = json.loads(resp.read().decode("utf-8"))
            self.fs_results.delete("1.0", "end")
            for r in data.get("results", []):
                pv = (r.get("previews") or {}).get("preview-hq-mp3", "")
                self.fs_results.insert("end", f"#{r.get('id')}  {r.get('name')} ({r.get('username')}) {round(r.get('duration', 0))}s\n  https://freesound.org/s/{r.get('id')}/  {pv}\n\n")
            self._log(_t(f"[Freesound] 找到 {len(data.get('results', []))} 条。", f"[Freesound] {len(data.get('results', []))} results."))
        except Exception as e:
            self.fs_results.delete("1.0", "end")
            self.fs_results.insert("end", _t("搜索失败（网络/Token）。\n", "Search failed (network/token).\n"))
            self._polite_net_error(_t(f"Freesound 检索失败：{e}", f"Freesound search failed: {e}"))

    # ================================================================ TUTORIAL
    def _quickstart_zh(self):
        return (
            "CARRYYOO AI 短剧专业工作室 - 快速上手\n"
            "========================================\n"
            "\n"
            "## 第 1 步：获取 MiniMax API Key 与 Group ID\n"
            "本程序使用你自己的 MiniMax 账号。在 MiniMax 开放平台（官方控制台 platform.minimax.io，"
            "以官方文档为准）创建 API Key 与 Group ID。API 使用按你的账号计费，请自行充值少量额度；"
            "本程序不提供免费生成，也不代付算力。\n"
            "\n"
            "## 第 2 步：在「剧本智能分镜」（Tab 1）粘贴剧本\n"
            "将剧本粘贴到输入区，点击「解析分镜」自动切分为镜头；在「模型提示词预设」中选择"
            " MiniMax/Hailuo、Kling/可灵、Runway Gen-3 或 Midjourney v6，"
            "导出 CSV / TSV / TXT。完全本地离线，无网络调用。\n"
            "\n"
            "## 第 3 步：用 MiniMax 流水线渲染（Tab 3）\n"
            "在「MiniMax 视频流水线」填入 API Key 与 Group ID，可先「一次性测试」单镜头，"
            "或在 PRO 模式下批量渲染并自动下载到本地 output/ 目录。Lite 限单次测试。\n"
            "\n"
            "## 第 4 步：用批量视频处理交付（Tab 2）\n"
            "选择视频文件夹，选「9:16 竖屏模糊填充」和/或加「-14 LUFS 响度归一化」，"
            "点击「开始批量处理」即可输出适配抖音 / YouTube Shorts 的竖屏成片。"
            "Lite 限 3 段，PRO 无限。\n"
            "\n"
            "## Tab 4 - 代理 / 连通性 / PRO 授权\n"
            "· 网络模式：本地直连 (海外环境) / 自动检测 / 自定义代理 (国内环境)\n"
            "· HTTP 代理（Clash/v2rayN）：http://127.0.0.1:7890；TUN 模式留空\n"
            "· 点击「检测连通性」按需测试，仅在手动触发时运行。\n"
            "\n"
            "· 授权验证：RSA-2048 PKCS#1 v1.5 SHA-256。公钥嵌入程序仅用于校验，签发私钥由发行方安全保管。\n"
            "· Lite (免费)：本地工具、批量限 3 段、MiniMax 单次测试\n"
            "· PRO ¥128 终身：无限批量、CSV/Excel 导出、9:16 模糊填充、-14 LUFS、全量批量渲染、优先更新\n"
            "· Creator Bundle ¥198：PRO 全部 + 2 个月 CarryVPN（100G/月境外流量）\n"
            "· 海外：19.9 USDT TRC-20 至 TVGr7ffrZEfBVxxGAeo2oB6aV8qcCUjLHP · Telegram: @CARRYYOO\n"
            "· 国内：微信 HEIGUIYO · 备注：短剧PRO\n"
            "· 在程序 PRO 弹窗中可直接复制各联系方式。\n"
        )

    def _quickstart_en(self):
        return (
            "CARRYYOO AI Short-Drama Pro Studio - Quick Start\n"
            "================================================\n"
            "\n"
            "## Step 1: Get your MiniMax API Key and Group ID\n"
            "This app uses YOUR OWN MiniMax account. On the MiniMax Open Platform (official console at "
            "platform.minimax.io; follow MiniMax's official docs), create an API Key and Group ID. API usage "
            "is billed to YOUR account - top up a small amount. This app does not offer free generation nor "
            "pay for compute.\n"
            "\n"
            "## Step 2: Paste a script in Script to Shots (Tab 1)\n"
            "Paste your script and click Parse to split into cinematic shots. In Model preset choose "
            "MiniMax/Hailuo, Kling, Runway Gen-3, or Midjourney v6, then export CSV / TSV / TXT. "
            "Fully local, offline, no network.\n"
            "\n"
            "## Step 3: Render with MiniMax Pipeline (Tab 3)\n"
            "Enter your API Key and Group ID. Use One-shot test for a single shot, or batch-render in PRO mode "
            "to auto-download MP4s to the local output/ folder. Lite: 1-shot only.\n"
            "\n"
            "## Step 4: Deliver with Batch Video (Tab 2)\n"
            "Pick a video folder, choose 9:16 Vertical Blur Fill and/or add -14 LUFS loudness, "
            "click Start batch to produce vertical shorts. Lite caps at 3 clips; PRO unlimited.\n"
            "\n"
            "## Tab 4 - Proxy / Connectivity / PRO License\n"
            "· Network mode: Direct (overseas) / Auto detect / Custom proxy (domestic)\n"
            "· HTTP proxy (Clash/v2rayN): http://127.0.0.1:7890; leave blank for TUN mode\n"
            "· Click Test connectivity to check - runs only on demand.\n"
            "\n"
            "· License: RSA-2048 PKCS#1 v1.5 SHA-256 verification. Public key embedded; private key held securely by issuer.\n"
            "· Lite (Free): Local tools, batch capped at 3, MiniMax 1-shot\n"
            "· PRO ¥128 lifetime: Unlimited batch, CSV/Excel, 9:16 blur fill, -14 LUFS, full batch render, priority updates\n"
            "· Creator Bundle ¥198: All PRO + 2 months CarryVPN (100G/month overseas)\n"
            "· Overseas: 19.9 USDT TRC-20 to TVGr7ffrZEfBVxxGAeo2oB6aV8qcCUjLHP · Telegram: @CARRYYOO\n"
            "· Domestic: WeChat HEIGUIYO · note: 短剧PRO\n"
            "· Copy addresses directly from the app's PRO modal.\n"
        )

    def _quickstart_md(self):
        zh = self._quickstart_zh()
        en = self._quickstart_en()
        return (en + "\n\n===== 中文版本 =====\n\n" + zh) if LANG == "en" else (zh + "\n\n===== English Version =====\n\n" + en)

    def _quickstart_text(self):
        return self._quickstart_en() if LANG == "en" else self._quickstart_zh()

    def _tab_tutorial(self, parent):
        frame = ctk.CTkFrame(parent, fg_color="transparent")
        frame.columnconfigure(0, weight=1)
        frame.rowconfigure(0, weight=1)

        head = ctk.CTkFrame(frame, fg_color="transparent")
        head.grid(row=0, column=0, sticky="ew", pady=4)
        h = ctk.CTkLabel(head, text=_t("新手教程 · 快速上手", "Quick Start Tutorial"),
                         font=ctk.CTkFont(family=CN_FONT, size=20, weight="bold"), text_color=INK)
        h.pack(side="left")
        self._tr(h, "text", "新手教程 · 快速上手", "Quick Start Tutorial")
        b_save = ctk.CTkButton(head, text=_t("保存为 CARRYYOO_STUDIO_QUICKSTART.md", "Save as CARRYYOO_STUDIO_QUICKSTART.md"),
                               width=260, height=34, corner_radius=10, fg_color=BLUE, hover_color=BLUE_DIM, text_color="#FFFFFF",
                               font=ctk.CTkFont(family=CN_FONT, size=13), command=self._save_tutorial)
        b_save.pack(side="right")
        self._tr(b_save, "text", "保存为 CARRYYOO_STUDIO_QUICKSTART.md", "Save as CARRYYOO_STUDIO_QUICKSTART.md")

        self.tutorial_box = ctk.CTkTextbox(frame, font=ctk.CTkFont(family=CN_FONT, size=14), fg_color=PANEL,
                                           border_color=BORDER, border_width=1, corner_radius=14, wrap="word",
                                           text_color=MUTED)
        self.tutorial_box.grid(row=1, column=0, sticky="nsew")
        self._render_tutorial()
        return frame

    def _render_tutorial(self):
        try:
            self.tutorial_box.configure(state="normal")
            self.tutorial_box.delete("1.0", "end")
            self.tutorial_box.insert("1.0", self._quickstart_text())
            self.tutorial_box.configure(state="disabled")
        except Exception:
            pass

    def _save_tutorial(self):
        try:
            target = Path(__file__).resolve().parent / "CARRYYOO_STUDIO_QUICKSTART.md"
            target.write_text(self._quickstart_md(), encoding="utf-8")
            self._log(_t(f"[教程] 已保存：{target}", f"[Tutorial] Saved: {target}"))
            messagebox.showinfo(_t("已保存", "Saved"), _t(f"教程已保存到：\n{target}", f"Tutorial saved to:\n{target}"))
        except Exception as e:
            messagebox.showerror(_t("保存失败", "Save failed"), str(e))

    # ================================================================ PRO
    def _open_pro(self):
        win = ctk.CTkToplevel(self)
        win.title("PRO · " + _t("激活", "Activate"))
        win.geometry("720x700")
        win.configure(fg_color=BG)
        win.grab_set()

        t = ctk.CTkLabel(win, text=_t("CARRYYOO Studio PRO · RSA-2048 授权验证",
                                       "CARRYYOO Studio PRO · RSA-2048 License"),
                         font=ctk.CTkFont(family=CN_FONT, size=18, weight="bold"), text_color=PURPLE, anchor="w")
        t.pack(anchor="w", padx=24, pady=12)

        mc = _machine_code()
        c1 = ctk.CTkLabel(win, text=_t("机器码 (硬件ID)", "Machine code (hardware ID)"),
                          font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED, anchor="w")
        c1.pack(anchor="w", padx=24, pady=7)
        self._tr(c1, "text", "机器码 (硬件ID)", "Machine code (hardware ID)")
        mrow = ctk.CTkFrame(win, fg_color="transparent")
        mrow.pack(fill="x", padx=24)
        mval = ctk.CTkEntry(mrow, font=ctk.CTkFont(family=MONO_FONT, size=14), fg_color=RAISED, border_color=BORDER, corner_radius=10)
        mval.insert(0, mc)
        mval.configure(state="readonly")
        mval.pack(side="left", fill="x", expand=True)
        bc = ctk.CTkButton(mrow, text=_t("复制", "Copy"), width=70, height=32, corner_radius=10, fg_color=RAISED, hover_color=BORDER,
                           text_color=INK, border_width=1, border_color=BORDER, font=ctk.CTkFont(family=CN_FONT, size=13),
                           command=lambda: self._copy_text(mc, _t("[PRO] 机器码已复制。", "[PRO] Machine code copied.")))
        bc.pack(side="left", padx=6)
        self._tr(bc, "text", "复制", "Copy")

        l1 = ctk.CTkLabel(win, text=_t("Base64 签名许可证 (密钥)", "Base64 signed license key"),
                          font=ctk.CTkFont(family=CN_FONT, size=13), text_color=MUTED, anchor="w")
        l1.pack(anchor="w", padx=24, pady=7)
        self._tr(l1, "text", "Base64 签名许可证 (密钥)", "Base64 signed license key")
        self._lk = ctk.CTkEntry(win, font=ctk.CTkFont(family=MONO_FONT, size=12), fg_color=RAISED, border_color=BORDER, corner_radius=10,
                                placeholder_text=_t("粘贴 base64 签名许可证…", "Paste base64 signed license key…"))
        self._lk.pack(fill="x", padx=24)
        self._tr(self._lk, "placeholder", "粘贴 base64 签名许可证…", "Paste base64 signed license key…")

        bv = ctk.CTkButton(win, text=_t("激活 PRO (Activate PRO)", "激活 PRO (Activate PRO)"), width=190, height=38, corner_radius=10,
                           fg_color=BLUE, hover_color=BLUE_DIM, text_color="#FFFFFF", font=ctk.CTkFont(family=CN_FONT, size=14),
                           command=self._activate_pro)
        bv.pack(anchor="w", padx=24, pady=8)
        self._tr(bv, "text", "激活 PRO (Activate PRO)", "激活 PRO (Activate PRO)")

        # Fee / Lite / PRO matrix
        matrix = ctk.CTkFrame(win, fg_color=PANEL, corner_radius=12)
        matrix.pack(fill="x", padx=24, pady=(4, 8))
        mh = ctk.CTkLabel(matrix, text=_t("功能对比 Lite vs PRO", "Lite vs PRO features"),
                          font=ctk.CTkFont(family=CN_FONT, size=14, weight="bold"), text_color=INK, anchor="w")
        mh.pack(anchor="w", padx=12, pady=(8, 4))
        self._tr(mh, "text", "功能对比 Lite vs PRO", "Lite vs PRO features")
        fzh = ("• Lite: 本地功能（分镜预览、批量限 3 段、MiniMax 单次测试）\n"
               "• PRO ¥128 终身：无限批量、CSV/Excel 导出、9:16 模糊填充、-14 LUFS、MiniMax/Hailuo/Kling 全量批量渲染、优先更新。\n"
               "• Creator Bundle ¥198：PRO 全部 + 2 个月 CarryVPN 专线（100G/月境外流量）")
        fen = ("• Lite: Local tools (script preview, batch capped at 3, MiniMax 1-shot)\n"
               "• PRO ¥128 lifetime: Unlimited batch, CSV/Excel export, 9:16 blur fill, -14 LUFS, full MiniMax/Hailuo/Kling batch, priority updates.\n"
               "• Creator Bundle ¥198: All PRO + 2 months CarryVPN (100G/month overseas traffic)")
        fl = ctk.CTkLabel(matrix, text=_t(fzh, fen), font=ctk.CTkFont(family=CN_FONT, size=12), text_color=MUTED,
                          anchor="w", justify="left", wraplength=640)
        fl.pack(anchor="w", padx=12, pady=(0, 10))
        self._tr(fl, "text", fzh, fen)

        # Payment detail with copy buttons
        pay = ctk.CTkFrame(win, fg_color=PANEL, corner_radius=12)
        pay.pack(fill="x", padx=24, pady=4)
        pt = ctk.CTkLabel(pay, text=_t("付款方式", "Payment methods"),
                          font=ctk.CTkFont(family=CN_FONT, size=14, weight="bold"), text_color=INK, anchor="w")
        pt.pack(anchor="w", padx=12, pady=(8, 2))
        self._tr(pt, "text", "付款方式", "Payment methods")

        contacts = [
            _t("微信 (WeChat): HEIGUIYO", "WeChat: HEIGUIYO"),
            _t("Telegram: @CARRYYOO", "Telegram: @CARRYYOO"),
            _t("TRC-20: TVGr7ffrZEfBVxxGAeo2oB6aV8qcCUjLHP", "TRC-20: TVGr7ffrZEfBVxxGAeo2oB6aV8qcCUjLHP"),
        ]
        for label in contacts:
            row = ctk.CTkFrame(pay, fg_color="transparent")
            row.pack(fill="x", padx=12, pady=2)
            value = label.split(": ", 1)[1] if ": " in label else label
            entry = ctk.CTkEntry(row, font=ctk.CTkFont(family=MONO_FONT, size=12), fg_color=RAISED, border_color=BORDER,
                                 corner_radius=8, height=28)
            entry.insert(0, value)
            entry.configure(state="readonly")
            entry.pack(side="left", fill="x", expand=True)
            cb = ctk.CTkButton(row, text=_t("复制", "Copy"), width=60, height=26, corner_radius=8,
                               fg_color=RAISED, hover_color=BORDER, text_color=INK, border_width=1, border_color=BORDER,
                               font=ctk.CTkFont(family=CN_FONT, size=11),
                               command=lambda v=value: self._copy_text(v, f"[PRO] 已复制：{v}"))
            cb.pack(side="left", padx=6)

        honest = ctk.CTkLabel(win, text=_t(
            "安全说明：本程序使用 RSA-2048 PKCS#1 v1.5 SHA-256 验证，公钥仅用于校验。签发私钥由发行方安全保管，未嵌入此客户端中。"
            "请在获得官方签发的 base64 许可证密钥后将其粘贴到上方输入框并点击激活。激活后 PRO 状态持久保存至 ~/.carryyoo_studio_license.json。"
            "Lite 功能始终可用无限制；本地功能完全离线、不进行网络检测。",
            "Security: RSA-2048 PKCS#1 v1.5 SHA-256 verification; the public key is embedded for verification purposes only. "
            "The signing private key is kept by the issuer and is NOT embedded in this client. "
            "Paste the official base64 signed license above and click Activate. PRO status is persisted in ~/.carryyoo_studio_license.json. "
            "Lite features are always available; local tools are fully offline with no network checks."),
            font=ctk.CTkFont(family=CN_FONT, size=12), text_color=WARN, anchor="w", justify="left", wraplength=640)
        honest.pack(anchor="w", padx=24, pady=(8, 16))
        self._tr(honest, "text",
                 "安全说明：本程序使用 RSA-2048 PKCS#1 v1.5 SHA-256 验证，公钥仅用于校验。签发私钥由发行方安全保管，未嵌入此客户端中。"
                 "请在获得官方签发的 base64 许可证密钥后将其粘贴到上方输入框并点击激活。激活后 PRO 状态持久保存至 ~/.carryyoo_studio_license.json。"
                 "Lite 功能始终可用无限制；本地功能完全离线、不进行网络检测。",
                 "Security: RSA-2048 PKCS#1 v1.5 SHA-256 verification; the public key is embedded for verification purposes only. "
                 "The signing private key is kept by the issuer and is NOT embedded in this client. "
                 "Paste the official base64 signed license above and click Activate. PRO status is persisted in ~/.carryyoo_studio_license.json. "
                 "Lite features are always available; local tools are fully offline with no network checks.")

    def _copy_text(self, text, log_msg):
        self.clipboard_clear()
        self.clipboard_append(text)
        if log_msg:
            self._log(log_msg)

    def _load_activation(self):
        """Load activation JSON at startup; require matching HWID + valid RSA signature."""
        try:
            if not os.path.exists(LICENSE_FILE):
                return False
            with open(LICENSE_FILE, "r", encoding="utf-8") as f:
                data = json.load(f)
            hwid_saved = data.get("hardware_id", "")
            sig_saved = data.get("signature", "")
            if not hwid_saved or not sig_saved:
                return False
            if clean_hwid(hwid_saved) != clean_hwid(_machine_code()):
                return False
            return bool(verify_license(_machine_code(), sig_saved))
        except Exception:
            return False

    def _update_pro_status(self):
        try:
            if self.pro_active:
                self.pro_btn.configure(text=_t("PRO 已激活", "PRO Active"), text_color=GOOD)
            else:
                self.pro_btn.configure(text=_t("PRO · 激活", "PRO · Activate"), text_color=PURPLE)
        except Exception:
            pass

    def _activate_pro(self):
        sig = (self._lk.get() or "").strip()
        hwid = _machine_code()
        if verify_license(hwid, sig):
            try:
                with open(LICENSE_FILE, "w", encoding="utf-8") as f:
                    json.dump({"hardware_id": clean_hwid(hwid), "signature": sig}, f,
                              ensure_ascii=False, indent=2)
            except Exception as e:
                messagebox.showerror(_t("保存失败", "Save failed"), str(e))
                return
            self.pro_active = True
            self._update_pro_status()
            self._log(_t("[PRO] RSA-2048 签名验证通过 · PRO 已激活：完整批量、MiniMax 全量批量、CSV 导出已解锁。",
                         "[PRO] RSA-2048 signature verified · PRO activated: full batch, MiniMax full batch, CSV export unlocked."))
            messagebox.showinfo(_t("PRO 已激活", "PRO Active"),
                                _t("RSA-2048 签名验证通过，完整功能已解锁。激活状态已保存到 ~/.carryyoo_studio_license.json。",
                                   "RSA-2048 signature verified, full features unlocked. Activation saved to ~/.carryyoo_studio_license.json."))
        else:
            messagebox.showwarning(_t("激活码无效", "Invalid License Key"),
                                   _t("激活码无效，请核对或联系官方客服\n（需要正确的 RSA-2048 SHA-256 PKCS#1 v1.5 签名）",
                                      "Invalid license key\n(requires a valid RSA-2048 SHA-256 PKCS#1 v1.5 signature)"))

    def _verify_license(self):
        self._activate_pro()

    def _toggle_pro_features(self, unlocked):
        """Update sidebar label."""
        self._update_pro_status()
        if unlocked:
            self._log(_t("[PRO] RSA-2048 签名验证通过 · PRO 功能已解锁。", "[PRO] RSA-2048 verified · PRO features unlocked."))
        else:
            self._log(_t("[PRO] PRO 功能未激活。", "[PRO] PRO features not active."))


def main():
    try:
        app = CarryyooStudio()
        app.mainloop()
    except ImportError:
        sys.stderr.write("缺少 customtkinter。请先运行：\n    pip install customtkinter\n然后重新运行本脚本。\n")
        sys.exit(1)


if __name__ == "__main__":
    main()
