Compare commits
5 Commits
3f216727bb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d82c8d22b | |||
| cfe963a2c7 | |||
| 549082ace3 | |||
| 88d598303c | |||
| 3f5dfdc465 |
@@ -32,6 +32,12 @@
|
||||
"storage" : "api\/.env \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "xAI \/ Grok Imagine Video 独立视频通道 API Key;生产只放服务器 deploy\/.env.production 的 XAI_VIDEO_API_KEY,本地开发放 api\/.env 或 deploy\/.env.local,不入库",
|
||||
"name" : "XAI_VIDEO_API_KEY",
|
||||
"storage" : "api\/.env \/ deploy\/.env.local \/ deploy\/.env.production",
|
||||
"type" : "api_key"
|
||||
},
|
||||
{
|
||||
"description" : "生产网页登录备用账号已停用,当前只允许飞书免登录;如需紧急恢复,需在服务器 deploy\/.env.production 显式开启 PASSWORD_AUTH_ENABLED=true。备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt,后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
|
||||
"name" : "WEB_LOGIN",
|
||||
|
||||
7
RULES.md
7
RULES.md
@@ -28,6 +28,9 @@
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
- Grok 创建重试与 AI 润色修复生产部署(2026-06-04):`88d5983`(AI 润色可用模型 fallback)和 `549082a`(Grok/xAI 视频创建阶段瞬时错误重试)已推送 Gitea 并通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前生产备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260604064742.tgz`。生产 Docker 重建后脚本验证通过(web/api/postgres Up、`web:no_local_api_refs`、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []`、`api:health ok db connected`);补验 `https://marketing.skg.com/login/` 返回 200,`https://marketing.skg.com/api/health` 未登录返回 401。生产 API 容器内 `/health` 确认 `xai_video_configured=true`、`xai_video_model=grok-imagine-video`、`xai_video_create_path=/v1/videos/generations`、`video_create_retry_attempts=3`、`video_create_retry_backoff_seconds=2.0`,视频模型列表中 `xai / Grok Imagine Video` 仍为 `available=true`。
|
||||
- Grok 创建重试生产真实生成验收(2026-06-04):部署后在生产 API 容器内用临时飞书测试身份走真实 HTTP API 创建 job `213509450eab`,触发 Grok video `5202f39c9827`,模型 `grok-imagine-video`,xAI provider id `b59a8fa0-b294-9e0c-a4bf-a0b349876eba`;轮询到 `completed` 后 `/jobs/213509450eab/storyboard-videos/5202f39c9827.mp4` 返回 `206 video/mp4`,`content-range bytes 0-1023/2274409`,确认生产创建、轮询、下载链路真实通畅。
|
||||
- Grok Imagine Video 生产接入(2026-06-03):`3f21672` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前生产备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260603155046.tgz`。本次先在服务器 `deploy/.env.production` 补齐 `VIDEO_MODEL_XAI=grok-imagine-video`、`XAI_VIDEO_API_BASE_URL=https://ai.skg.com/ezlink/xai`、`XAI_VIDEO_API_KEY`、创建/轮询路径,补配置前已生成备份 `deploy/.env.production.xai-backup-20260603155008`,真实 key 不入库。生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/api/postgres Up、`web:no_local_api_refs`、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []`、`api:health ok db connected`);生产 API 容器确认 `/health` 能力中 `xai_video_configured=true`,视频模型列表含 `seedance`、`seedance_hd`、`xai:grok-imagine-video`。生产真实 HTTP 验收已直接创建测试 job `1c3e63e5791b` 并触发 video `71fab5ea21d4`,xAI provider id `e33bcb9e-d3dc-9a7c-92c4-480b4879f438`,最终 `/jobs/1c3e63e5791b/storyboard-videos/71fab5ea21d4.mp4` 返回 `206 video/mp4`、`content-range bytes 0-1023/1125433`,确认创建、轮询、MP4 下载链路通畅。
|
||||
- Agent Cut 独立预览服务器:`2.24.28.41`(Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`,Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
|
||||
- Agent Cut 独立预览验证(2026-05-21):已在 `2.24.28.41` 的 `/opt/skg-marketing-studio` 用 `docker-compose.standalone.yml` 启动 `skg-agent-api` / `skg-agent-web`;独立 compose 通过网络别名兼容 Nginx 的 `skg-marketing-api` upstream。该裸 IP HTTP 入口的服务器 `deploy/.env.production` 需要 `WEB_AUTH_COOKIE_SECURE=false`;本次已补齐 `WEB_AUTH_*` 后重启验证通过:未登录 `/agent/` 返回 302 到 `/login/`,登录后 `/agent/` 返回 200,`/api/agent-runs` 返回数组,容器内 `/health` 返回 `ok:true` 且 `auth_configured:true`。
|
||||
- 稳定性/安全加固(2026-05-30):`3572dde`(含 `3ed3f72` fix(api) / `b56d517` fix(canvas) / `6201ee9` fix(web))已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260529181045.tgz`。本次后端:`run()` 子进程加超时(下载 `DOWNLOAD_TIMEOUT_SECONDS` 默认 600s、其余 300s,超时 kill 并标 failed)、新增 `validate_source_url()` SSRF 白名单(拒绝 `file://`/私有·环回·链路本地 IP,域名走 `SOURCE_URL_ALLOWED_HOSTS`,默认主流短视频平台)、per-job `RLock` 保护 `save_state`/`update`/`update_generated_video` 与 retry 的 check-and-set、`db.py` 改用 `psycopg_pool` 连接池且写失败 `logging.error` 暴露、只读媒体 GET 改用不创建目录的 `job_path()`、多处 `Image.open()` 改 `with` 防 fd 泄漏;新增后端依赖 `psycopg-pool`(未装自动回退)。前端:画布 VideoNode 上传改走后端 `/jobs/upload` 拿稳定 URL 并在 `cleanNodeForStorage` 剥 `blob:`、`useCachedMediaUrl` 用真实 `blob.size` 统计缓存并补 catch 竞态校验、读参考图补 credentials、删除与 Canvas 层重复的节点级视频轮询与 `api/video.js` 死代码、`request.js` timeout 改 60s+withCredentials;首页/详情页视频轮询改为容错(连续失败 10 次才停)、agent 页预览 objectURL 移入 effect、登录页 pointermove rAF 节流。飞书登录自动跳转行为按确认保留不动。本地 `python3 -m py_compile api/main.py api/db.py` 与 `cd web && pnpm build`(canvas + next)通过(本机 Docker web 镜像因 next/font 拉取 Google Fonts 受限未重建,生产服务器构建正常)。生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/api/postgres Up、`web:no_local_api_refs`、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、`api:ytdlp_cookie_args []`、`api:health ok db connected`);生产 API 容器内复验 `psycopg_pool 3.2.4` 生效、`validate_source_url` 对 `file://`/`169.254.169.254`/`evil.com` 返回 400 而 `tiktok.com` 放行、`run()` 默认 timeout=300、`DOWNLOAD_TIMEOUT_SECONDS=600`。新增可选 env:`DOWNLOAD_TIMEOUT_SECONDS`、`SOURCE_URL_ALLOWED_HOSTS`、`DB_POOL_MAX_SIZE`。
|
||||
@@ -149,7 +152,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 产品卖点
|
||||
@@ -166,6 +170,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`,不入库。
|
||||
|
||||
@@ -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
|
||||
|
||||
153
api/main.py
153
api/main.py
@@ -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,23 +6433,28 @@ 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]}"
|
||||
|
||||
try:
|
||||
resp = llm().chat.completions.create(
|
||||
model=REWRITE_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},
|
||||
],
|
||||
temperature=0.45,
|
||||
max_tokens=900,
|
||||
)
|
||||
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))
|
||||
except Exception as e:
|
||||
print(f"[prompt polish fallback] {e}", flush=True)
|
||||
return _prompt_polish_fallback(req)
|
||||
model_errors: list[str] = []
|
||||
for model in _prompt_polish_model_candidates(req):
|
||||
try:
|
||||
resp = llm().chat.completions.create(
|
||||
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},
|
||||
],
|
||||
temperature=0.45,
|
||||
max_tokens=900,
|
||||
)
|
||||
out = _clean_prompt_output(resp.choices[0].message.content or "")
|
||||
if not out:
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
@app.post("/translate")
|
||||
@@ -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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
if resp.status_code < 400:
|
||||
create = resp
|
||||
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"{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"{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"{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"{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}:
|
||||
raise RuntimeError(_video_create_failure_message(create_errors))
|
||||
break
|
||||
if create is not None:
|
||||
break
|
||||
create_errors.append(f"{video_path(create_path)} -> HTTP {resp.status_code}: {resp.text[:700]}")
|
||||
if resp.status_code not in {400, 404, 405}:
|
||||
resp.raise_for_status()
|
||||
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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<jobId>/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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user