# -*- coding: utf-8 -*-
"""
AI 背景匹配（多通道候选版，2026-08-14；2026-08-15 重构为「AI 语义 + 程序数值」分工；
2026-08-16 晚 user 拍板：OpenAI 已充值 → **主通道恢复 OpenAI 直连 gpt-image-2**，
venice_gpt 降回兜底）。

通道优先级（2026-08-16 晚定案）：
  gpt（主） = OpenAI gpt-image-2 images/edits 直连，n=3 / quality=high / size=1824x1024。
    「高逼真照片模式」查证结论（官方模型页+Cookbook）：没有独立写实参数——
    `input_fidelity` 已移除（全输入自动最高保真），quality=high 即最高质感档，
    写实靠 prompt 摄影语言（见 PROMPT 上方注释）。质感上限比 venice 通道高一档：
    venice 网关 60s 硬超时逼得那边只能跑 quality=low。
  venice_gpt（兜底，OpenAI 429/欠费/网络失败时用）= venice `/image/multi-edit` +
    modelId=`gpt-image-2-edit` + 真·双图输入（images=[参考帧, 背景图]，不再需要拼接术），
    quality=low（60s 网关约束），n=3 靠**并发**3个请求实现
    （该端点不支持 n 参数，一次只出一张）。
    🔴 排查记录（user 质疑"这么重要的模型不可能一直用不了"，查证明他是对的）：
      ① `/image/edit`+`model=gpt-image-2` → `Invalid model id`（这个端点不认这个 id）；
      ② `/image/multi-edit`+`modelId=gpt-image-2-edit` 才是文档承认的正确端点/id，
         此前项目一直没试过它；
      ③ 默认 `quality=high` 100% 在 60.1s 被 venice 网关断开连接（RemoteDisconnected），
         与 `venice-image` 技能旧笔记"60s timeout 100%失败"完全吻合——不是模型不可用，
         是高质量档太慢撞了网关硬超时。降档后单发都在线内成功（medium 46.6s / low 36.8s），
         真实素材验证无人像混入（`results/dark_diag/venice_gpt2_cmp.jpg`）。
         🔴🔴 但 **n=3 并发下不能想当然沿用单发的安全边际**：并发抢 GPU 算力，medium 档
         3 并发实测两轮分别是 51.9s（3/3 险过）和 60.2s+（1/3 超时失败）——距 60s 线只剩
         个位数秒的余量，等于在赌网络抖动。改用 `quality=low` 后 3 并发×3 独立轮次
         （不同背景）**9/9 全部成功，31~36s**，余量拉回约 25s，才是真正稳的默认档。
         视觉质量在 low/medium 之间差异很小（图证 `results/dark_diag/venice_gpt2_cmp.jpg`），
         n=3 场景默认 `quality="low"`；今后如果只出 1 张（不并发），可以考虑传 medium。
      ④ 响应是**二进制图片**（不是 gpt-image-1 那种 JSON+b64_json），解析方式不同。
  🔴 gpt 通道探参记录（实测 + 官方文档双确认）：gpt-image-2 **没有** style/realism/
     input_fidelity 等"写实开关"参数（实测全部 400 Unknown parameter；官方明确
     input_fidelity 已移除、全输入自动 high）。写实=quality=high + prompt 摄影语言；
     size 支持任意 WxH（错误提示示例 1824x1024，≈16:9=模型原生输出，不再上采样）。
  venice = venice /image/edit 缺省模型 + 拼图法（旧兜底，已被 venice_gpt 取代，不再接入
    PROVIDERS 分发；函数体保留只是给 CANVAS_PROMPT 留一个调用方，别再复用这条路）。

🔴 2026-08-15 分工重构（user 拍板"prompt 优化完 + 程序调剩余部分"）：
  · AI（prompt）只管**生成阶段才能定的语义层**：内容、光源方向逻辑、整体曝光档位、
    环境光颜色氛围。曝光/光向不再让模型"自己看图估"，而是把**程序实测的数字**
    （room_l 0-100、estimate_light_dir 的方向）直接写进 prompt——§5 铁律早证明
    纯 prompt 指定光照不可靠，必须给可执行的量化锚点 + 事后校验。
  · 程序（finish 链）只管**锐度**这一项确定性可后处理的数值层（textured_sharpness
    纹理块 P80 口径二分解模糊，§9.3 第3条），默认关闭、user 按需开（bgh_sharpen tune）。
    🔴 白平衡/色调统计迁移已于 2026-08-15 撤掉：user 指出候选制作阶段做 color_transfer
    仍是"统计拉齐"判据，和被否决的 _auto_expose 同一方案家族换皮，不该留。
  · 🔴 因此 prompt 里**删掉**了旧版的 soft-focus / mild noise / grain /
    compression-softness 要求，反过来要求 AI 出**最锐最干净**的图——解模糊是
    单向变换（只能变糊不能变锐），AI 出图偏锐是好事、偏糊则程序追不回来
    （quality.py 的 warn 正是这种情况）。噪声/颗粒完全不做：§11.11 实测服务端
    注入的噪声会被下行 H264 量化掉，颗粒已由浏览器端 grainLayer 负责。
  · 闭环校验（§5）：每张候选 finish 后实测 bg_l 写入 bgh_meta.json；调用方
    （pipeline setter）据此排序推荐与决定是否带测量反馈重试。不自动换背景——
    候选仍由 user 眼睛终审。
"""
import base64
import json
import os
import time
import urllib.request
import uuid

import cv2
import numpy as np

from validated_impl import textured_sharpness

# 🔴 2026-08-16 晚 prompt 升级（user：OpenAI 已充值，恢复直连主通道 + 高逼真照片模式）：
#    查证官方口径（developers.openai.com 模型页 + Cookbook prompting guide）——
#    gpt-image-2 **没有独立的"写实模式"参数**：
#      · `input_fidelity` 参数已被移除，所有输入图自动按最高保真处理（不要再传，传了 400）；
#      · `quality`（low/medium/high）是唯一质量开关，high = 最大细节/材质保真档；
#      · 官方 Cookbook 明确：写实靠 prompt——直接写 "photorealistic"、用摄影语言
#        （镜头/光线/取景）、点名真实世界纹理瑕疵（磨损、灰尘、不平整）。
#    所以"高逼真照片模式" = quality=high + 下面这份按官方指南重写的 prompt。
#    结构沿用 v4 骨架（person 只在开头交代合成意图、结尾 no people——0486e95 的教训：
#    prompt 里反复念叨 person 会诱导编辑模型往空场景里画人，这次 person 仍只出现 2 次），
#    质感段改成**逐条枚举摄像头捕获特征**让模型照单复制 + 反 CGI/HDR 负面清单。
#    🔴 2026-08-17 user 拍板删掉"keep real-world imperfections（不平的墙/磨损/灰尘）"：
#    那是在指挥**场景内容**——环境设定完全由背景图提供，prompt 只管摄像头**画面质感**层，
#    别让模型往场景里添加原图没有的物理瑕疵。分界线：质感词（颗粒/曝光/白平衡/镜头）可以，
#    内容词（墙面状况/杂物/磨损）不行。

PROMPT = (
    "IMAGE 1 is a webcam frame of a person in a room. IMAGE 2 is a stock background photo.\n"
    "The person will be cut out of IMAGE 1 and composited over IMAGE 2, so the final composite must "
    "look like ONE continuous photograph - one camera, one room, one moment.\n"
    "Your job: re-render IMAGE 2 as a PHOTOREALISTIC photo captured by exactly the same webcam as "
    "IMAGE 1, under the same ambient light, at the same instant. First study IMAGE 1's physical "
    "capture signature, then copy every part of it onto IMAGE 2:\n"
    "- the same white balance and ambient light color, including how that light spills and bounces "
    "across walls and surfaces;\n"
    "- the same overall exposure and brightness level;{BRIGHT}\n"
    "{LIGHT}"
    "- the same limited consumer-webcam dynamic range: low contrast, slightly lifted soft blacks, "
    "gently rolled-off highlights;\n"
    "- the same fine sensor grain and luminance noise, strongest in shadows and flat areas;\n"
    "- the same lens character: mild optical softness, background sitting slightly out of focus, "
    "a subtle vignette toward the corners;\n"
    "- the same slight video-compression softness and muted, natural color saturation.\n"
    "No AI-art smoothing, no illustration or CGI look, no oversaturation, no HDR tone-mapping.\n"
    "Keep IMAGE 2's content and composition unchanged; no people, no text. Output only IMAGE 2.")

CANVAS_PROMPT = (
    "This single canvas contains two photos side by side. LEFT: a raw webcam frame of a person, "
    "showing the room's real lighting and the webcam's rendering. RIGHT: a stock background photo.\n"
    "The person from the left photo will be composited onto the right photo. Repaint ONLY the right "
    "photo so the composite will look like ONE photograph taken by the left webcam: same ambient "
    "light color, same exposure and brightness as the person's lighting, same low-contrast soft "
    "webcam rendering, background slightly out of focus, subtle noise.\n"
    "Keep the side-by-side layout and the left photo exactly unchanged; keep the right photo's "
    "content and composition; no people or text in the right photo.")


def build_prompt(ctx=None):
    """基底 prompt 静态，只注入**两个程序实测的数值锚点**（§11.13c 的「AI 语义 + 程序数值」
    分工）：① 房间实测亮度 room_l；② 闭环重试时的测量反馈。

    🔴 2026-08-17 恢复亮度锚点的原因（user 真机实测暴露）：房间实测 97/100 而三张候选只有
       68.6~71.2，差 26~28 —— 人在很亮的房间里拍，却叠在明显更暗的背景上，这才是 user 说的
       「颜色对比度不一样」的主要来源（比编解码链路那 3.7 L 大一个数量级）。原因是回滚 v4
       prompt 时把数值锚点一起删了，第一轮全靠模型看图盲猜曝光、只有闭环重试才给数字——
       等于每次都要先浪费一轮 2 分钟。§5 铁律早写过：光照/曝光靠纯文字描述不可靠，
       必须给可执行的量化锚点 + 事后校验。
    🔴 措辞硬约束：锚点句里**不能出现 person/face**（0486e95 的教训：prompt 里 person 词频
       从 2 推到 11 会诱导编辑模型往空场景里画人）。这里只说 "the room in IMAGE 1"，
       person 词频保持 2 次不变（有断言脚本可复核）。"""
    ctx = ctx or {}
    rl = ctx.get("room_l")
    bright = ""
    if rl:
        n = round(float(rl))
        # 🔴 2026-08-17 改成**对称**锚点（user 实测暗房间出了亮背景才暴露）：旧文案只写
        #    "not darker"——那是给「AI 常偏暗」的历史场景写的单边约束，房间很暗时模型的
        #    "明亮室内"先验完全不受约束。暗场景（<45）还要显式点破先验：库存照片默认是
        #    亮的，必须整体重打光成真正的昏暗房间。
        bright = (" MEASURED TARGET: the scene in IMAGE 1 measures %d out of 100 in brightness."
                  " IMAGE 2 must land at that SAME measured level - neither brighter nor darker." % n)
        if n < 45:
            bright += (" That target is DIM: re-light the whole scene as a genuinely dark room"
                       " (most lights off, deep soft shadows) - NOT a bright daylight interior,"
                       " even though the stock photo looks bright.")
    # 🔴 光照方向段（回滚 v4 时被整个删掉，user 实测"方向也没匹配"）。措辞只讲场景与
    #    frame 的左右，不出现 person（0486e95 词频教训）。front/未知 → 不加（别瞎约束）。
    side = ((ctx.get("light") or {}).get("cls") or "").lower()
    light = ""
    if side in ("left", "right"):
        light = ("- KEY LIGHT DIRECTION: in IMAGE 1 the dominant light enters from the %s side"
                 " of the frame. Relight IMAGE 2 to match: surfaces facing the %s are lit,"
                 " brightness falls off toward the %s, shadows stretch toward the %s -"
                 " one consistent directional source across the whole scene;\n"
                 % (side.upper(), side, "right" if side == "left" else "left",
                    "right" if side == "left" else "left"))
    elif side == "top":
        light = ("- KEY LIGHT DIRECTION: in IMAGE 1 the dominant light comes from ABOVE."
                 " Relight IMAGE 2 to match: upward-facing surfaces lit, shadows pooling"
                 " below objects, one consistent overhead source;\n")
    p = PROMPT.replace("{BRIGHT}", bright).replace("{LIGHT}", light)
    if ctx.get("feedback"):
        p += "\n" + str(ctx["feedback"])
    return p


def _jpg(im, q=92):
    return cv2.imencode(".jpg", im, [cv2.IMWRITE_JPEG_QUALITY, q])[1].tobytes()


def luma100(im, mask=None):
    """0-100 亮度（与 pipeline _levels 同口径：Rec.709 加权；im 为 BGR）。"""
    w = np.float32([0.0722, 0.7152, 0.2126])
    g = (im.astype(np.float32) * w).sum(2)
    if mask is not None and mask.any():
        g = g[mask]
    return float(g.mean()) / 255.0 * 100.0


def finish(cand, ref, bg_mask=None, log=print, tag="", sharpen=False):
    """AI 候选的确定性后处理（生成后、写盘前）：目前只做锐度对齐，且默认关闭。

    🔴 2026-08-15 撤掉白平衡/色调迁移（color_transfer）：user 指出这是把上一轮已经
       否决的"统计式调色"（自动曝光那族）换皮塞回来了，即便发生位置从"每帧实时"
       挪到"候选制作一次性"，判据本质仍是统计拉齐、没有物理依据——属于同一被否决
       方案家族，不该再用。曝光/白平衡现在完全交给 prompt（build_prompt 把实测
       room_l/光方向数字写进去），程序不再在这一层插手颜色。
    锐度对齐（sharpen=True 才做，默认关，user 自己按需开）：目标=参考帧整帧
    textured P80（§9.3 第3条：基准用整帧不用背景区），二分解模糊到目标；候选本来
    就比参考糊则 σ=0 不动+告警（解模糊救不了）。这是单向确定性变换、无统计拉齐，
    user 判定"背景比人锐"时可保留。
    返回 (处理后图, 指标 dict)。任何一步失败退回原图（不因后处理丢掉整个候选）。
    """
    m = {}
    try:
        t0 = time.time()
        m["L_ai"] = round(luma100(cand), 1)
        out = cand
        if not sharpen:
            m["sharp_ref"] = m["sharp_ai"] = m["sharp_out"] = m["blur_sigma"] = None
            m["finish_s"] = round(time.time() - t0, 3)
            m["bg_l"] = m["L_ai"]
            return out, m

        target = textured_sharpness(ref)
        s0 = textured_sharpness(out)
        m["sharp_ref"], m["sharp_ai"] = round(target, 2), round(s0, 2)
        if s0 <= target:
            m["blur_sigma"] = 0.0
            if s0 < target * 0.6:
                log("[bgh]%s ⚠️ 候选比摄像头还糊(%.1f<%.1f)，解模糊救不了——挑图时留意"
                    % (tag, s0, target))
        else:
            lo, hi = 0.0, 8.0
            for _ in range(14):
                mid = (lo + hi) / 2
                s = textured_sharpness(cv2.GaussianBlur(out, (0, 0), mid) if mid > 0.05 else out)
                if s > target:
                    lo = mid
                else:
                    hi = mid
            sig = (lo + hi) / 2
            if sig > 0.05:
                out = cv2.GaussianBlur(out, (0, 0), sig)
            m["blur_sigma"] = round(float(sig), 3)
        m["sharp_out"] = round(textured_sharpness(out), 2)
        m["finish_s"] = round(time.time() - t0, 1)
        m["bg_l"] = round(luma100(out), 1)
        return out, m
    except Exception as e:
        log("[bgh]%s ⚠️ finish 失败退回原图: %s %s" % (tag, type(e).__name__, str(e)[:120]))
        m["bg_l"] = round(luma100(cand), 1)
        return cand, m


META = "bgh_meta.json"


def _meta_update(assets_dir, entries, log=print):
    """bgh_meta.json：{候选文件名: 指标}。单写者（setter 保证一轮一线程），读改写。"""
    p = os.path.join(assets_dir, META)
    try:
        cur = json.load(open(p, encoding="utf-8")) if os.path.exists(p) else {}
    except Exception:
        cur = {}
    cur.update(entries)
    try:
        with open(p, "w", encoding="utf-8") as f:
            json.dump(cur, f, ensure_ascii=False)
    except Exception as e:
        log("[bgh] ⚠️ meta 写入失败:", type(e).__name__, e)
    return cur


def _b64uri(im, maxw=1600):
    h, w = im.shape[:2]
    if w > maxw:
        im = cv2.resize(im, (maxw, int(h * maxw / w)), interpolation=cv2.INTER_AREA)
    return "data:image/jpeg;base64," + base64.b64encode(_jpg(im, 90)).decode()


def _structure_score(bg0, bg1):
    """内容保持分（模糊后灰度相关）。仅记录告警，不做硬门——候选面板由 user 眼睛终审，
    gpt-image-2 属重绘型（书本细节会变），硬门会错杀 user 可能喜欢的候选。"""
    def sig(im):
        g = cv2.cvtColor(cv2.resize(im, (320, 180), interpolation=cv2.INTER_AREA),
                         cv2.COLOR_BGR2GRAY).astype(np.float32)
        return cv2.GaussianBlur(g, (0, 0), 4)
    return float(np.corrcoef(sig(bg0).ravel(), sig(bg1).ravel())[0, 1])


def _gpt_one(ref, bg, key, prompt, quality="high", size="1824x1024"):
    """OpenAI gpt-image-2 images/edits 直连，出 1 张。🔴 必须走**流式**（stream=true）：
    非流式请求要等全部渲染完才回首字节，高质量档单张实测 111s——而「60s 无响应字节就
    被中间设备掐断（RemoteDisconnected）」这坑已经踩过两次（venice 网关 60.1s、
    2026-08-16 本机出口 60s 整）。流式响应头立即返回 + partial_images 中途持续来字节，
    两类超时（等首包/空闲）全部失效。实测：非流式 high 100% 死在 60s，流式 111s 稳过。
    🔴 不带 input_fidelity/style/realism：官方已移除 input_fidelity（全输入自动最高保真），
    其余实测 400 Unknown parameter。quality 是唯一质量开关，high=写实主开关。"""
    bnd = uuid.uuid4().hex
    parts = []

    def field(k, v):
        parts.append(("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n"
                      % (bnd, k, v)).encode())

    def filef(k, name, im):
        parts.append(("--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n"
                      "Content-Type: image/jpeg\r\n\r\n" % (bnd, k, name)).encode()
                     + _jpg(im) + b"\r\n")
    field("model", "gpt-image-2")
    field("prompt", prompt)
    field("size", size)               # 模型原生 ≈16:9（API 错误提示的示例值），不再上采样
    field("quality", quality)
    field("n", "1")                   # 多张靠并发（见 _gpt），流式下单请求只出一张最稳
    field("stream", "true")
    field("partial_images", "2")      # 中途吐 2 张半成品字节流＝天然 keep-alive
    filef("image[]", "ref.jpg", ref)
    filef("image[]", "bg.jpg", bg)
    parts.append(("--%s--\r\n" % bnd).encode())
    req = urllib.request.Request("https://api.openai.com/v1/images/edits", data=b"".join(parts),
                                 headers={"Authorization": "Bearer " + key,
                                          "Content-Type": "multipart/form-data; boundary=" + bnd})
    resp = urllib.request.urlopen(req, timeout=600)
    buf, final, partial = b"", None, None
    for chunk in iter(lambda: resp.read(8192), b""):
        buf += chunk
        while b"\n\n" in buf:                       # SSE：事件以空行分隔
            ev, buf = buf.split(b"\n\n", 1)
            for line in ev.split(b"\n"):
                if not line.startswith(b"data: "):
                    continue
                try:
                    d = json.loads(line[6:])
                except Exception:
                    continue
                t = d.get("type", "")
                if t.endswith("partial_image"):
                    partial = d.get("b64_json") or partial
                elif d.get("b64_json"):             # image_edit.completed
                    final = d["b64_json"]
    b64 = final or partial                          # 完成事件丢了就用最后一张半成品兜底
    if not b64:
        raise RuntimeError("gpt-image-2 流式响应里没有图")
    im = cv2.imdecode(np.frombuffer(base64.b64decode(b64), np.uint8), cv2.IMREAD_COLOR)
    if im is None:
        raise RuntimeError("gpt-image-2 结果解码失败")
    return im


def _gpt(ref, bg, key, prompt, n=3):
    """出 n 张候选：n 个并发流式请求（与 _venice_gpt 同模式）。相比旧的单请求 n=3：
    ① 墙钟 ≈ 单张时间（~2min）而不是排队 3 张；② 单张失败不拖累其它；③ 每路都有
    自己的流式 keep-alive。全败才向上抛第一个异常给 harmonize 打日志。"""
    import concurrent.futures as _fut
    results, errs = [None] * n, [None] * n

    def one(i):
        try:
            results[i] = _gpt_one(ref, bg, key, prompt)
        except Exception as e:
            errs[i] = e
    with _fut.ThreadPoolExecutor(max_workers=n) as ex:
        list(ex.map(one, range(n)))
    out = [im for im in results if im is not None]
    if not out:
        raise (errs[0] if errs[0] else RuntimeError("gpt 全部失败"))
    return out                          # list！


def _venice(ref, bg, key, prompt):
    """⚠️ 旧的单图拼接兜底术，已废弃——`_venice_gpt` 用真正的多图输入替代了它。
    留着只是给 CANVAS_PROMPT 保一个调用方，不再接入 PROVIDERS。"""
    H = 720
    r = cv2.resize(ref, (int(ref.shape[1] * H / ref.shape[0]), H))
    b = cv2.resize(bg, (int(bg.shape[1] * H / bg.shape[0]), H))
    canvas = np.hstack([r, b])
    body = {"prompt": CANVAS_PROMPT, "image": base64.b64encode(_jpg(canvas, 90)).decode()}
    req = urllib.request.Request("https://api.venice.ai/api/v1/image/edit",
                                 data=json.dumps(body).encode(),
                                 headers={"Authorization": "Bearer " + key,
                                          "Content-Type": "application/json"})
    raw = urllib.request.urlopen(req, timeout=300).read()
    try:
        j = json.loads(raw)
        raw = base64.b64decode(j["images"][0] if isinstance(j.get("images"), list) else j["image"])
    except Exception:
        pass                                    # 直接就是图片字节
    im = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
    if im is None:
        raise RuntimeError("venice 结果解码失败")
    cut = int(im.shape[1] * r.shape[1] / canvas.shape[1])
    return im[:, cut:]


def _venice_gpt_one(ref, bg, key, prompt, quality="low"):
    """venice 代理的 gpt-image-2（真正的多图输入，走 `/image/multi-edit`）。出 1 张。

    🔴 2026-08-16 排查记录（user 质疑"这么重要的模型不可能一直用不了"，查证明他是对的）：
       ① `/image/edit` + `model=gpt-image-2` → `Invalid model id`：这个端点根本不认这个 id。
       ② `/image/multi-edit` + `modelId=gpt-image-2-edit` + `images=[a,b]` 数组，
          文档 https://docs.venice.ai/api-reference/endpoint/image/multi-edit 确认存在、
          支持最多两张（模型限）图输入——这才是正确路径，项目此前一直没试过这个端点。
       ③ 🔴 默认 `quality=high` 在 5090 本地实测 **100% 在 60.1s 被网关断开连接**
          （`RemoteDisconnected`），与本项目 `venice-image` 技能旧笔记记录的
          "gpt-image-2-edit 60s timeout 100%失败" 完全吻合——但那不是模型不可用，
          是**高质量档太慢撞了 venice 网关的硬超时**。降到 `quality=low`（36.8s）/
          `medium`（46.6s）后 100% 在超时线内成功，真实素材验证过、背景里没有出现人像
          （图证 `results/dark_diag/venice_gpt2_cmp.jpg`）。默认取 `medium`——
          与 low 视觉差异很小，但书脊纹理更清晰；47s 距 60s 网关线仍有 13s 安全边际。
       ④ 响应是**二进制图片**（`image/png`），不是 JSON——不能像 gpt-image-1 那样按
          `{"data":[{"b64_json":...}]}` 解析。
    返回单张 BGR ndarray；失败抛异常（由调用方 `harmonize` 统一 catch）。
    """
    body = {"modelId": "gpt-image-2-edit", "images": [
        base64.b64encode(_jpg(ref)).decode(), base64.b64encode(_jpg(bg)).decode()],
        "prompt": prompt, "quality": quality}
    req = urllib.request.Request("https://api.venice.ai/api/v1/image/multi-edit",
                                 data=json.dumps(body).encode(),
                                 headers={"Authorization": "Bearer " + key,
                                          "Content-Type": "application/json"})
    raw = urllib.request.urlopen(req, timeout=90).read()
    im = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
    if im is None:
        raise RuntimeError("venice(gpt-image-2-edit) 结果解码失败，%d 字节" % len(raw))
    return im


def _venice_gpt(ref, bg, key, prompt, n=3):
    """出 n 张候选。multi-edit 端点不支持 `n` 参数（一次一张），所以并发发 n 个请求——
    都是网络 I/O 等待（36-47s 起，算力在 venice 那端），线程并发没有本地计算争抢，
    n=3 的墙钟时间约等于单次请求时间，不是 3 倍。单张失败不拖累其它——收集异常，
    全败才向上抛，让 `harmonize` 走它自己的失败分支。"""
    import concurrent.futures as _fut
    results, errs = [None] * n, [None] * n

    def one(i):
        try:
            results[i] = _venice_gpt_one(ref, bg, key, prompt)
        except Exception as e:
            errs[i] = e
    with _fut.ThreadPoolExecutor(max_workers=n) as ex:
        list(ex.map(one, range(n)))
    out = [im for im in results if im is not None]
    if not out:
        raise (errs[0] if errs[0] else RuntimeError("venice_gpt 全部失败"))
    return out


PROVIDERS = {"gpt": _gpt, "venice": _venice, "venice_gpt": _venice_gpt}


def harmonize(ref_bgr, bg_path, key, out_paths, provider="gpt", log=print, ctx=None):
    """out_paths=输出槽位列表。返回成功写出的张数（0=失败）。
    gpt 一次产 n 张按槽位顺序写；venice 只产 1 张写第一个槽。
    ctx（可选）：{"room_l": 0-100, "light": estimate_light_dir 结果, "bg_mask": 参考帧
    背景区布尔掩膜, "feedback": 重试时的测量反馈句}。每张候选先过 finish 链再写盘，
    指标进 bgh_meta.json（闭环校验与排序的数据源）。"""
    if isinstance(out_paths, str):
        out_paths = [out_paths]
    ctx = ctx or {}
    try:
        bg0 = cv2.imdecode(np.fromfile(bg_path, np.uint8), cv2.IMREAD_COLOR)
        if bg0 is None or ref_bgr is None:
            log("[bgh]", provider, "输入缺失"); return 0
        prompt = build_prompt(ctx)
        t0 = time.time()
        ims = PROVIDERS[provider](ref_bgr, bg0, key, prompt)
        if ims is None:
            ims = []
        if not isinstance(ims, list):
            ims = [ims]
        n = 0
        metas = {}
        for im, out_path in zip(ims, out_paths):
            im = cv2.resize(im, (bg0.shape[1], bg0.shape[0]), interpolation=cv2.INTER_AREA)
            name = os.path.basename(out_path)
            im, met = finish(im, ref_bgr, ctx.get("bg_mask"), log=log, tag=" " + name,
                            sharpen=bool(ctx.get("sharpen")))
            sc = _structure_score(bg0, im)
            met["structure"] = round(sc, 2)
            if ctx.get("room_l"):
                met["room_l"] = round(float(ctx["room_l"]), 1)
            cv2.imencode(".jpg", im, [cv2.IMWRITE_JPEG_QUALITY, 92])[1].tofile(out_path)
            metas[name] = met
            log("[bgh] ✅ %s %.0fs %s  L:%s(房间%s) 锐:%s→%s(σ%s,%s) 结构%.2f%s" %
                (provider, time.time() - t0, name,
                 met.get("L_ai"), met.get("room_l", "?"),
                 met.get("sharp_ai"), met.get("sharp_out"), met.get("blur_sigma"),
                 "已对齐" if ctx.get("sharpen") else "关闭",
                 sc, " (内容改动较大,自行斟酌)" if sc < 0.70 else ""))
            n += 1
        if metas:
            _meta_update(os.path.dirname(out_paths[0]), metas, log)
        if n == 0:
            log("[bgh]", provider, "无结果")
        return n
    except Exception as e:
        try:
            import urllib.error
            if isinstance(e, urllib.error.HTTPError):
                log("[bgh] ❌", provider, e.code, e.read().decode('utf-8', 'ignore')[:160])
                return 0
        except Exception:
            pass
        log("[bgh] ❌", provider, type(e).__name__, str(e)[:160])
        return 0
