diff --git a/core/backend/.env b/core/backend/.env index 5f4bf38..9a786cf 100644 --- a/core/backend/.env +++ b/core/backend/.env @@ -26,6 +26,9 @@ VIDEO_ARK_API_KEY=9225161a-a640-47e6-94ed-81f6c5610072 DEFAULT_TRIAL_CREDITS=1000.0000 YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf YUNQI_BASE_URL=https://www.yunqiai.chat/v1 +# YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度) +YUNQI_GPT_API_KEY=sk-WX9ClkkjJmquaz4CsmtiUTNQ7fYgDmngNasDUUjbK504uFEd +YUNQI_GEMINI_API_KEY=sk-WNmbNpwHiln5PUfSFgepXKdfoIe3L59rSpaYffp1hHqZ0s5N # tokenssr 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型 TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0 diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 34e3992..4a487a2 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -206,11 +206,17 @@ TOKENSSR = { PROVIDER_BASE_URLS = { "volcengine": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"), "yunqi": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"), + # YunQi 对话(脚本助手):gpt-5.5 与 gemini-3.1-pro-preview 在 YunQi 同一地址、各有独立计费 key, + # 故拆成两个 provider(一 provider 一 key 是现有约定)。base_url 都指 YunQi。 + "yunqi_gpt": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"), + "yunqi_gemini": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"), "tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"), } PROVIDER_KEYS = { "volcengine": env("VOLCANO_ARK_API_KEY", ""), "yunqi": env("YUNQI_API_KEY", ""), + "yunqi_gpt": env("YUNQI_GPT_API_KEY", ""), + "yunqi_gemini": env("YUNQI_GEMINI_API_KEY", ""), "tokenssr": env("TOKENSSR_API_KEY", ""), } # 临时:视频(Seedance)单独借 AirDrama 火山账号的 ARK key。真人素材库审核(ASSETS_API)建在 AirDrama 账号下, diff --git a/core/backend/apps/ai/migrations/0017_switch_script_to_yunqi.py b/core/backend/apps/ai/migrations/0017_switch_script_to_yunqi.py new file mode 100644 index 0000000..9961f83 --- /dev/null +++ b/core/backend/apps/ai/migrations/0017_switch_script_to_yunqi.py @@ -0,0 +1,87 @@ +"""脚本助手文本模型从 tokenssr 切到 YunQi(tokenssr 欠费致 GPT-5.5/Gemini 全挂)。 + +YunQi 同一地址(https://www.yunqiai.chat/v1)对 gpt / gemini 分开计费、各一把 key,故按 +「一 provider 一 key」拆成两个 provider: + - yunqi_gpt → gpt-5.5(默认脚本模型) · key=.env YUNQI_GPT_API_KEY + - yunqi_gemini → gemini-3.1-pro-preview · key=.env YUNQI_GEMINI_API_KEY +凭证留空 → 运行时由 settings.PROVIDER_KEYS 从 .env 解析(密钥不进库)。 +模型名经实测确认:gpt-5.5 / gemini-3.1-pro-preview 在 YunQi 返回 200 且能流式出正文; +其余名(gpt-5 / gemini-3-pro-preview …)YunQi 报 model_not_found。 + +同时停用旧的 tokenssr 文本模型(gpt-5.5 / gemini-3-pro-preview),让 get_default_model(text) +选到新的 YunQi gpt-5.5。幂等:全部 update_or_create / 显式置位,可重复 apply。 +""" +from django.db import migrations + +YUNQI_BASE_URL = "https://www.yunqiai.chat/v1" + +# (provider_name, display, model_name, model_display, is_default) +YUNQI_TEXT = [ + ("yunqi_gpt", "YunQi · GPT", "gpt-5.5", "GPT-5.5", True), + ("yunqi_gemini", "YunQi · Gemini", "gemini-3.1-pro-preview", "Gemini 3.1 Pro", False), +] + + +def seed(apps, schema_editor): + ModelProvider = apps.get_model("ai", "ModelProvider") + ModelConfig = apps.get_model("ai", "ModelConfig") + + for prov_name, prov_display, model_name, model_display, is_default in YUNQI_TEXT: + provider, _ = ModelProvider.objects.get_or_create( + name=prov_name, + defaults={ + "display_name": prov_display, + "status": "active", + "base_url": YUNQI_BASE_URL, + "api_key": "", # 运行时从 .env(PROVIDER_KEYS)解析,密钥不进库 + "metadata": {"kind": "relay", "gateway": "yunqi"}, + }, + ) + # down→up 循环后 get_or_create 命中旧行(可能 disabled / 缺 base_url),显式补齐 + changed = [] + if provider.status != "active": + provider.status = "active"; changed.append("status") + if not provider.base_url: + provider.base_url = YUNQI_BASE_URL; changed.append("base_url") + if changed: + provider.save(update_fields=changed) + + ModelConfig.objects.update_or_create( + provider=provider, + name=model_name, + capability="text", + defaults={ + "display_name": model_display, + "endpoint": "chat/completions", + "unit_price": "1.0000", + "status": "active", + "is_default": is_default, + "metadata": {"gateway": "yunqi", "verified": True}, + }, + ) + + # 停用旧的 tokenssr 文本模型(欠费),并清掉它们可能带的 is_default,避免与新默认打架 + ModelConfig.objects.filter( + provider__name="tokenssr", capability="text", + ).update(status="disabled", is_default=False) + + +def unseed(apps, schema_editor): + ModelConfig = apps.get_model("ai", "ModelConfig") + # 回滚:停用 YunQi 文本模型,恢复 tokenssr 文本模型为 active(gpt-5.5 复位默认) + ModelConfig.objects.filter(provider__name__in=["yunqi_gpt", "yunqi_gemini"], capability="text").update( + status="disabled", is_default=False, + ) + ModelConfig.objects.filter(provider__name="tokenssr", capability="text").update(status="active") + ModelConfig.objects.filter(provider__name="tokenssr", capability="text", name="gpt-5.5").update(is_default=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("ai", "0016_person_triview_16_9"), + ] + + operations = [ + migrations.RunPython(seed, unseed), + ] diff --git a/core/backend/apps/ai/migrations/0018_default_script_gemini.py b/core/backend/apps/ai/migrations/0018_default_script_gemini.py new file mode 100644 index 0000000..f8a1a5f --- /dev/null +++ b/core/backend/apps/ai/migrations/0018_default_script_gemini.py @@ -0,0 +1,38 @@ +"""脚本默认模型从 gpt-5.5 改为 gemini-3.1-pro-preview,并停用 YunQi 的 gpt-5.5。 + +实测(完整电商脚本 skill ~28KB 重型结构化任务,各连发 5 次): + - YunQi gpt-5.5 → 仅 1/5 成功(3 次吐空、1 次出文无合法 JSON,成功那次还要 69-95s)→ 频道扛不住,不可用 + - YunQi gemini-3.1-pro → 5/5 成功(每次 ~2300 字合法 JSON,30-48s 稳定) +故默认切 gemini;gpt-5.5 停用(YunQi 频道修好后改回 active 即可,一行)。 +幂等:显式置位,可重复 apply。 +""" +from django.db import migrations + + +def apply(apps, schema_editor): + ModelConfig = apps.get_model("ai", "ModelConfig") + # gemini 设默认 + 确保 active + ModelConfig.objects.filter( + provider__name="yunqi_gemini", capability="text", name="gemini-3.1-pro-preview", + ).update(is_default=True, status="active") + # gpt-5.5 取消默认 + 停用(频道不可用) + ModelConfig.objects.filter( + provider__name="yunqi_gpt", capability="text", name="gpt-5.5", + ).update(is_default=False, status="disabled") + + +def revert(apps, schema_editor): + ModelConfig = apps.get_model("ai", "ModelConfig") + ModelConfig.objects.filter(provider__name="yunqi_gemini", capability="text", name="gemini-3.1-pro-preview").update(is_default=False) + ModelConfig.objects.filter(provider__name="yunqi_gpt", capability="text", name="gpt-5.5").update(is_default=True, status="active") + + +class Migration(migrations.Migration): + + dependencies = [ + ("ai", "0017_switch_script_to_yunqi"), + ] + + operations = [ + migrations.RunPython(apply, revert), + ] diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index fd8d945..22d0de1 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -2,11 +2,14 @@ import json import re import subprocess import tempfile +import time import uuid from datetime import timedelta from decimal import Decimal from io import BytesIO from pathlib import Path + +import requests from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import transaction @@ -865,6 +868,38 @@ def build_person_frontal_prompt(description: str = "") -> str: return render_prompt("person_portrait", default, 描述=desc) +# --------------------------------------------------------------------------- # +# 出图重试(中转站偶发抖动 → 一次失败就「生成不出来」的根因兜底) +# --------------------------------------------------------------------------- # +_IMAGE_GEN_ATTEMPTS = 3 + + +def _is_transient_image_error(exc: Exception) -> bool: + """判定出图失败是否「瞬时」(值得重试)。瞬时:网络抖动 / 5xx / 429 限流 / 中转站吐空无 media url。 + 永久(不重试,立即退费报错):4xx 内容违规 / 参数错(如 invalid_image_file)、配置缺失等。""" + if isinstance(exc, (requests.ConnectionError, requests.Timeout)): + return True + if isinstance(exc, requests.HTTPError): + code = getattr(getattr(exc, "response", None), "status_code", 0) or 0 + return code >= 500 or code == 429 + # extract_first_media_url 在响应没有 url 时抛 ValueError —— 多为中转站偶发空返回,重试常能拿到 + if isinstance(exc, ValueError) and "media url" in str(exc).lower(): + return True + return False + + +def _run_image_with_retry(make, *, attempts: int = _IMAGE_GEN_ATTEMPTS): + """跑一次出图(provider 调用 + 取 media url),瞬时错误按退避重试,永久错误立即抛。 + make() 须返回 (response, media)。退避 2s / 4s,总耗时上限 ~6s + 出图本身,worker 内执行对用户无感。""" + for i in range(attempts): + try: + return make() + except Exception as exc: # noqa: BLE001 + if i == attempts - 1 or not _is_transient_image_error(exc): + raise + time.sleep(2 * (i + 1)) + + def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask: """提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级), 慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。 @@ -927,18 +962,22 @@ def run_base_asset_task(*, task_id: str) -> None: provider = get_image_provider(model_config) reservation = task.credit_reservation try: - if use_edit and ref_url: - # 商品三视图:比例可在 admin 改(默认横) - size = prompt_ratio_size("product_triview", "1536x1024") - response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size) - else: - # 场景默认横、人物立绘默认竖;两者比例都可在 admin 改 - if kind == BaseAssetGroup.Kind.SCENE: - gen_size = prompt_ratio_size("scene", "1536x1024") + def _make(): + if use_edit and ref_url: + # 商品三视图:比例可在 admin 改(默认横) + size = prompt_ratio_size("product_triview", "1536x1024") + resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size) else: - gen_size = prompt_ratio_size("person_portrait", "1024x1536") - response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size) - media = provider.extract_first_media_url(response) + # 场景默认横、人物立绘默认竖;两者比例都可在 admin 改 + if kind == BaseAssetGroup.Kind.SCENE: + gen_size = prompt_ratio_size("scene", "1536x1024") + else: + gen_size = prompt_ratio_size("person_portrait", "1024x1536") + resp = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size) + return resp, provider.extract_first_media_url(resp) + + # 中转站偶发抖动(网络/5xx/限流/空返回)按退避重试,避免「一次失败就生成不出来」;永久错误仍立即退费 + response, media = _run_image_with_retry(_make) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.response_payload = response @@ -1027,9 +1066,13 @@ def run_triview_task(*, task_id: str) -> None: provider = get_image_provider(model_config) reservation = task.credit_reservation try: - tri_size = prompt_ratio_size("person_triview", "1536x1024") - response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size) - media = provider.extract_first_media_url(response) + def _make(): + tri_size = prompt_ratio_size("person_triview", "1536x1024") + resp = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size) + return resp, provider.extract_first_media_url(resp) + + # 同基础资产:瞬时抖动重试,永久错误立即退费报错 + response, media = _run_image_with_retry(_make) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.response_payload = response diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 1a93141..29d3109 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -916,7 +916,7 @@ export function App() { onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")} onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")} onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")} - onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已替换为所选演员")} + onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已采用所选素材")} onGenerateTriview={async (portraitAssetId) => { // 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图 const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成"); @@ -940,11 +940,24 @@ export function App() { action(async () => { // 「全部重跑」要把已完成的也重跑(否则全成功的项目点了等于没点);只跳过在途(running/queued)避免重复提交 const targets = pipelineProject.video_segments.filter((segment) => !["running", "queued"].includes(segment.status)); - for (const segment of targets) { - await api.submitVideo(pipelineProject.id, { - video_segment_id: segment.id, - prompt: `${prompt} 第 ${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒` - }); + // ★ 并发提交,且单段失败绝不拖累其余段。旧实现用 for+await 串行,任一段抛错(如人脸需走素材库 + // 的 502、中转限流)整个循环就 break → 后面的段永不提交,前端出现「4 个框只有 2 个在跑」。 + // 改 allSettled:每段各自独立提交,失败只标该段,成功的照常进生成。 + const results = await Promise.allSettled( + targets.map((segment) => + api.submitVideo(pipelineProject.id, { + video_segment_id: segment.id, + prompt: `${prompt} 第 ${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒`, + }) + ) + ); + const failed = results.filter((r): r is PromiseRejectedResult => r.status === "rejected"); + if (failed.length) { + // 部分失败也要刷新,让已成功提交的段立刻进「生成中」(否则 action 走 catch 分支会跳过刷新) + await refreshProjectDetail(); + const firstErr = failed[0].reason; + const detail = firstErr instanceof Error ? firstErr.message : "提交失败"; + throw new Error(`${targets.length - failed.length}/${targets.length} 段已提交,${failed.length} 段失败:${detail}(可对失败的段单独点重跑)`); } return targets.length; }, "多段视频已提交,生成中…") diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index e7ee2e5..46f4b61 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -603,6 +603,36 @@ export function PipelinePage(props: { // 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group const [triPanelOpen, setTriPanelOpen] = useState(false); const [triPreviewId, setTriPreviewId] = useState(null); + // 问题5(a):用「已有素材」直接当最终三视图——选商品已有图 / 上传本地图,attach 成商品组采用版(不走平台 AI 生成、不计费)。 + const [triPickOpen, setTriPickOpen] = useState(false); + const [triPickBusy, setTriPickBusy] = useState(false); + const triFileRef = useRef(null); + async function attachExistingTri(assetId: string) { + setTriPickBusy(true); + try { + await onAttachBaseAsset({ kind: "product" }, assetId); // 后端 attach-base-asset 按 kind=product 命中/建商品组并采用 + setTriPickOpen(false); + } finally { + setTriPickBusy(false); + } + } + async function uploadTriFromFile(file: File) { + setTriPickBusy(true); + try { + const fd = new FormData(); + fd.append("file", file); + fd.append("asset_type", "image"); + fd.append("category", "tri_view"); // 标三视图类(与生成的三视图同类,送审/展示口径一致) + fd.append("name", `${productName || "商品"}·三视图`); + const asset = await api.uploadAsset(fd); // 落 TOS + 建 team 资产,返回带 id + await onAttachBaseAsset({ kind: "product" }, asset.id); + setTriPickOpen(false); + } catch (e) { + onNotify?.("error", e instanceof Error && e.message ? e.message : "上传失败,请重试"); + } finally { + setTriPickBusy(false); + } + } function jumpAssetSection(kind: "product" | "person" | "scene") { setAssetTab(kind); if (typeof document !== "undefined") { @@ -2818,6 +2848,8 @@ export function PipelinePage(props: { {triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")} + {/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */} + {/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */} @@ -3936,6 +3968,38 @@ export function PipelinePage(props: { ); })()} + {/* 问题5(a):用已有素材作三视图——选商品已有图 / 上传本地图,attach 成商品采用版(不走平台 AI 生成、不计费) */} + {triPickOpen && ( +
{ if (e.target === e.currentTarget && !triPickBusy) setTriPickOpen(false); }}> +
+
+

使用已有素材作三视图

+ // 选商品图或上传本地图,直接作最终三视图 + +
+
+ { const f = e.target.files?.[0]; if (f) void uploadTriFromFile(f); e.target.value = ""; }} /> +
+ {/* 上传本地图 tile */} +
{ if (!triPickBusy) triFileRef.current?.click(); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !triPickBusy) { e.preventDefault(); triFileRef.current?.click(); } }}> + {triPickBusy ? "处理中…" : "+ 上传本地图"} +
+ {/* 商品已有图:点击直接采用为三视图 */} + {(productRecord?.images ?? []).map((img) => ( +
{ if (!triPickBusy) void attachExistingTri(img.asset); }} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !triPickBusy) { e.preventDefault(); void attachExistingTri(img.asset); } }}> + {!img.preview_url && // 无预览} +
+ ))} +
+ {(productRecord?.images ?? []).length === 0 && ( +
// 该商品暂无已有图,可直接上传本地图
+ )} +
+
+
+ )} {/* 流程步骤4 · 演员库:浏览 / 添加演员(AI生成·本地上传)/ 替换回填 */}