2 Commits

Author SHA1 Message Date
549082ace3 fix: retry transient xai video creation failures 2026-06-04 14:17:12 +08:00
88d598303c fix: route ai polish through available models 2026-06-04 10:13:31 +08:00
7 changed files with 162 additions and 40 deletions

View File

@@ -150,7 +150,8 @@
- `LOCAL_ASR_BIN` / `LOCAL_ASR_MODEL` / `LOCAL_ASR_TIMEOUT_SECONDS`:本机 ASR 兜底,默认使用 `/opt/homebrew/bin/mlx_whisper` + `mlx-community/whisper-tiny`,用于当前 SKG 网关 `/audio/transcriptions` 不可用时生成真实逐句时间轴
- `TRANSLATE_MODEL`:字幕翻译模型,默认 `gemini-2.5-flash`
- `GPT_TEXT_MODEL`GPT 文本 / 视觉默认模型,默认 `gpt-4o`;用于兜底修正旧 Gemini 覆盖值
- `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
- `REWRITE_MODEL`:通用改写/分镜描述模型,当前用于 AI 润色时默认 `gpt-4o-mini`;如果主模型不可用,`/prompt/polish` 会继续尝试 `REWRITE_MODEL_FALLBACKS`
- `REWRITE_MODEL_FALLBACKS`AI 润色备用模型列表,逗号分隔,默认 `gpt-4o-mini,gemini-2.5-flash`;只有全部失败时才允许返回本地模板 fallback
- `VISION_MODEL`:关键帧画面理解模型,默认 `gpt-4o`;如果旧环境仍写 `gemini-*`,后端会自动改用 `GPT_TEXT_MODEL`
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;如果旧环境仍写 `gemini-*`,后端会自动改用 `REWRITE_MODEL`
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
@@ -167,6 +168,7 @@
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
- `POE_API_KEY` / `VIDEO_API_KEY`:默认视频生成通道 Key只能放本地环境变量如果显式配置了 `VIDEO_API_BASE_URL`,必须同时配置 `VIDEO_API_KEY` 才会在 `/health` 暴露该默认视频通道,不能用通用 `LLM_API_KEY` 冒充视频 key。
- `XAI_VIDEO_API_BASE_URL` / `XAI_VIDEO_API_KEY` / `VIDEO_MODEL_XAI`xAI / Grok Imagine Video 独立视频通道;默认 base 为 `https://ai.skg.com/ezlink/xai`,模型为 `grok-imagine-video`,真实 key 只放本地 `api/.env`、本地 Docker `deploy/.env.local` 或服务器 `deploy/.env.production`,不入库。未配置 `XAI_VIDEO_API_KEY``/health` 会标记 xAI 视频不可用,画布不显示该模型;已配置时即使默认 Doubao/Seedance 视频 key 为空,也可以独立显示和生成 Grok Imagine Video。
- `VIDEO_CREATE_RETRY_ATTEMPTS` / `VIDEO_CREATE_RETRY_BACKOFF_SECONDS`:视频创建请求的瞬时错误重试配置,默认 Grok/xAI 创建阶段遇到连接重置、超时、429 或 5xx 时最多尝试 3 次,基础退避 2 秒400/401/403/404 等参数或权限错误不重试,避免掩盖真实配置问题。
- `PASSWORD_AUTH_ENABLED`:生产密码登录总开关;当前固定为 `false`,只允许飞书免登录。若应急恢复密码入口,必须显式改成 `true` 并重启 API。
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。当前密码入口被 `PASSWORD_AUTH_ENABLED=false` 禁用;即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。

View File

@@ -30,7 +30,8 @@ LOCAL_ASR_MODEL=mlx-community/whisper-tiny
LOCAL_ASR_TIMEOUT_SECONDS=180
TRANSLATE_MODEL=gemini-2.5-flash
GPT_TEXT_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o-mini
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
VISION_MODEL=gpt-4o
PRODUCT_VIEW_MODEL=gpt-image-2
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
@@ -114,6 +115,8 @@ VIDEO_STATUS_PATH=/videos/{id}
VIDEO_CONTENT_PATH=/videos/{id}/content
VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900
VIDEO_CREATE_RETRY_ATTEMPTS=3
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
# 工作目录
KEYFRAME_COUNT=12

View File

@@ -107,6 +107,11 @@ def gpt_model_env(name: str, default: str | None = None) -> str:
REWRITE_MODEL = gpt_model_env("REWRITE_MODEL")
REWRITE_MODEL_FALLBACKS = [
model.strip()
for model in os.getenv("REWRITE_MODEL_FALLBACKS", "gpt-4o-mini,gemini-2.5-flash").split(",")
if model.strip()
]
VISION_MODEL = gpt_model_env("VISION_MODEL")
IMAGE_BASE_URL = os.getenv("IMAGE_BASE_URL", LLM_BASE_URL).strip()
IMAGE_API_KEY = os.getenv("IMAGE_API_KEY", LLM_API_KEY).strip()
@@ -446,6 +451,8 @@ VIDEO_STATUS_PATH = os.getenv("VIDEO_STATUS_PATH", DEFAULT_VIDEO_STATUS_PATH).st
VIDEO_CONTENT_PATH = os.getenv("VIDEO_CONTENT_PATH", DEFAULT_VIDEO_CONTENT_PATH).strip() or DEFAULT_VIDEO_CONTENT_PATH
VIDEO_DURATION_FIELD = os.getenv("VIDEO_DURATION_FIELD", "seconds").strip() or "seconds"
VIDEO_POLL_TIMEOUT_SECONDS = max(60, int(os.getenv("VIDEO_POLL_TIMEOUT_SECONDS", "900")))
VIDEO_CREATE_RETRY_ATTEMPTS = max(1, int(os.getenv("VIDEO_CREATE_RETRY_ATTEMPTS", "3")))
VIDEO_CREATE_RETRY_BACKOFF_SECONDS = max(0.5, float(os.getenv("VIDEO_CREATE_RETRY_BACKOFF_SECONDS", "2")))
FFMPEG_BIN = os.getenv("FFMPEG_BIN", "").strip()
FFPROBE_BIN = os.getenv("FFPROBE_BIN", "").strip()
LOCAL_FFMPEG_CANDIDATES = [
@@ -5914,6 +5921,7 @@ class CreativeCopyResp(BaseModel):
class PromptPolishReq(BaseModel):
text: str
system_prompt: str = ""
model: str = ""
mode: Literal["image", "video", "general", "chat"] = "image"
target_language: Literal["en", "zh", "keep"] = "en"
@@ -6320,11 +6328,26 @@ def _prompt_polish_fallback(req: PromptPolishReq) -> PromptPolishResp:
return PromptPolishResp(model="fallback", text=_sanitize_polished_prompt(req, intent, _basic_polished_prompt(req, intent)))
def _repair_polished_prompt(req: PromptPolishReq, intent: PromptIntent, output: str, *, allow_llm: bool = False) -> str:
def _prompt_polish_model_candidates(req: PromptPolishReq) -> list[str]:
requested = (req.model or "").strip()
candidates = [requested, REWRITE_MODEL, *REWRITE_MODEL_FALLBACKS]
seen: set[str] = set()
out: list[str] = []
for model in candidates:
model = model.strip()
key = model.lower()
if model and key not in seen:
out.append(model)
seen.add(key)
return out
def _repair_polished_prompt(req: PromptPolishReq, intent: PromptIntent, output: str, *, allow_llm: bool = False, model: str | None = None) -> str:
out = _sanitize_polished_prompt(req, intent, output)
issue = _polished_prompt_issue(intent, out)
if not issue or not allow_llm or not LLM_API_KEY:
return out
repair_model = (model or REWRITE_MODEL).strip() or REWRITE_MODEL
repair_prompt = (
"Repair the rewritten generation prompt so it follows the source input exactly.\n"
f"Issue to fix: {issue}.\n"
@@ -6340,7 +6363,7 @@ def _repair_polished_prompt(req: PromptPolishReq, intent: PromptIntent, output:
)
try:
resp = llm().chat.completions.create(
model=REWRITE_MODEL,
model=repair_model,
messages=[
{"role": "system", "content": "You repair generation prompts by removing contradictions and preserving only source intent."},
{"role": "user", "content": repair_prompt},
@@ -6410,9 +6433,11 @@ def polish_prompt(req: PromptPolishReq) -> PromptPolishResp:
prompt += f"\nUser-selected polishing guidance:\n{user_system[:1000]}\n"
prompt += f"\nSource input:\n{intent.cleaned_text[:2500]}"
model_errors: list[str] = []
for model in _prompt_polish_model_candidates(req):
try:
resp = llm().chat.completions.create(
model=REWRITE_MODEL,
model=model,
messages=[
{"role": "system", "content": "You are a neutral professional prompt editor. Preserve source intent exactly and never inject SKG or unrelated brands, products, platforms, people, or marketing context."},
{"role": "user", "content": prompt},
@@ -6422,10 +6447,13 @@ def polish_prompt(req: PromptPolishReq) -> PromptPolishResp:
)
out = _clean_prompt_output(resp.choices[0].message.content or "")
if not out:
out = _prompt_polish_fallback(req).text
return PromptPolishResp(model=REWRITE_MODEL, text=_repair_polished_prompt(req, intent, out, allow_llm=True))
raise RuntimeError("empty prompt polish response")
return PromptPolishResp(model=model, text=_repair_polished_prompt(req, intent, out, allow_llm=True, model=model))
except Exception as e:
print(f"[prompt polish fallback] {e}", flush=True)
message = str(e).replace("\n", " ")[:400]
model_errors.append(f"{model}: {message}")
print(f"[prompt polish model fallback] model={model} error={message}", flush=True)
print(f"[prompt polish fallback] {' | '.join(model_errors)}", flush=True)
return _prompt_polish_fallback(req)
@@ -6703,6 +6731,8 @@ def health() -> dict:
"video_base_url": video_api_base(),
"video_configured": bool(video_api_key()),
"video_create_paths": VIDEO_CREATE_PATHS,
"video_create_retry_attempts": VIDEO_CREATE_RETRY_ATTEMPTS,
"video_create_retry_backoff_seconds": VIDEO_CREATE_RETRY_BACKOFF_SECONDS,
"xai_video_model": XAI_VIDEO_MODEL,
"xai_video_base_url": XAI_VIDEO_API_BASE_URL,
"xai_video_configured": bool(video_api_key(XAI_VIDEO_MODEL)),
@@ -9041,6 +9071,9 @@ def _video_public_error(raw: object) -> str:
"connecterror",
"connecttimeout",
"readtimeout",
"connection reset",
"connection aborted",
"remote protocol error",
"ssl:",
"_ssl.c",
"handshake",
@@ -9098,6 +9131,19 @@ def _video_public_error(raw: object) -> str:
if any(token in lower for token in ("timeout", "timed out", "readtimeout", "connecttimeout", "超时")):
return "视频生成失败:视频模型响应超时,可能是上游繁忙或网络不稳定。请稍后重试,或缩短时长后再生成。"
if any(token in lower for token in (
"http 500",
"http 502",
"http 503",
"http 504",
"internal server error",
"bad gateway",
"service unavailable",
"gateway timeout",
"server error",
)):
return "视频生成失败:视频模型上游服务暂时异常,系统已自动重试但仍未成功。请稍后重新生成;如果持续出现,请联系管理员检查视频网关。"
if any(token in lower for token in (
"name or service not known",
"temporary failure in name resolution",
@@ -9105,6 +9151,9 @@ def _video_public_error(raw: object) -> str:
"connection refused",
"network is unreachable",
"connecterror",
"connection reset",
"connection aborted",
"remote protocol error",
"ssl:",
"网络",
"dns",
@@ -9283,6 +9332,21 @@ def submit_video_create(
)
_VIDEO_CREATE_RETRY_STATUS_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
def _video_create_attempts(model: str | None) -> int:
return VIDEO_CREATE_RETRY_ATTEMPTS if video_uses_xai(model) else 1
def _video_create_retry_delay(attempt: int) -> float:
return min(20.0, VIDEO_CREATE_RETRY_BACKOFF_SECONDS * (2 ** max(0, attempt - 1)))
def _video_create_transport_error(exc: Exception) -> bool:
return isinstance(exc, (httpx.TransportError, httpx.TimeoutException))
def render_storyboard_video(
job_id: str,
local_id: str,
@@ -9326,22 +9390,43 @@ def render_storyboard_video(
create = None
create_errors: list[str] = []
for create_path in video_create_paths(model):
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs, primary_role)
path = video_path(create_path)
url = f"{base}{path}"
attempts = _video_create_attempts(model)
for attempt in range(1, attempts + 1):
try:
resp = submit_video_create(client, url, headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs, primary_role)
except Exception as exc:
create_errors.append(f"{path} attempt {attempt}/{attempts} -> {exc.__class__.__name__}: {str(exc)[:700]}")
if attempt < attempts and _video_create_transport_error(exc):
delay = _video_create_retry_delay(attempt)
print(f"[video create retry] job={job_id} video={local_id} path={path} attempt={attempt}/{attempts} error={str(exc)[:300]} retry_in={delay:.1f}s", flush=True)
time.sleep(delay)
continue
raise
if video_uses_ark(model) and source_ref and resp.status_code in {400, 422}:
create_errors.append(f"{video_path(create_path)} + reference_video -> HTTP {resp.status_code}: {resp.text[:700]}")
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs, primary_role)
create_errors.append(f"{path} + reference_video -> HTTP {resp.status_code}: {resp.text[:700]}")
resp = submit_video_create(client, url, headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs, primary_role)
if video_uses_ark(model) and prepared_last_img and resp.status_code in {400, 422}:
create_errors.append(f"{video_path(create_path)} + last_frame -> HTTP {resp.status_code}: {resp.text[:700]}")
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_imgs, primary_role)
create_errors.append(f"{path} + last_frame -> HTTP {resp.status_code}: {resp.text[:700]}")
resp = submit_video_create(client, url, headers, ref_img, payload, None, None, prepared_product_imgs, primary_role)
if video_uses_ark(model) and prepared_product_imgs and resp.status_code in {400, 422}:
create_errors.append(f"{video_path(create_path)} + product_reference -> HTTP {resp.status_code}: {resp.text[:700]}")
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, None, primary_role)
create_errors.append(f"{path} + product_reference -> HTTP {resp.status_code}: {resp.text[:700]}")
resp = submit_video_create(client, url, headers, ref_img, payload, None, prepared_last_img, None, primary_role)
if resp.status_code < 400:
create = resp
break
create_errors.append(f"{video_path(create_path)} -> HTTP {resp.status_code}: {resp.text[:700]}")
create_errors.append(f"{path} attempt {attempt}/{attempts} -> HTTP {resp.status_code}: {resp.text[:700]}")
if resp.status_code in _VIDEO_CREATE_RETRY_STATUS_CODES and attempt < attempts:
delay = _video_create_retry_delay(attempt)
print(f"[video create retry] job={job_id} video={local_id} path={path} attempt={attempt}/{attempts} http={resp.status_code} retry_in={delay:.1f}s", flush=True)
time.sleep(delay)
continue
if resp.status_code not in {400, 404, 405}:
resp.raise_for_status()
raise RuntimeError(_video_create_failure_message(create_errors))
break
if create is not None:
break
if create is None:
print(f"[video create failed] job={job_id} video={local_id} errors={' | '.join(create_errors)[:1800]}", flush=True)
raise RuntimeError(_video_create_failure_message(create_errors))

View File

@@ -54,7 +54,8 @@ AI_HTTP_PROXY=
# Text/vision/audio model names
GPT_TEXT_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o-mini
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
VISION_MODEL=gpt-4o
TRANSLATE_MODEL=gemini-2.5-flash
ASR_BASE_URL=https://ai.skg.com/azure/v1
@@ -81,6 +82,8 @@ VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900
VIDEO_CREATE_RETRY_ATTEMPTS=3
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations

View File

@@ -56,7 +56,8 @@ FASTER_WHISPER_DEVICE=cpu
FASTER_WHISPER_COMPUTE_TYPE=int8
TRANSLATE_MODEL=gemini-2.5-flash
GPT_TEXT_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o
REWRITE_MODEL=gpt-4o-mini
REWRITE_MODEL_FALLBACKS=gemini-2.5-flash
VISION_MODEL=gpt-4o
PRODUCT_VIEW_MODEL=gpt-image-2
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
@@ -113,6 +114,8 @@ VIDEO_STATUS_PATH=/api/v3/contents/generations/tasks/{id}
VIDEO_CONTENT_PATH=/api/v3/contents/generations/tasks/{id}/content
VIDEO_DURATION_FIELD=seconds
VIDEO_POLL_TIMEOUT_SECONDS=900
VIDEO_CREATE_RETRY_ATTEMPTS=3
VIDEO_CREATE_RETRY_BACKOFF_SECONDS=2
XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai
XAI_VIDEO_API_KEY=
XAI_VIDEO_CREATE_PATH=/v1/videos/generations

View File

@@ -690,9 +690,9 @@
<h3>后端核心</h3>
<table>
<tbody>
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回同时承载全局 <code>prompt_library</code><code>asset_library</code> 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 <code>state.json</code> / 资源库并写入索引;<code>/canvas-projects</code> 系列接口把画布项目按当前登录用户持久化,<code>/canvas-workflows</code> 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 <code>POST /creative/jobs/image</code> 把上传图片或空白底图写成一个只有 0 号关键帧的 <code>Job</code>,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;<code>POST /prompt/polish</code> 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品、平台或短视频广告话术。润色链路会先用 <code>_strip_previous_polish_boilerplate</code> 去掉旧模板尾巴,再用 <code>_classify_prompt_intent</code> 判断人物、无人、物体、场景、动物或未知主体,最后用 <code>_repair_polished_prompt</code> 修掉有人/无人矛盾、未写人却新增人物、未写 SKG 却出现 SKG 等冲突;<code>_append_reference_image_person_guard</code> 会在视频任务最终入队前给参考图请求追加条件提示,声明参考图里若有人物则按 AI 生成的虚拟角色处理;<code>/health</code> 返回 <code>database</code><code>image_options</code><code>image_size_options</code><code>video_options</code><code>video_size_options</code><code>video_duration_options</code><code>video_max_duration_seconds</code><code>/frames/{idx}/generate</code><code>model</code> 字段用于图片模型偏好,<code>size</code> 字段用于图片输出尺寸;<code>/storyboard/video</code> 继续使用 <code>model</code> 字段选择视频别名,并先校验画幅与时长能力边界,然后把 <code>GeneratedVideo</code> 写成 <code>queued</code> 占位并进入进程内视频队列。队列默认 <code>VIDEO_QUEUE_MAX_CONCURRENT=2</code><code>VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1</code>,同一用户连续提交不会占满全局并发;排队任务会回写 <code>queue_position</code><code>queue_size</code><code>queue_message</code>。旧 <code>AgentRun</code> 一键出片状态机、TK 复刻接口和 <code>POST /creative/copy</code> 作为明确的 SKG 营销文案接口继续保留。</td></tr>
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回同时承载全局 <code>prompt_library</code><code>asset_library</code> 的磁盘索引、CRUD、删除保护和复制到 job API。启动时会初始化 Postgres schema、扫描现有 <code>state.json</code> / 资源库并写入索引;<code>/canvas-projects</code> 系列接口把画布项目按当前登录用户持久化,<code>/canvas-workflows</code> 系列接口把我的工作流按当前登录用户持久化为可复用模板。轻量创作入口 <code>POST /creative/jobs/image</code> 把上传图片或空白底图写成一个只有 0 号关键帧的 <code>Job</code>,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;<code>POST /prompt/polish</code> 用于中性 AI 润色和通用 LLM 文本生成,只保留用户明确给出的主体、品牌、产品、地点、风格和意图,不默认加入 SKG、按摩产品、平台或短视频广告话术。润色链路会先用 <code>_strip_previous_polish_boilerplate</code> 去掉旧模板尾巴,再用 <code>_classify_prompt_intent</code> 判断人物、无人、物体、场景、动物或未知主体,最后用 <code>_repair_polished_prompt</code> 修掉有人/无人矛盾、未写人却新增人物、未写 SKG 却出现 SKG 等冲突;<code>_append_reference_image_person_guard</code> 会在视频任务最终入队前给参考图请求追加条件提示,声明参考图里若有人物则按 AI 生成的虚拟角色处理;<code>/health</code> 返回 <code>database</code><code>image_options</code><code>image_size_options</code><code>video_options</code><code>video_size_options</code><code>video_duration_options</code><code>video_max_duration_seconds</code> 和视频创建重试配置<code>/frames/{idx}/generate</code><code>model</code> 字段用于图片模型偏好,<code>size</code> 字段用于图片输出尺寸;<code>/storyboard/video</code> 继续使用 <code>model</code> 字段选择视频别名,并先校验画幅与时长能力边界,然后把 <code>GeneratedVideo</code> 写成 <code>queued</code> 占位并进入进程内视频队列。Grok/xAI 创建阶段遇到连接重置、超时、429 或 5xx 会按 <code>VIDEO_CREATE_RETRY_ATTEMPTS</code><code>VIDEO_CREATE_RETRY_BACKOFF_SECONDS</code> 自动退避重试400/403 等明确错误不重试。队列默认 <code>VIDEO_QUEUE_MAX_CONCURRENT=2</code><code>VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1</code>,同一用户连续提交不会占满全局并发;排队任务会回写 <code>queue_position</code><code>queue_size</code><code>queue_message</code>。旧 <code>AgentRun</code> 一键出片状态机、TK 复刻接口和 <code>POST /creative/copy</code> 作为明确的 SKG 营销文案接口继续保留。</td></tr>
<tr><td><code>api/db.py</code></td><td>Postgres 适配层:在 <code>DATABASE_URL</code> 存在且 <code>psycopg</code> 可用时启用;负责建表、健康检查、用户 upsert、审计日志、画布项目 CRUD、我的工作流 CRUD以及把 <code>Job</code><code>AgentRun</code>、提示词库和素材库写入索引表。数据库不可用时本地开发会降级为 disabled生产 <code>verify-prod-docker.sh</code> 会要求 <code>database.connected=true</code></td></tr>
<tr><td><code>video_model_options()</code></td><td>视频模型能力出口按当前视频网关过滤可真实路由的业务别名Doubao / Ark 网关只暴露 <code>doubao-seedance*</code> 真实模型Poe 网关才允许 Poe 的 Seedance / Kling / Veo 类模型;如果显式配置了 <code>VIDEO_API_BASE_URL</code><code>VIDEO_API_KEY</code> 为空,默认视频通道会标记不可用,不再回退通用 <code>LLM_API_KEY</code>。新增 <code>xai</code> / <code>grok-imagine-video</code> 独立走 <code>XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai</code><code>XAI_VIDEO_API_KEY</code><code>/v1/videos/generations</code><code>/v1/videos/{id}</code>,创建返回 <code>request_id</code>、轮询完成返回 <code>video.url</code>;未配置 xAI key 时 <code>/health</code> 会标记不可用,前端不显示。</td></tr>
<tr><td><code>video_model_options()</code></td><td>视频模型能力出口按当前视频网关过滤可真实路由的业务别名Doubao / Ark 网关只暴露 <code>doubao-seedance*</code> 真实模型Poe 网关才允许 Poe 的 Seedance / Kling / Veo 类模型;如果显式配置了 <code>VIDEO_API_BASE_URL</code><code>VIDEO_API_KEY</code> 为空,默认视频通道会标记不可用,不再回退通用 <code>LLM_API_KEY</code>。新增 <code>xai</code> / <code>grok-imagine-video</code> 独立走 <code>XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai</code><code>XAI_VIDEO_API_KEY</code><code>/v1/videos/generations</code><code>/v1/videos/{id}</code>,创建返回 <code>request_id</code>、轮询完成返回 <code>video.url</code>;未配置 xAI key 时 <code>/health</code> 会标记不可用,前端不显示。创建阶段的瞬时错误重试由 <code>VIDEO_CREATE_RETRY_ATTEMPTS</code> / <code>VIDEO_CREATE_RETRY_BACKOFF_SECONDS</code> 控制,并随 <code>/health</code> 暴露非敏感数值。</td></tr>
<tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,<code>images/</code> 存 45 张参考图。</td></tr>
<tr><td><code>api/character_library/skg-characters</code></td><td>内置相似主体形象库:从桌面 5 套策划形象导入,<code>manifest.json</code> 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 <code>prompt_brief</code>。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。</td></tr>
<tr><td><code>asset_library/</code></td><td>全局素材库目录,和 <code>jobs/</code> 平级,不写入任何 job state。四类目录为 <code>subjects</code><code>products</code><code>scenes</code><code>videos</code>;每个素材自带 <code>manifest.json</code> 和图片/视频文件,<code>index.json</code> 只是启动扫描重建出来的缓存。库素材选用到 job 时必须复制文件到 <code>jobs/&lt;jobId&gt;/assets</code><code>storyboard-videos</code>,禁止直接保存 library 引用。</td></tr>
@@ -1324,6 +1324,18 @@ ProductRefStateItem {
<p><strong>影响:</strong>本地只配置 <code>XAI_VIDEO_API_KEY</code> 时,画布视频下拉只显示 Grok Imagine Video同时配置有效 <code>VIDEO_API_KEY</code> 时才显示 Seedance。Kling / Veo 不会再因旧环境变量或旧缓存进入生成下拉。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-06-04 · Grok 视频创建阶段增加瞬时错误重试</h3>
<span class="tag blue">API</span>
<span class="tag orange">Bugfix</span>
</header>
<div class="body">
<p><strong>问题:</strong>生产排查刘凌的 Grok 视频失败时,后端状态显示模型已正确传为 <code>grok-imagine-video</code>,但 xAI 创建接口在返回 <code>request_id</code> 前出现 <code>500 Internal Server Error</code><code>Connection reset by peer</code>,旧逻辑会第一次失败就把候选视频标为失败。</p>
<p><strong>改动:</strong><code>api/main.py</code> 给 Grok/xAI 创建阶段增加 <code>VIDEO_CREATE_RETRY_ATTEMPTS</code><code>VIDEO_CREATE_RETRY_BACKOFF_SECONDS</code>默认遇到连接重置、超时、429 或 5xx 自动退避重试 3 次400/401/403/404 等明确参数或权限错误不重试。<code>/health</code> 暴露非敏感重试配置,错误提示把 5xx 归类为上游视频服务暂时异常。</p>
<p><strong>影响:</strong>Grok 通道不再因一次上游瞬时 500/断连直接失败;仍然保留日志中的每次重试状态,方便后续区分网关波动、权限问题和内容审核失败。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-06-03 · 接入 xAI Grok Imagine Video</h3>
@@ -1417,6 +1429,19 @@ ProductRefStateItem {
<p><strong>影响:</strong>Postgres 里的 <code>canvas_projects</code> 重新成为主存储;刷新、换浏览器或本地缓存异常时,不应再把服务端项目缩小或清空。旧项目首次迁移仍可用,但迁移动作变为非破坏性。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-06-04 · AI 润色不再静默套模板</h3>
<span class="tag amber">API</span>
<span class="tag violet">Canvas</span>
<span class="tag cyan">Model</span>
</header>
<div class="body">
<p><strong>问题:</strong>当前网关分组对 <code>gpt-4o</code> 返回“无可用渠道”,而 <code>/prompt/polish</code> 捕获异常后直接返回本地 <code>fallback</code>,用户看到的是固定尾巴模板,不是真正的模型润色;同时前端 <code>useChat({ model: 'gpt-4o-mini' })</code> 没有把 <code>model</code> 发给后端,配置实际上未生效。</p>
<p><strong>改动:</strong><code>PromptPolishReq</code> 新增 <code>model</code> 字段,<code>web/canvas-app/src/hooks/useApi.js</code> 会把前端选择的模型传到 <code>/prompt/polish</code>;后端按“请求模型 → <code>REWRITE_MODEL</code><code>REWRITE_MODEL_FALLBACKS</code>”依次尝试,当前本地默认 <code>REWRITE_MODEL=gpt-4o-mini</code>、备用 <code>gemini-2.5-flash</code>。只有全部模型失败时才返回本地模板 <code>model=fallback</code>,并在日志里记录每个失败模型。</p>
<p><strong>影响:</strong>画布底部和文本节点的 AI 润色会优先走真实模型输出,不再把固定 “Clear main subject...” 或 “Cinematic motion...” 当作正常润色结果;如果未来网关主模型不可用,接口会自动降级到备用模型,而不是立刻套模板。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-26 · AI 润色改为意图分类和冲突校验</h3>

View File

@@ -200,6 +200,7 @@ export const useChat = (options = {}) => {
body: JSON.stringify({
text: content,
system_prompt: options.systemPrompt || '',
model: options.model || '',
mode,
target_language: options.targetLanguage || (mode === 'chat' ? 'keep' : 'en')
})