Compare commits

..

3 Commits

Author SHA1 Message Date
CI Bot 3353af0fb8 style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 18s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m53s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m53s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 10m40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 10m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 13m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 11m14s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 21m43s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 27m10s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-10-05 11:55:06 +00:00
xiaoxia aac785a9ed feat(vision): 新增DashScope(阿里云百炼/qwen) provider支持
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
AI Code Review / AI Code Review (pull_request) Successful in 6m58s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 10m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- 新增_provider.py统一后端切换:VISION_V2_PROVIDER=ark|dashscope(默认dashscope对比测试)
- vlm_fast_json.py:双后端httpx直连,ark用thinking={type:disabled}、dashscope用enable_thinking=false,400降级逻辑按provider处理
- vlm_fallback.py:dashscope用qwen3.7-plus+精简JSON prompt(<20s),ark保留原有ai_client+XML/JSON双解析
- fast_path.py:超时默认值从provider读取(dashscope pro 20s、ark pro 45s)
- 通过环境变量DASHSCOPE_API_KEY配置key(敏感信息不入代码)
- 本地curl验证:qwen3.8-flash关thinking单图2.6s、3图并发7.3s、8图并发10.3s(7/8可用)
2026-10-05 19:42:11 +08:00
xiaoxia cdd343131e fix(vision): #2205 thinking参数互斥修复——只传thinking=disabled,去掉reasoning_effort
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m58s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m8s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
根因:上版同时传 thinking={type:disabled} + reasoning_effort=low,方舟返回400
Invalid combination of reasoning_effort and thinking type。降级重试分支
把两个参数都pop掉,模型回到默认thinking开启→响应9-12s超时,fast全败。

修复:
1. 只传 thinking={"type":"disabled"},去掉互斥的 reasoning_effort
2. 400降级只pop thinking,保留精简payload重试(不pop多个)
3. 先单独验证 lite 单次HTTP 200且reasoning_tokens=0再跑E2E,省一轮部署
2026-10-05 19:06:02 +08:00
152 changed files with 7338 additions and 18081 deletions
+2 -1
View File
@@ -1 +1,2 @@
trigger staging deploy for 3fa1fbf0 (MOCK_STORYBOARD fix) at 2026-10-12 13:40:16
CI trigger file - safe to delete
retrigger at 2026-09-15 20:31:24 UTC
+1 -2
View File
@@ -437,7 +437,7 @@ jobs:
if: "always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'"
name: Unit Tests
runs-on: ci-l2
timeout-minutes: 20
timeout-minutes: 8
env:
PIP_CACHE_DIR: /root/.cache/pip
PIP_NO_CACHE_DIR: ''
@@ -1195,7 +1195,6 @@ jobs:
DOUBAO_FAST_MODEL: "${{ secrets.DOUBAO_FAST_MODEL }}"
DOUBAO_TIMEOUT: "${{ secrets.DOUBAO_TIMEOUT }}"
DOUBAO_MAX_RETRIES: "${{ secrets.DOUBAO_MAX_RETRIES }}"
DITTO_EMOTION_TIMEOUT: "${{ secrets.DITTO_EMOTION_TIMEOUT }}"
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
@@ -1,61 +0,0 @@
"""功能计费积分字段(爆款/对口型/智能剪辑 DB 化计费)。
给 gpu_lipsync_tasks / generation_tasks / lipsync_jobs 三张表加积分字段:
- credits_prepaid: 提交任务时预扣积分
- credits_cost: 最终结算积分
- credits_transaction_id: 预扣流水 ID
注意:feature_pricing_configs 配置表由 xiaoxia-admin 侧 migration 建立,
本仓库只读,不在此创建。
Revision ID: 096_feature_billing_fields
Revises: 095_viral_video_prompt_templates
Create Date: 2026-10-05
"""
import sqlalchemy as sa
from alembic import op
revision = "096_feature_billing_fields"
down_revision = "095_viral_video_prompt_templates"
branch_labels = None
depends_on = None
_TABLES = ("gpu_lipsync_tasks", "generation_tasks", "lipsync_jobs")
_COLUMNS = (
("credits_prepaid", sa.Float(), "0"),
("credits_cost", sa.Float(), "0"),
("credits_transaction_id", sa.String(36), ""),
)
def _table_exists(conn, name: str) -> bool:
return name in sa.inspect(conn).get_table_names()
def upgrade() -> None:
conn = op.get_bind()
for table in _TABLES:
if not _table_exists(conn, table):
continue
existing = {c["name"] for c in sa.inspect(conn).get_columns(table)}
for col_name, col_type, default in _COLUMNS:
if col_name in existing:
continue
op.add_column(
table,
sa.Column(col_name, col_type, nullable=False, server_default=default),
)
def downgrade() -> None:
conn = op.get_bind()
for table in _TABLES:
if not _table_exists(conn, table):
continue
existing = {c["name"] for c in sa.inspect(conn).get_columns(table)}
for col_name, _col_type, _default in _COLUMNS:
if col_name not in existing:
continue
op.drop_column(table, col_name)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,222 +0,0 @@
# -*- coding: utf-8 -*-
"""099: AI 模型路由层 seed — 补齐缺失模型和能力配置.
幂等:所有 INSERT 先检查存在性。
- ai_models: 补齐 qwen3.7-plus, seedream, seedance, embedding, wan3.0 等
- ai_capability_configs: 补齐 image_generation, video_generation, embedding
- 更新已有 capability 的 lite_model_id
"""
import json
import sqlalchemy as sa
from alembic import op
revision = "099_ai_model_router_seed"
down_revision = "098_viral_video_image_analysis_v5"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# CI 环境下 ai_models 表可能尚未创建(由 ORM 自动建表,非 migration)
# 如果表不存在则跳过 seed,由应用启动时 ORM 建表后首次访问时生效
table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
if not table_check:
# ai_models 表不存在,跳过所有 seed(CI 环境)
return
# ── 1. 补齐 ai_models 缺失记录 ────────────────────────────────────────────
existing_models = {
row[0]
for row in conn.execute(
sa.text("SELECT model_key FROM ai_models WHERE deleted_at IS NULL")
).fetchall()
}
# 从已有 active 记录获取 API key(复用,不硬编码)
dashscope_key_row = conn.execute(
sa.text(
"SELECT api_key FROM ai_models WHERE provider='dashscope' AND deleted_at IS NULL AND api_key IS NOT NULL AND api_key != '' LIMIT 1"
)
).first()
dashscope_key = dashscope_key_row[0] if dashscope_key_row else ""
volcengine_key_row = conn.execute(
sa.text(
"SELECT api_key FROM ai_models WHERE provider='volcengine' AND deleted_at IS NULL AND api_key IS NOT NULL AND api_key != '' LIMIT 1"
)
).first()
volcengine_key = volcengine_key_row[0] if volcengine_key_row else ""
new_models = [
{
"model_key": "qwen3.7-plus",
"name": "通义千问3.7 Plus(VLM 兜底)",
"provider": "dashscope",
"api_key": dashscope_key,
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"description": "阿里云百炼 Qwen3.7 Plus 多模态模型,用于 VLM 兜底分析",
},
{
"model_key": "doubao-seedream-5-0-flash-260915",
"name": "Seedream 5.0 Flash(图片生成)",
"provider": "volcengine",
"api_key": volcengine_key,
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
"description": "火山引擎 Seedream 5.0 Flash 文生图模型",
},
{
"model_key": "doubao-seedance-2-5-260628",
"name": "Seedance 2.5(视频生成)",
"provider": "volcengine",
"api_key": volcengine_key,
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
"description": "火山引擎 Seedance 2.5 图/文生视频模型",
},
{
"model_key": "doubao-embedding-vision-251215",
"name": "豆包多模态向量嵌入",
"provider": "volcengine",
"api_key": volcengine_key,
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
"description": "火山引擎豆包多模态向量嵌入模型",
},
{
"model_key": "wan3.0-video",
"name": "Wan 3.0 视频生成",
"provider": "dashscope",
"api_key": dashscope_key,
"api_base": "https://dashscope.aliyuncs.com/api/v1",
"description": "阿里云百炼 Wan 3.0 视频生成模型",
},
{
"model_key": "doubao-seed-2-1-pro-260915",
"name": "豆包 Seed 2.1 Pro(高精度推理)",
"provider": "volcengine",
"api_key": volcengine_key,
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
"description": "火山引擎豆包 Seed 2.1 Pro 深度思考+多模态",
},
]
for m in new_models:
if m["model_key"] not in existing_models:
conn.execute(
sa.text(
"""
INSERT INTO ai_models (id, name, provider, model_key, api_key, api_base, description, status, is_default, usage_today, created_at, updated_at)
VALUES (gen_random_uuid()::text, :name, :provider, :model_key, :api_key, :api_base, :description, 'active', false, 0, now(), now())
"""
),
m,
)
# ── 2. 补齐 ai_capability_configs 缺失项 ──────────────────────────────────
cap_table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
if not cap_table_check:
return
existing_caps = {
row[0]
for row in conn.execute(
sa.text("SELECT capability_key FROM ai_capability_configs")
).fetchall()
}
def _get_model_id(model_key: str) -> str | None:
row = conn.execute(
sa.text(
"SELECT id FROM ai_models WHERE model_key = :key AND deleted_at IS NULL AND status = 'active' LIMIT 1"
),
{"key": model_key},
).first()
return row[0] if row else None
# image_generation
if "image_generation" not in existing_caps:
mid = _get_model_id("doubao-seedream-5-0-flash-260915")
if mid:
conn.execute(
sa.text(
"""
INSERT INTO ai_capability_configs (id, capability_key, capability_name, primary_model_id, timeout_seconds, max_retries, concurrency, extra_params, is_enabled, created_at, updated_at)
VALUES (gen_random_uuid()::text, :ck, :cn, :pm, 60, 1, 2, :ep, true, now(), now())
"""
),
{
"ck": "image_generation",
"cn": "图片生成(Seedream)",
"pm": mid,
"ep": json.dumps({"size": "1K"}),
},
)
# video_generation
if "video_generation" not in existing_caps:
mid = _get_model_id("doubao-seedance-2-5-260628")
fb_mid = _get_model_id("wan3.0-video")
if mid:
conn.execute(
sa.text(
"""
INSERT INTO ai_capability_configs (id, capability_key, capability_name, primary_model_id, fallback_model_id, timeout_seconds, max_retries, concurrency, extra_params, is_enabled, created_at, updated_at)
VALUES (gen_random_uuid()::text, :ck, :cn, :pm, :fm, 600, 1, 1, :ep, true, now(), now())
"""
),
{
"ck": "video_generation",
"cn": "视频生成(Seedance/Wan)",
"pm": mid,
"fm": fb_mid,
"ep": json.dumps({}),
},
)
# embedding
if "embedding" not in existing_caps:
mid = _get_model_id("doubao-embedding-vision-251215")
if mid:
conn.execute(
sa.text(
"""
INSERT INTO ai_capability_configs (id, capability_key, capability_name, primary_model_id, timeout_seconds, max_retries, concurrency, extra_params, is_enabled, created_at, updated_at)
VALUES (gen_random_uuid()::text, :ck, :cn, :pm, 30, 2, 5, :ep, true, now(), now())
"""
),
{
"ck": "embedding",
"cn": "向量嵌入",
"pm": mid,
"ep": json.dumps({}),
},
)
# ── 3. 更新 image_analysis 的 lite_model_id ─────────────────────────────
lite_model_id = _get_model_id("qwen3.8-flash")
if lite_model_id:
conn.execute(
sa.text(
"UPDATE ai_capability_configs SET lite_model_id = :lite WHERE capability_key = 'image_analysis' AND lite_model_id IS NULL"
),
{"lite": lite_model_id},
)
def downgrade() -> None:
conn = op.get_bind()
# 安全检查表是否存在
table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
if not table_check:
return
conn.execute(
sa.text("DELETE FROM ai_capability_configs WHERE capability_key IN ('image_generation', 'video_generation', 'embedding')")
)
conn.execute(
sa.text(
"DELETE FROM ai_models WHERE model_key IN ('qwen3.7-plus', 'doubao-seedream-5-0-flash-260915', 'doubao-seedance-2-5-260628', 'doubao-embedding-vision-251215', 'wan3.0-video', 'doubao-seed-2-1-pro-260915') AND deleted_at IS NULL"
)
)
@@ -1,107 +0,0 @@
# -*- coding: utf-8 -*-
"""100: 修正已有 capability 的模型绑定.
幂等:仅当 primary_model_id 当前绑定到旧模型 (doubao-seed-1-6) 时才更新,
避免覆盖用户在后台的自定义配置。
- 更新 5 个 LLM capability (intent_parsing, copy_fusion, storyboard, copy_review, asset_classify)
的 primary_model_id 从 doubao-seed-1-6 改为 doubao-seed-2-1-pro-260915
- 更新 image_analysis 的 primary/lite/fallback 模型绑定
"""
import sqlalchemy as sa
from alembic import op
revision = "100_fix_capability_model_bindings"
down_revision = "099_ai_model_router_seed"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Check tables exist
table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
if not table_check:
return
config_table_check = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
if not config_table_check:
return
# Look up model IDs by model_key (not hardcoded UUIDs)
pro_model_row = conn.execute(
sa.text(
"SELECT id FROM ai_models WHERE model_key = 'doubao-seed-2-1-pro-260915' AND deleted_at IS NULL LIMIT 1"
)
).first()
if not pro_model_row:
return
pro_model_id = pro_model_row[0]
old_model_row = conn.execute(
sa.text("SELECT id FROM ai_models WHERE model_key = 'doubao-seed-1-6-250615' LIMIT 1")
).first()
old_model_id = old_model_row[0] if old_model_row else None
llm_capabilities = [
"intent_parsing",
"copy_fusion",
"storyboard",
"copy_review",
"asset_classify",
]
for cap_key in llm_capabilities:
if old_model_id:
conn.execute(
sa.text(
"UPDATE ai_capability_configs SET primary_model_id = :new_id, updated_at = NOW() "
"WHERE capability_key = :cap_key AND primary_model_id = :old_id"
),
{"new_id": pro_model_id, "old_id": old_model_id, "cap_key": cap_key},
)
# Update image_analysis
qwen38_row = conn.execute(
sa.text("SELECT id FROM ai_models WHERE model_key = 'qwen3.8-flash' AND deleted_at IS NULL LIMIT 1")
).first()
qwen37_row = conn.execute(
sa.text("SELECT id FROM ai_models WHERE model_key = 'qwen3.7-plus' AND deleted_at IS NULL LIMIT 1")
).first()
if qwen38_row and qwen37_row:
qwen38_id = qwen38_row[0]
qwen37_id = qwen37_row[0]
current_ia = conn.execute(
sa.text(
"SELECT primary_model_id, lite_model_id, fallback_model_id "
"FROM ai_capability_configs WHERE capability_key = 'image_analysis'"
)
).first()
if current_ia:
current_primary, current_lite, current_fallback = current_ia
updates = {}
if current_primary != qwen38_id:
updates["primary_model_id"] = qwen38_id
if current_lite != qwen38_id:
updates["lite_model_id"] = qwen38_id
if current_fallback != qwen37_id:
updates["fallback_model_id"] = qwen37_id
if updates:
set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys()])
set_clause += ", updated_at = NOW()"
updates["cap_key"] = "image_analysis"
conn.execute(
sa.text(f"UPDATE ai_capability_configs SET {set_clause} WHERE capability_key = :cap_key"),
updates,
)
def downgrade() -> None:
pass
@@ -1,153 +0,0 @@
# -*- coding: utf-8 -*-
"""101: 补齐 qwen-vl-plus 视觉模型并修正 image_analysis 绑定与 max_tokens.
背景:
- qwen-vl-plus 做图片识别时返回 JSON 约 500-600 tokens,旧硬编码
max_tokens=350 导致 JSON 被截断、解析失败返回"未识别"。
- 代码侧已移除硬编码,改由 capability 的 DB 配置决定 max_tokens。
幂等:
- qwen-vl-plus 已存在则不插入;
- 仅当 image_analysis 当前 primary_model 不是 qwen-vl-plus 时才更新绑定,
避免覆盖后台手动配置。
"""
import sqlalchemy as sa
from alembic import op
revision = "101_qwen_vl_plus_and_max_tokens"
down_revision = "100_fix_capability_model_bindings"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
models_table = conn.execute(sa.text("SELECT to_regclass('public.ai_models')")).scalar()
if not models_table:
return
caps_table = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
if not caps_table:
return
# ── c. 补全其他 capability 的 max_tokens 默认值(幂等)──────────────────
# 放在 image_analysis 特定逻辑之前,确保任何分支 return 都不会跳过本段。
# 仅在当前值为 NULL 或过小 (<100) 时更新,不覆盖已有合理配置。
# embedding / tts / voice_clone 不走 chat 接口,无需设置。
default_max_tokens = {
"intent_parsing": 500,
"copy_fusion": 2500,
"storyboard": 4000,
"copy_review": 1000,
"asset_classify": 500,
"image_generation": 500,
"video_generation": 500,
}
for cap_key, mt in default_max_tokens.items():
conn.execute(
sa.text(
"UPDATE ai_capability_configs "
"SET max_tokens = :mt, updated_at = now() "
"WHERE capability_key = :key "
"AND (max_tokens IS NULL OR max_tokens < 100)"
),
{"mt": mt, "key": cap_key},
)
# ── a. 确保 qwen-vl-plus 模型存在 ────────────────────────────────────────
conn.execute(sa.text("""
INSERT INTO ai_models (id, name, provider, model_key, api_key, api_base,
description, status, is_default, usage_today,
created_at, updated_at)
SELECT gen_random_uuid()::text,
'通义千问VL Plus',
'dashscope',
'qwen-vl-plus',
COALESCE(
(SELECT api_key FROM ai_models
WHERE provider = 'dashscope' AND deleted_at IS NULL
AND api_key IS NOT NULL AND api_key != ''
LIMIT 1),
''
),
'https://dashscope.aliyuncs.com/compatible-mode/v1',
'阿里云视觉理解模型(图片识别/分析)',
'active', false, 0, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM ai_models
WHERE model_key = 'qwen-vl-plus' AND deleted_at IS NULL
)
"""))
qwen_vl_row = conn.execute(
sa.text(
"SELECT id FROM ai_models WHERE model_key = 'qwen-vl-plus' "
"AND deleted_at IS NULL AND status = 'active' LIMIT 1"
)
).first()
if not qwen_vl_row:
return
qwen_vl_id = qwen_vl_row[0]
qwen37_row = conn.execute(
sa.text(
"SELECT id FROM ai_models WHERE model_key = 'qwen3.7-plus' "
"AND deleted_at IS NULL AND status = 'active' LIMIT 1"
)
).first()
qwen37_id = qwen37_row[0] if qwen37_row else None
# ── b. 仅当当前 primary 不是 qwen-vl-plus 时修正绑定与 max_tokens ───────
current = conn.execute(
sa.text(
"SELECT primary_model_id, lite_model_id, fallback_model_id, max_tokens "
"FROM ai_capability_configs WHERE capability_key = 'image_analysis'"
)
).first()
if current is None:
# capability 不存在则创建
conn.execute(
sa.text("""
INSERT INTO ai_capability_configs
(id, capability_key, capability_name, primary_model_id,
lite_model_id, fallback_model_id, timeout_seconds,
max_retries, max_tokens, concurrency, extra_params,
is_enabled, created_at, updated_at)
VALUES (gen_random_uuid()::text, 'image_analysis', '图片分析',
:primary, :primary, :fallback, 30, 1, 1000, 2,
'{}'::jsonb, true, now(), now())
"""),
{"primary": qwen_vl_id, "fallback": qwen37_id},
)
return
current_primary = current[0]
if current_primary == qwen_vl_id:
# 已经绑定 qwen-vl-plus:视为后台/数据迁移已处理,不覆盖任何配置
return
set_parts = [
"primary_model_id = :vl_id",
"lite_model_id = :vl_id",
"max_tokens = 1000",
"updated_at = now()",
]
params: dict = {"vl_id": qwen_vl_id}
if qwen37_id is not None:
set_parts.insert(2, "fallback_model_id = :qwen37_id")
params["qwen37_id"] = qwen37_id
conn.execute(
sa.text(
"UPDATE ai_capability_configs SET " + ", ".join(set_parts) + " WHERE capability_key = 'image_analysis'"
),
params,
)
def downgrade() -> None:
pass
@@ -1,36 +0,0 @@
# -*- coding: utf-8 -*-
"""102: image_analysis max_tokens 1200 -> 1500.
v6 prompt 更长、字段更多,旧 max_tokens 容易截断 JSON。
仅在 image_analysis 当前 max_tokens < 1500 时更新(幂等,不覆盖后台已调到 >=1500 的配置)。
"""
import sqlalchemy as sa
from alembic import op
revision = "102_image_analysis_max_tokens_1500"
down_revision = "101_qwen_vl_plus_and_max_tokens"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
caps_table = conn.execute(sa.text("SELECT to_regclass('public.ai_capability_configs')")).scalar()
if not caps_table:
return
conn.execute(
sa.text(
"UPDATE ai_capability_configs "
"SET max_tokens = 1500, updated_at = now() "
"WHERE capability_key = 'image_analysis' "
"AND (max_tokens IS NULL OR max_tokens < 1500)"
)
)
def downgrade() -> None:
pass
@@ -1,194 +0,0 @@
# -*- coding: utf-8 -*-
"""image_analysis v7 prompt + max_tokens 3000 + max_retries 3
Revision ID: 103_v7_prompt_and_tokens_3000
Revises: 102_image_analysis_max_tokens_1500
Create Date: 2026-10-07
变更:
1. 插入v7精简prompt(~1KB,v6 ~4.5KB,删除few-shot/冗长规则,减少输出token占用),设为active
2. v6停用(is_active=False),保留历史
3. image_analysis capability: max_tokens 1500→3000,max_retries 1→3
ai_capability_configs 由应用 create_all 创建,全新 alembic-only 库可能不存在,
故第3步做 to_regclass 守卫(同 102)。
"""
from sqlalchemy import text
from alembic import op
revision = "103_v7_prompt_and_tokens_3000"
down_revision = "102_image_analysis_max_tokens_1500"
branch_labels = None
depends_on = None
V7_SYSTEM = """# 角色
你是一位专业的图片分析师,擅长准确识别图片中的场景、人物、物体、文字、氛围。
# 任务
对用户上传的图片逐张分析,描述你看到的内容,输出JSON格式。
## 技能
### 技能1:判断图片类型
判断图片属于哪种类型,type字段填对应的英文值:
- 商品图(product):单个或多个商品、产品包装
- 门店场景图(store):店铺内部、门头招牌、货架陈列
- 人物图(person):人物形象、穿搭造型、肖像照片
- 风景图(scene):风景、动物、美食、街景
- 其他(other):以上都不是
### 技能2:描述通用信息
不管什么图都要描述:
- type:图片类型,填product/store/person/scene/other其中一个
- scene:一句话描述场景,例如"理疗养生店内部,摆着多张理疗床和产品货架"
- mood:整体氛围,2-4个词,例如"整洁专业"、"热闹温馨"
- colors:主要颜色,最多5个,写具体颜色名(亮红色/米白色/深蓝色,不写笼统的红色蓝色)
- visible_text:图片里看到的文字,说明什么字、在什么位置,最多5条;没看到就空数组
- lighting:光线情况,例如"明亮柔光"、"自然光"、"室内暖黄灯"
- composition:怎么拍的,例如"居中特写"、"中景平视"、"俯拍"
- has_person:有没有人,true或false
### 技能3:描述门店场景
如果是门店场景图(type="store"),还要描述:
- store_type:什么类型的店,例如"养生馆"、"便利店"、"餐饮店"、"母婴店"
- brand_signage:招牌上写了什么字、有什么品牌标识
- visual_elements:看到哪些显眼的东西(招牌样式、灯光、货架、商品陈列、海报、收银台等),最多8个
- product_categories:看到哪些品类的商品,例如"饮料零食"、"养生产品"
- promotion_elements:有没有促销活动(打折海报、满减吊旗等),没有就空数组
- atmosphere:店内什么氛围,例如"亲民生活化"、"老字号专业感"
- cleanliness:店内干净程度,例如"干净整洁"、"货架整齐"
- 看到顾客或店员要描述他们在做什么,has_person填true
### 技能4:描述商品
如果是商品图(type="product"),逐个商品描述:
- product_name:商品名称,尽量具体,例如"OMO奥妙除菌除螨洗衣液";看不出来填null
- brand:什么牌子,看不出来填null
- category:类目,从以下选一个:服饰鞋包/美妆/数码/食品/家居清洁/母婴/配饰/其他
- package_type:什么包装,例如"瓶装"、"盒装"、"罐装"、"袋装"、"多瓶装"
- package_color:包装主要颜色,写具体色(亮红色不写红色)
- body_shape:瓶身或包装形状,例如"圆润胖瓶"、"竖款带把手瓶身"
- label_design:标签设计,例如"红色标签印白色品牌logo"
- key_text_on_package:包装上最显眼的文字(品牌名、功能词、卖点词),最多5个
- product_features:包装特征,3-6个短语,包含颜色、瓶盖、形状、标签图案
- key_selling_points:核心卖点,1-3个短语
### 技能5:描述人物
如果是人物图(type="person"),描述:
- person_count:几个人
- gender:性别(男/女/无法判断)
- age_range:年龄段(儿童/青少年/青年/中年/老年/无法判断)
- outfit_style:穿搭风格,例如"休闲日常"、"通勤商务"、"街头潮流"
- upper_wear:上装(颜色+款式+材质),穿裙装不填
- lower_wear:下装(颜色+款式+版型),穿裙装不填
- dress_wear:裙装描述,穿上下装不填
- outerwear:外套
- shoes:鞋子
- bag:包袋,没有填null
- accessories:配饰(眼镜/帽子/项链/耳环/手表/手链/围巾/腰带等),没有填空数组
- hairstyle:发型
- makeup:妆容,男生或看不出填null
- expression:表情,例如"微笑看镜头"、"冷酷无表情"
- pose:姿势动作,例如"身直立正对镜头"、"单手撩发"
- body_type:身材,例如"纤细苗条"、"高挑身材"、"丰满匀称"
- portrait_prompt:80-150字详细描述人物形象(后面用来AI生成肖像图),要写清年龄段、穿搭完整细节、发型发色、妆容、表情、姿势、场景、光线、风格感觉,语言要有画面感
### 技能6:描述风景
如果是风景图(type="scene"),描述:
- scene_type:什么场景,例如"自然风景"、"城市街景"、"动物"、"美食"
- main_subject:画面主体是什么
- key_elements:关键元素,最多8个
- environment_objects:周围环境物体,最多8个
- atmosphere:整体氛围,例如"秋日慵懒氛围感"、"清新自然氧气感"
- 有人物就描述人物特征
## 限制
- 只输出JSON,不要任何解释文字,不要markdown代码块包裹,不要写"好的""以下是分析结果"这种废话
- 颜色写具体色调(亮红色/米白色/深蓝色/翠绿色),不写笼统词汇
- 瓶身、包装、招牌上的文字尽量识别出来(品牌名、功能词、卖点词)
- 多个商品、多个人物分开描述,不要合并
- 看不出来、不确定的字段填null或空数组,布尔值填true/false,绝对不要瞎编
- 确保JSON格式合法,所有大括号、中括号、引号正确闭合
- 数组字段控制数量:colors最多5个,visible_text最多5条,visual_elements最多8个,accessories最多10个"""
V7_USER = "请分析这张图片,按系统消息的JSON结构输出。"
def _capability_table_exists(bind) -> bool:
return bool(bind.execute(text("SELECT to_regclass('public.ai_capability_configs')")).scalar())
def upgrade() -> None:
bind = op.get_bind()
# 1. 停用旧的active image_analysis prompt(含v6)
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type = 'image_analysis' AND is_active = TRUE"
)
)
# 2. 幂等插入v7(存在则更新并重新激活)
existing = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 7")
).fetchone()
if existing:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
"system_prompt = :sys, user_prompt_template = :usr, "
"name = 'v7 精简结构化分析', updated_at = NOW() "
"WHERE prompt_type = 'image_analysis' AND version = 7"
),
{"sys": V7_SYSTEM, "usr": V7_USER},
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"is_active, created_at, updated_at) "
"VALUES ('image_analysis', 7, 'v7 精简结构化分析', "
":sys, :usr, TRUE, NOW(), NOW())"
),
{"sys": V7_SYSTEM, "usr": V7_USER},
)
# 3. capability max_tokens=3000、max_retries=3(表不存在则跳过)
if _capability_table_exists(bind):
bind.execute(
text(
"UPDATE ai_capability_configs SET max_tokens = 3000, "
"updated_at = NOW() "
"WHERE capability_key = 'image_analysis' AND "
"(max_tokens IS NULL OR max_tokens < 3000)"
)
)
bind.execute(
text(
"UPDATE ai_capability_configs SET max_retries = 3, updated_at = NOW() "
"WHERE capability_key = 'image_analysis' AND "
"(max_retries IS NULL OR max_retries < 3)"
)
)
def downgrade() -> None:
bind = op.get_bind()
# 删除v7
bind.execute(
text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 7")
)
# 恢复v6为active
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
"WHERE prompt_type = 'image_analysis' AND version = 6"
)
)
# tokens/retries回退
if _capability_table_exists(bind):
bind.execute(
text(
"UPDATE ai_capability_configs SET max_tokens = 1500, max_retries = 1, "
"updated_at = NOW() WHERE capability_key = 'image_analysis'"
)
)
@@ -1,242 +0,0 @@
# -*- coding: utf-8 -*-
"""image_analysis v8 prompt + storyboard v3 prompt - 用户端展示格式 markdown 控制
Revision ID: 104_v8_display_markdown
Revises: 103_v7_prompt_and_tokens_3000
Create Date: 2026-10-07
变更:
1. image_analysis v8: 在 v7 基础上 system_prompt 末尾追加「## 用户端展示格式」章节,
要求 VLM 在每张图的 JSON 里输出 summary_markdown 字段(markdown 格式的图片描述),
v8 设 is_active=true,v7 设 is_active=false。
2. storyboard v3: 在 v2 基础上 system_prompt 追加要求 LLM 在 copy_result 中
输出 copy_display_markdown 字段(markdown 格式的完整文案展示),
v3 设 is_active=true,v2 设 is_active=false。
"""
from sqlalchemy import text
from alembic import op
revision = "104_v8_display_markdown"
down_revision = "103_v7_prompt_and_tokens_3000"
branch_labels = None
depends_on = None
# ── v8 追加的 system prompt 内容 ──────────────────────────────────────
V8_SYSTEM_APPEND = """
## 用户端展示格式
对于每张分析的图片,在 JSON 中额外输出一个 **summary_markdown** 字段,用 markdown 格式写出给用户看的图片描述。
格式要求(根据图片类型自适应):
**商品图(type=product)**示例:
### 商品名称
**品牌**:品牌名 | **类目**:服饰鞋包/美妆/数码/...
**核心特征**
- 特征1:描述
- 特征2:描述
**外观**:颜色+材质+设计描述
**包装**:包装类型描述
**文字信息**:包装上看到的文字
**门店场景图(type=store)**示例:
### 门店名称/类型
**类型**:奶茶店/便利店/养生馆/...
**品牌标识**:招牌文字描述
**环境氛围**:店内整体感觉
**陈列亮点**
- 亮点1
- 亮点2
**氛围**:亲民/专业/时尚/...
**人物图(type=person)**示例:
### 人物描述
**形象**:年龄段 + 风格
**穿搭**
- 上装:颜色+款式
- 下装:颜色+款式
- 配饰:...
**气质**:表情+姿势+整体感觉
**风景/场景图(type=scene)**示例:
### 场景名称
**类型**:自然风景/城市街景/动物/美食
**主体**:画面主要元素
**氛围**:整体感觉描述
要求:
- 内容真实具体,从实际图片分析得出
- 用 markdown 语法:**加粗**、列表、标题
- 控制在 100-200 字
- 不要编造图片中没有的信息
"""
# ── storyboard v3 追加的 system prompt 内容 ──────────────────────────
V3_STORYBOARD_APPEND = """
## 用户端展示格式
在输出分镜脚本的同时,在顶层输出一个 **copy_display_markdown** 字段(用 XML 标签 <copy_display_markdown> 包裹),用 markdown 格式写出完整文案展示。
格式示例:
# 标题/主题
## 整体概要
一句话描述视频内容
## 分镜预览
### 镜头1(0-3秒)
**景别**:近景俯拍,缓慢推镜
**画面**:场景描述
**台词**:口播文本
**动作**:人物动作描述
### 镜头2(3-9秒)
...
## 完整口播
完整口播文案文本
要求:
- 把所有分镜按时间顺序整理成易读的格式
- 用 markdown 语法组织,**加粗**标签、##二级标题、列表等
- 控制在 300-500 字
- 让用户一眼看懂视频会拍成什么样
"""
def upgrade() -> None:
bind = op.get_bind()
# ── 1. image_analysis v8 ──────────────────────────────────────────
# 停用所有 active image_analysis prompt
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type = 'image_analysis' AND is_active = TRUE"
)
)
# 读取 v7 的 prompt 内容作为基础
v7_row = bind.execute(
text(
"SELECT system_prompt, user_prompt_template, COALESCE(example_output, '') "
"FROM viral_video_prompt_templates "
"WHERE prompt_type = 'image_analysis' "
"ORDER BY version DESC LIMIT 1"
)
).fetchone()
if v7_row:
v7_system = v7_row[0] or ""
v8_system = v7_system + V8_SYSTEM_APPEND
v8_user = v7_row[1] or "{image_url}"
v8_example = v7_row[2] or ""
# 幂等:已有 v8 则更新,否则插入
existing_v8 = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 8")
).fetchone()
if existing_v8:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
"system_prompt = :sys, user_prompt_template = :usr, "
"example_output = :ex, name = 'v8 用户端展示格式', "
"updated_at = NOW() "
"WHERE prompt_type = 'image_analysis' AND version = 8"
),
{"sys": v8_system, "usr": v8_user, "ex": v8_example},
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"example_output, is_active, created_at, updated_at) "
"VALUES ('image_analysis', 8, 'v8 用户端展示格式', "
":sys, :usr, :ex, TRUE, NOW(), NOW())"
),
{"sys": v8_system, "usr": v8_user, "ex": v8_example},
)
# ── 2. storyboard v3 ─────────────────────────────────────────────
# 停用所有 active storyboard prompt
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type = 'storyboard' AND is_active = TRUE"
)
)
# 读取当前 storyboard prompt
sb_row = bind.execute(
text(
"SELECT system_prompt, user_prompt_template, COALESCE(example_output, '') "
"FROM viral_video_prompt_templates "
"WHERE prompt_type = 'storyboard' "
"ORDER BY version DESC LIMIT 1"
)
).fetchone()
if sb_row:
sb_system = sb_row[0] or ""
v3_system = sb_system + V3_STORYBOARD_APPEND
v3_user = sb_row[1] or ""
v3_example = sb_row[2] or ""
existing_v3 = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = 'storyboard' AND version = 3")
).fetchone()
if existing_v3:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, "
"system_prompt = :sys, user_prompt_template = :usr, "
"example_output = :ex, name = 'v3 用户端展示格式', "
"updated_at = NOW() "
"WHERE prompt_type = 'storyboard' AND version = 3"
),
{"sys": v3_system, "usr": v3_user, "ex": v3_example},
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"example_output, is_active, created_at, updated_at) "
"VALUES ('storyboard', 3, 'v3 用户端展示格式', "
":sys, :usr, :ex, TRUE, NOW(), NOW())"
),
{"sys": v3_system, "usr": v3_user, "ex": v3_example},
)
def downgrade() -> None:
bind = op.get_bind()
# 删除 v8
bind.execute(
text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'image_analysis' AND version = 8")
)
# 恢复 v7 active
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, updated_at = NOW() "
"WHERE prompt_type = 'image_analysis' AND version = 7"
)
)
# 删除 v3
bind.execute(text("DELETE FROM viral_video_prompt_templates " "WHERE prompt_type = 'storyboard' AND version = 3"))
# 恢复 storyboard v2 active
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE, updated_at = NOW() "
"WHERE prompt_type = 'storyboard' AND version = 2"
)
)
-123
View File
@@ -1,123 +0,0 @@
# -*- coding: utf-8 -*-
"""image_analysis v8 + storyboard v3 叙述优先重写版(架构大简化)
Revision ID: 105_narration_first
Revises: 104_v8_display_markdown
Create Date: 2026-10-07
变更:
1. image_analysis v8:用「叙述优先」版整体替换 104 的 append 版——VLM 主交付物是
自然叙述 summary_markdown,结构化字段仅保留 type/name/brand/has_person,
顶层 products 改名 images;v8 active,其余 image_analysis 全部 deactivate。
2. storyboard v3:整体替换为风格重写版(口播口语化、画面有画面感、
copy_display_markdown 流畅叙述);v3 active,其余 storyboard deactivate。
3. intent_parsing 类型模板全部 deactivate(意图解析步骤已删除)。
模板内容直接取自 packages.application.viral_video.prompts.DEFAULT_TEMPLATES,
保证代码默认值与 DB seed 完全一致。
"""
from sqlalchemy import text
from alembic import op
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
revision = "105_narration_first"
down_revision = "104_v8_display_markdown"
branch_labels = None
depends_on = None
def _tpl(prompt_type: str, version: int) -> dict:
"""取模板:先按指定版本找,找不到则取该类型最新版本(兼容 v3→v4 升级)。"""
# 先按指定版本找
for t in DEFAULT_TEMPLATES:
if t["prompt_type"] == prompt_type and t["version"] == version:
return t
# 找不到则取最新版本
candidates = [t for t in DEFAULT_TEMPLATES if t["prompt_type"] == prompt_type]
if candidates:
latest = max(candidates, key=lambda x: x["version"])
return latest
raise RuntimeError("default template missing: %s" % prompt_type)
def _upsert(bind, t: dict) -> None:
existing = bind.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = :pt AND version = :ver"),
{"pt": t["prompt_type"], "ver": t["version"]},
).fetchone()
params = {
"pt": t["prompt_type"],
"ver": t["version"],
"name": t["name"],
"sys": t["system_prompt"],
"usr": t["user_prompt_template"],
"ex": t.get("example_output", "") or "",
}
if existing:
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET name = :name, "
"system_prompt = :sys, user_prompt_template = :usr, "
"example_output = :ex, is_active = TRUE, updated_at = NOW() "
"WHERE prompt_type = :pt AND version = :ver"
),
params,
)
else:
bind.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(prompt_type, version, name, system_prompt, user_prompt_template, "
"example_output, is_active, created_at, updated_at) "
"VALUES (:pt, :ver, :name, :sys, :usr, :ex, TRUE, NOW(), NOW())"
),
params,
)
def upgrade() -> None:
bind = op.get_bind()
# 1. image_analysis:停用全部后写入叙述优先 v8
bind.execute(
text("UPDATE viral_video_prompt_templates SET is_active = FALSE " "WHERE prompt_type = 'image_analysis'")
)
_upsert(bind, _tpl("image_analysis", 8))
# 2. storyboard:停用全部后写入重写版 v3
bind.execute(text("UPDATE viral_video_prompt_templates SET is_active = FALSE " "WHERE prompt_type = 'storyboard'"))
_upsert(bind, _tpl("storyboard", 3))
# 3. intent_parsing 已废弃:全部停用
bind.execute(
text("UPDATE viral_video_prompt_templates SET is_active = FALSE " "WHERE prompt_type = 'intent_parsing'")
)
# 4. review 模板确保 active
bind.execute(text("UPDATE viral_video_prompt_templates SET is_active = TRUE " "WHERE prompt_type = 'review'"))
def downgrade() -> None:
bind = op.get_bind()
# 恢复 104 的 v8/v3 无法重建(内容已替换),仅把版本 active 状态回退:
# 停用新版,尝试恢复 v7 / v2
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = FALSE "
"WHERE prompt_type IN ('image_analysis','storyboard') "
"AND version IN (8, 3)"
)
)
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
"WHERE prompt_type = 'image_analysis' AND version = 7"
)
)
bind.execute(
text(
"UPDATE viral_video_prompt_templates SET is_active = TRUE "
"WHERE prompt_type = 'storyboard' AND version = 2"
)
)
-43
View File
@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
"""system_settings 正式建表(#2246)
Revision ID: 106_system_settings
Revises: 105_narration_first
Create Date: 2026-10-08
system_settings 表此前在 staging 手工创建(对应 034 占位迁移),
此处补正式迁移保证其他环境一致。CREATE TABLE/INDEX 使用 IF NOT EXISTS,
对已手工建表的环境幂等。
"""
from alembic import op
revision = "106_system_settings"
down_revision = "105_narration_first"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS system_settings (
id VARCHAR(36) NOT NULL,
setting_key VARCHAR(100) NOT NULL,
setting_value TEXT,
setting_type VARCHAR(20) NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '',
is_public BOOLEAN NOT NULL DEFAULT FALSE,
updated_by VARCHAR(36),
category VARCHAR(50) NOT NULL DEFAULT 'general',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT pk_system_settings PRIMARY KEY (id),
CONSTRAINT uq_system_settings_setting_key UNIQUE (setting_key)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_system_settings_category " "ON system_settings (category)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_system_settings_category")
op.execute("DROP TABLE IF EXISTS system_settings")
@@ -1,23 +0,0 @@
"""add language column to viral_video_jobs
Revision ID: 107
Revises: 106_system_settings
Create Date: 2026-10-09
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "107"
down_revision = "106_system_settings"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE viral_video_jobs " "ADD COLUMN IF NOT EXISTS language VARCHAR(20) NOT NULL DEFAULT 'zh-CN'")
def downgrade() -> None:
op.execute("ALTER TABLE viral_video_jobs DROP COLUMN IF EXISTS language")
@@ -1,235 +0,0 @@
# -*- coding: utf-8 -*-
"""storyboard v4 - 多角色对话 + 废除旁白 + visual 5要素
Revision ID: 108_storyboard_v4_multivoice
Revises: 107
Create Date: 2026-10-10
变更:
1. storyboard v4: 废除旁白思维,所有voiceover必须是角色台词
- 增加<speaker>标签,每镜必须标注说话人
- visual强制5要素结构(景别/运镜/动作/环境/光线),每镜不少于30字
- voiceover_script用[speaker:xxx]标记格式
2. 旧版storyboard模板is_active设为false
3. 检查image_analysis和review是否有active模板,没有则插入保底版本
"""
from sqlalchemy import text
from alembic import op
revision = "108_storyboard_v4_multivoice"
down_revision = "107"
branch_labels = None
depends_on = None
# ── storyboard v4 system_prompt ──────────────────────────────────────
V4_STORYBOARD_SYSTEM = """你是一名懂短视频的编导和口播文案高手。你会拿到图片的真实观察、营销目的和用户参数,请一次性完成对营销意图的理解,并产出可直接拍摄/生成的分镜脚本。不要单独输出"意图解析",意图要直接体现在台词和分镜里。
## 核心设计原则(必须严格遵守)
1. **废除旁白思维**:所有视频类型——无论对话短剧/口播带货/获客引流/品牌故事——voiceover 必须是人物说的话(第一人称或角色对白),绝对不能出现第三人称旁白解说。观众看的是人在演、在说。
2. **严禁第三人称解说性台词**:如"接下来展示...""这款产品..."这类上帝视角描述禁止出现在 voiceover 中。
3. **短剧类营销目的**(对话短剧/反转短剧/悬念短剧/情绪短片):双角色对话格式"甲:xxx 乙:xxx",镜头在角色间切换。
4. **口播类**(口播带货/促销转化/功能演示/痛点解决/获客引流/账号涨粉/活动通知/场景种草):第一人称对镜头说话,像真人出镜。
## 输出格式(XML,严格按结构输出,不要输出额外解释)
<script>
<copy_display_markdown><![CDATA[直接展示给用户看的成片文案,用 Markdown 写成流畅叙述]]></copy_display_markdown>
<clips>
<clip index="1">
<time_range>0-3秒</time_range>
<speaker>说话人标识(如"店主""顾客""主播")</speaker>
<voiceover>这一镜的角色台词(人物说的话,不是旁白)</voiceover>
<visual>【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】5要素结构,不少于30字</visual>
<reference_image_index>0</reference_image_index>
</clip>
</clips>
<voiceover_script>把所有 clip 的 voiceover 连成完整台词稿,用 [speaker:xxx] 标记每个说话段落</voiceover_script>
<theme>一句话主题</theme>
<negative>【反套路化要求】
禁止使用"家人们谁懂啊""绝绝子""宝子们""家人们""太绝了""yyds"等烂大街网络词;
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。</negative>
</script>
## 写作要求
1. **台词(voiceover)**:像真人面对镜头说话或角色对白,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。严禁第三人称解说。
2. **说话人(speaker)**:每个 clip 必须标注说话人标识,如"店主""顾客""主播""我"等。短剧类必须有至少 2 个不同角色。
3. **画面描述(visual)**:强制 5 要素结构——【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】,每镜 visual 不少于 30 字,要具体到"闭眼听台词能想象出画面"。
4. **voiceover_script 格式**:用 [speaker:xxx] 标记每个说话段落,如"[speaker:店主]你是不是也觉得...[speaker:顾客]是啊,怎么回事?"
5. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案。
6. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。
7. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。
8. 分镜数量与时长匹配总时长,节奏紧凑。
9. **口播字数硬约束**(必须严格遵守):按每秒约 2.5~3 个中文字(正常口播语速)计算:
- 5秒视频:voiceover_script 总字数 12~15 字
- 10秒视频:voiceover_script 总字数 25~30 字
- 15秒视频:voiceover_script 总字数 35~45 字
- 20秒视频:voiceover_script 总字数 50~60 字
- 30秒视频:voiceover_script 总字数 75~90 字
- 宁可少写也不要多写,超长会导致 TTS 音频超出视频时长限制
10. **镜头数量硬约束**:5秒1~2镜、10秒3镜、15秒3~4镜、20秒4~5镜、30秒6~8镜
11. **时间轴硬约束**:第一个clip从0秒开始,最后一个clip结束于total_duration秒,相邻clip首尾相接
12. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜
13. 镜头间动作衔接要自然,画面描述要具体到能直接拍摄/生成"""
V4_STORYBOARD_USER = """<marketing_purpose>{marketing_purpose}</marketing_purpose>
<industry>{industry}</industry>
<image_analysis>
{image_summary}
</image_analysis>
<user_parameters>
<theme_hint>{theme_hint}</theme_hint>
<duration>{duration}秒</duration>
<aspect_ratio>{aspect_ratio}</aspect_ratio>
<tone>{tone}</tone>
<target_audience>{target_audience}</target_audience>
<persona>{persona_hint}</persona>
<viral_structure>{viral_structure_hint}</viral_structure>
<language>{language_hint}</language>
<extra_requirements>{extra_requirements}</extra_requirements>
</user_parameters>
{video_style_section}
请严格按 XML 结构输出分镜脚本。"""
V4_STORYBOARD_EXAMPLE = """<script>
<copy_display_markdown><![CDATA[# 在御众堂,把松弛的自己一点点找回来
产后妈妈最懂那种力不从心,推开门,暖光和一杯热茶先接住了你……]]></copy_display_markdown>
<clips>
<clip index="1">
<time_range>0-3秒</time_range>
<speaker>店主</speaker>
<voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>
<visual>【中近景】【缓推】【妈妈疲惫看向镜子】【暖光店内环境】【柔和暖光】</visual>
<reference_image_index>0</reference_image_index>
</clip>
<clip index="2">
<time_range>3-6秒</time_range>
<speaker>顾客</speaker>
<voiceover>是啊,怎么回事?</voiceover>
<visual>【近景】【固定】【顾客表情惊讶】【店内休息区】【暖色调】</visual>
<reference_image_index>0</reference_image_index>
</clip>
</clips>
<voiceover_script>[speaker:店主]生完娃,是不是连照镜子的勇气都没了?[speaker:顾客]是啊,怎么回事?</voiceover_script>
<theme>产后妈妈走进御众堂重拾状态</theme>
<negative>模糊、畸变、夸大疗效、绝对化用语</negative>
</script>"""
def upgrade() -> None:
conn = op.get_bind()
# 1. 查询 storyboard 当前最大 version
result = conn.execute(
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
{"pt": "storyboard"},
)
max_version = result.scalar() or 0
new_version = max_version + 1
# 2. 旧版 storyboard 模板 is_active 设为 false
conn.execute(
text("UPDATE viral_video_prompt_templates SET is_active = false WHERE prompt_type = :pt"),
{"pt": "storyboard"},
)
# 3. 防御性插入新版 storyboard 模板
existing = conn.execute(
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = :pt AND version = :ver"),
{"pt": "storyboard", "ver": new_version},
).fetchone()
if not existing:
conn.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
),
{
"name": "编导分镜v4-多角色对话版",
"pt": "storyboard",
"ver": new_version,
"sys": V4_STORYBOARD_SYSTEM,
"usr": V4_STORYBOARD_USER,
"ex": V4_STORYBOARD_EXAMPLE,
"active": True,
},
)
# 4. 检查 image_analysis 是否有 active 模板,没有则插入保底
ia_active = conn.execute(
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
{"pt": "image_analysis"},
).scalar()
if ia_active == 0:
# 插入保底 image_analysis 模板(简化版)
ia_max = (
conn.execute(
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
{"pt": "image_analysis"},
).scalar()
or 0
)
conn.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
),
{
"name": "图片分析保底版",
"pt": "image_analysis",
"ver": ia_max + 1,
"sys": "你是一名擅长观察和写作的品牌内容编导。分析图片并输出JSON。",
"usr": "请分析这张图片。图片地址:{image_url}",
"ex": '{"images": [{"type": "store", "name": "门店", "summary_markdown": "描述"}]}',
"active": True,
},
)
# 5. 检查 review 是否有 active 模板
review_active = conn.execute(
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
{"pt": "review"},
).scalar()
if review_active == 0:
rv_max = (
conn.execute(
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
{"pt": "review"},
).scalar()
or 0
)
conn.execute(
text(
"INSERT INTO viral_video_prompt_templates "
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
),
{
"name": "文案审核保底版",
"pt": "review",
"ver": rv_max + 1,
"sys": "你是短视频广告合规审核专家。审核文案输出XML。",
"usr": "请审核:{fusion_text}",
"ex": "<review><passed>true</passed></review>",
"active": True,
},
)
def downgrade() -> None:
# 恢复旧版 storyboard 为 active
conn = op.get_bind()
conn.execute(
text("UPDATE viral_video_prompt_templates SET is_active = true WHERE prompt_type = :pt AND version < :ver"),
{"pt": "storyboard", "ver": 4},
)
# 删除新版
conn.execute(
text("DELETE FROM viral_video_prompt_templates WHERE prompt_type = :pt AND version >= :ver"),
{"pt": "storyboard", "ver": 4},
)
@@ -1,42 +0,0 @@
# -*- coding: utf-8 -*-
"""viral_video_param_guides 表:运营可配置的人设/结构/营销目的/目标客群/语言指导文案(PR3 P2-h)。
Revision ID: 109_add_viral_video_param_guides
Revises: 108_storyboard_v4_multivoice
Create Date: 2026-10-12
表用途:
- 替代 worker 中 ~130 行硬编码 if/elif mapping;
- 5 类 guide_type: persona / structure / marketing_purpose / target_customer / language;
- 启动时由 seed_viral_video_param_guides.py 灌入代码默认值,运营可在后台改,30s TTL 缓存自动热加载。
"""
from alembic import op
revision = "109_add_viral_video_param_guides"
down_revision = "108_storyboard_v4_multivoice"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS viral_video_param_guides (
id SERIAL PRIMARY KEY,
guide_type VARCHAR(32) NOT NULL,
guide_key VARCHAR(64) NOT NULL,
guide_text TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (guide_type, guide_key)
)
""")
op.execute("""
CREATE INDEX IF NOT EXISTS ix_viral_video_param_guides_type_key
ON viral_video_param_guides (guide_type, guide_key)
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_viral_video_param_guides_type_key")
op.execute("DROP TABLE IF EXISTS viral_video_param_guides")
-4
View File
@@ -1,4 +1,3 @@
from app.api.routes.admin.ditto_emotion import router as admin_ditto_emotion_router
from app.api.routes.ai import router as ai_router
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
@@ -243,6 +242,3 @@ api_router.include_router(
tags=["GPU Worker"],
)
api_router.include_router(viral_video_router, prefix="/viral-video", tags=["爆款视频"])
# #2246:后台 Ditto 表情配置(router 自带 /admin/ditto-emotion 前缀)
api_router.include_router(admin_ditto_emotion_router)
@@ -1 +0,0 @@
"""后台管理路由(#2246 起)."""
@@ -1,203 +0,0 @@
"""Ditto 数字人表情后台配置 — #2246.
路由前缀 /api/v1/admin/ditto-emotion,全部使用 _verify_internal_api_key 鉴权
(X-API-Key header)。仅开放 6 项白名单配置:
- ditto_emotion_enabled / ditto_emotion_model / ditto_emotion_temperature
- ditto_emotion_prompt / ditto_blend_frames / doubao_api_key
注意:doubao_api_key 属于密钥,GET 只返回脱敏占位,绝不返回明文。
ditto_emotion_model 为自由文本(支持火山方舟 ep- 接入点 ID),
推荐选项 model_options 从 system_config 的 ditto_emotion_model_options(JSON 数组)读取。
"""
from __future__ import annotations
import json
import re
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from packages.application.system_config_service import get_system_config_service
from packages.config import get_api_settings
from packages.domain.system_setting import (
SETTING_TYPE_BOOL,
SETTING_TYPE_FLOAT,
SETTING_TYPE_INT,
SETTING_TYPE_STRING,
)
from ..auth import _verify_internal_api_key
router = APIRouter(
prefix="/admin/ditto-emotion",
tags=["Admin"],
dependencies=[Depends(_verify_internal_api_key)],
)
# key → (类型, 分类)
_WHITELIST: dict[str, str] = {
"ditto_emotion_enabled": SETTING_TYPE_BOOL,
"ditto_emotion_model": SETTING_TYPE_STRING,
"ditto_emotion_temperature": SETTING_TYPE_FLOAT,
"ditto_emotion_prompt": SETTING_TYPE_STRING,
"ditto_blend_frames": SETTING_TYPE_INT,
"doubao_api_key": SETTING_TYPE_STRING,
}
_DESCRIPTIONS: dict[str, str] = {
"ditto_emotion_enabled": "LLM 情绪分析开关。关闭时回退到原有关键词匹配模式,不影响正常出片。",
"ditto_emotion_model": "用于分析文案情绪的大模型。",
"ditto_emotion_temperature": "模型温度,0-1,越低越稳定保守。",
"ditto_emotion_prompt": "情绪分析提示词,核心调优入口,必须包含 {文案} 占位符。",
"ditto_blend_frames": "表情切换过渡帧数(6-30),越大越柔和。",
"doubao_api_key": "豆包/火山方舟 API Key(用于情绪分析LLM调用)",
}
# 密钥类配置:GET 脱敏、PUT 响应不回显明文
_SECRET_KEYS = {"doubao_api_key"}
_SECRET_PLACEHOLDER = "***已配置***"
def _settings():
return get_api_settings()
def _default_value(key: str) -> Any:
return getattr(_settings(), key)
def _model_options_from_db(service) -> list[str]:
"""模型推荐选项:从 system_config 读 ditto_emotion_model_options(JSON 数组字符串)。
未配置 / 非法 JSON / 非数组 JSON 均返回空数组;数组元素统一转字符串。
"""
raw = service.get_config("ditto_emotion_model_options", "") or ""
if not str(raw).strip():
return []
try:
data = json.loads(raw)
except (TypeError, ValueError):
return []
if not isinstance(data, list):
return []
return [str(x) for x in data if isinstance(x, (str, int, float))]
def _build_config_item(key: str) -> dict[str, Any]:
service = get_system_config_service()
if key in _SECRET_KEYS:
# 密钥不回明文、不暴露 env 默认值:DB 或 env 任一侧已配置即视为已配置
db_value = service.get_config(key, None)
configured = bool((db_value or "").strip()) or bool((getattr(_settings(), key, "") or "").strip())
return {
"key": key,
"type": _WHITELIST[key],
"description": _DESCRIPTIONS.get(key, ""),
"default": "",
"value": _SECRET_PLACEHOLDER if configured else "",
}
item: dict[str, Any] = {
"key": key,
"type": _WHITELIST[key],
"description": _DESCRIPTIONS.get(key, ""),
"default": _default_value(key),
}
item["value"] = service.get_config(key, _default_value(key))
if key == "ditto_emotion_model":
item["model_options"] = _model_options_from_db(service)
return item
class ConfigUpdatePayload(BaseModel):
configs: dict[str, Any]
class TestPayload(BaseModel):
test_text: str
def _validate_value(key: str, value: Any) -> Any:
st = _WHITELIST[key]
if st == SETTING_TYPE_BOOL:
if not isinstance(value, bool):
raise ValueError(f"{key} 必须是布尔值")
elif st == SETTING_TYPE_INT:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{key} 必须是整数")
if not 6 <= value <= 30:
raise ValueError(f"{key} 必须在 6-30 之间")
elif st == SETTING_TYPE_FLOAT:
if isinstance(value, bool):
raise ValueError(f"{key} 必须是数字")
try:
value = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{key} 必须是数字") from exc
if not 0.0 <= value <= 1.0:
raise ValueError(f"{key} 必须在 0-1 之间")
elif st == SETTING_TYPE_STRING:
if not isinstance(value, str):
raise ValueError(f"{key} 必须是字符串")
if key == "ditto_emotion_prompt" and value.strip() and "{文案}" not in value:
raise ValueError("提示词必须包含 {文案} 占位符")
if key == "ditto_emotion_model":
if not value.strip():
raise ValueError(f"{key} 不能为空字符串")
if len(value) > 200:
raise ValueError(f"{key} 长度不能超过 200 字符")
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
raise ValueError(f"{key} 只能包含字母、数字、连字符和下划线")
if key == "doubao_api_key" and not value.strip():
raise ValueError(f"{key} 不能为空字符串")
return value
@router.get("/config")
def get_config() -> dict[str, Any]:
return {"configs": [_build_config_item(k) for k in _WHITELIST]}
@router.put("/config")
def update_config(
payload: ConfigUpdatePayload,
x_api_key: str = Depends(_verify_internal_api_key),
) -> dict[str, Any]:
configs = payload.configs
illegal = [k for k in configs if k not in _WHITELIST]
if illegal:
return {
"ok": False,
"error": f"不允许修改的配置项:{', '.join(illegal)}",
}
service = get_system_config_service()
updated: dict[str, Any] = {}
for key, raw in configs.items():
try:
value = _validate_value(key, raw)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
service.set_config(
key,
value,
setting_type=_WHITELIST[key],
updated_by=str(x_api_key)[:8] if x_api_key and x_api_key is not True else None,
)
updated[key] = _SECRET_PLACEHOLDER if key in _SECRET_KEYS else value
return {"ok": True, "updated": updated}
@router.post("/config/test")
def test_config(payload: TestPayload) -> dict[str, Any]:
text = (payload.test_text or "").strip()
if not text:
return {"ok": False, "error": "test_text 不能为空"}
from packages.application.ditto_emotion_service import get_ditto_emotion_service
service = get_ditto_emotion_service()
segments = service.analyze(text)
return {
"ok": True,
"enabled": service.enabled,
"segments": [s.to_dict() for s in segments],
}
+1 -48
View File
@@ -44,7 +44,6 @@ from packages.application import (
GetGenerationTaskUseCase,
ListGeneratedVideosByTaskUseCase,
)
from packages.domain import feature_pricing_service
from packages.domain.smart_match import smart_select_assets
# #2035:文案关键词 → 素材分类 映射表(用于 smart_match category_match 维度)
@@ -164,6 +163,7 @@ def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
return matched or None
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -700,17 +700,6 @@ def create_generation_task(
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
effective_strategy_id = "one_take"
# ── smart_edit 计费预扣(全局 points 开关 + 功能开关均开才扣) ──
# 首期固定价:dynamic_cost=0,price=(0+fixed_cost)×multiplier,price_cap 封顶。
# 预览任务不扣费;按任务条数扣费,任一任务预扣失败(余额不足)整体拒绝。
smart_edit_charge = 0.0
charged_task_count = 0
if not request.is_preview and feature_pricing_service.is_feature_enabled("smart_edit"):
unit_credits, _bd = feature_pricing_service.calculate_price("smart_edit", 0.0)
if unit_credits > 0:
smart_edit_charge = round(unit_credits * count, 2)
charged_task_count = count
# 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。
# - 变体 0:clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant
# 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用);
@@ -941,42 +930,6 @@ def create_generation_task(
)
# 变体序号写入 extra_meta(响应/排查时可辨识)
task.extra_meta["variant_index"] = task_index
# smart_edit 逐条预扣(首期固定价,credits_cost=prepaid,不做结算)
task_txn_id = ""
if charged_task_count > 0:
from packages.domain.points_service import PointsService
unit_credits = round(smart_edit_charge / count, 2)
res = PointsService().deduct_points(
user_id=user_id,
amount=unit_credits,
source="smart_edit",
db=db,
description="智能剪辑生成预扣",
ref_id=task.id,
)
if not res.get("success"):
# 余额不足:退还本次请求已扣积分后整体拒绝
already_charged = round(unit_credits * task_index, 2)
if already_charged > 0:
PointsService().refund_points(
user_id=user_id,
amount=already_charged,
source="smart_edit",
db=db,
ref_id=task.id,
description="智能剪辑批量提交失败退回",
)
raise HTTPException(
status_code=402,
detail=(f"积分不足:智能剪辑每条需 {unit_credits:.2f} 积分,当前余额 {res.get('balance', 0)}"),
)
task_txn_id = str(res.get("transaction_id") or "")
task.credits_prepaid = unit_credits
task.credits_cost = unit_credits
task.credits_transaction_id = task_txn_id
generation_task_repository.update(task)
try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
-1
View File
@@ -89,7 +89,6 @@ def get_balance(
is_member=_is_member(current_user),
member_type=_member_type(current_user),
member_expires_at=getattr(current_user.user, "member_expires_at", None),
credits_enabled=_credits_enabled(),
)
+212 -276
View File
@@ -2,10 +2,12 @@
v1.6 三步分步流水线端点(单次 Seedance 出片版):
POST /api/v1/viral-video/analyze-images 阶段1:创建任务 + 仅做图片/视频分析,暂停在 image_analyzed
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后文案+分镜一步生成+审核,暂停在 copy_generated
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染(TTS+Seedance+上传),直到完成
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 copy_generated
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染,直到完成
意图分析已并入脚本生成一步完成,不再有独立的 intent_parsing 阶段或 wait_user_confirm 状态。
旧端点(兼容保留,旧前端/一键生成模式):
POST /api/v1/viral-video/generate 一键入队,前半段跑到 wait_user_confirm
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
通用:
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/copy_result 编导脚本)
@@ -28,6 +30,8 @@ from app.schemas.viral_video import (
AnalyzeStyleRequest,
AnalyzeStyleResponse,
ConfirmCopyRequest,
ConfirmIntentRequest,
CreateViralVideoRequest,
CreditsFormulaBreakdown,
EstimateCreditsRequest,
EstimateCreditsResponse,
@@ -45,17 +49,7 @@ from packages.adapters.sqlalchemy_impl.viral_video_repository import (
SQLAlchemyViralVideoJobRepository,
SQLAlchemyViralVideoStyleTemplateRepository,
)
from packages.application.viral_video.constants import (
API_FALLBACK_HARD_CONSTRAINTS,
API_FALLBACK_NEGATIVE_PROMPTS,
)
from packages.domain.points_rules import (
calculate_viral_video_credits,
calculate_viral_video_credits_with_breakdown,
list_viral_video_models,
resolve_video_dimensions,
)
from packages.domain.points_service import PointsService
from packages.domain.points_rules import list_viral_video_models
from packages.domain.viral_video import ViralVideoStatus
from packages.shared.dashscope_client import get_dashscope_client
@@ -71,7 +65,7 @@ def _build_copy_result(job) -> dict | None:
"""v1.6: 返回编导分镜脚本 CopyResult 结构(给前端/Seedance 使用)。
- 若 job.copy_result 已持久化(v1.6 worker 生成),直接返回(补 final_copy 兜底)。
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表)拼装兼容结构。
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表, intent_result)拼装兼容结构。
"""
cr = getattr(job, "copy_result", None)
if isinstance(cr, dict) and cr:
@@ -85,9 +79,12 @@ def _build_copy_result(job) -> dict | None:
# 兼容 v1.5 老数据:storyboard 是老格式 [{order,type,description,text,duration,...}]
copy_text = getattr(job, "generated_copy_text", "") or ""
sb = getattr(job, "storyboard", None) or []
intent = getattr(job, "intent_result", None) or {}
if not copy_text and not sb:
return None
title = ""
if isinstance(intent, dict):
title = intent.get("suggested_title") or intent.get("intent", "") or ""
shots = []
for seg in sb:
if isinstance(seg, dict):
@@ -108,8 +105,8 @@ def _build_copy_result(job) -> dict | None:
"overview": {"theme": title, "total_duration": getattr(job, "duration", 15), "aspect_ratio": ratio},
"scene_and_lighting": "",
"shots": shots,
"hard_constraints": list(API_FALLBACK_HARD_CONSTRAINTS),
"negative_prompts": list(API_FALLBACK_NEGATIVE_PROMPTS),
"hard_constraints": ["无字幕", "无水印", "人物一致性"],
"negative_prompts": ["字幕", "水印", "错误文字", "五官崩坏"],
"voiceover_script": copy_text,
"final_copy": copy_text,
"suggested_copy": copy_text,
@@ -145,9 +142,9 @@ def _to_response(job) -> ViralVideoJobResponse:
copy_result=_build_copy_result(job),
voice_id=getattr(job, "voice_id", "") or "",
voice_source=getattr(job, "voice_source", "") or "",
language=getattr(job, "language", "zh-CN") or "zh-CN",
video_ratio=getattr(job, "video_ratio", "9:16") or "9:16",
video_model=getattr(job, "video_model", "") or "",
intent_result=job.intent_result,
result_video_url=job.result_video_url,
pre_trusted_images=getattr(job, "pre_trusted_images", None) or None,
video_resolution=getattr(job, "video_resolution", "720p") or "720p",
@@ -170,157 +167,58 @@ def _get_style_repo(session: Session) -> SQLAlchemyViralVideoStyleTemplateReposi
return SQLAlchemyViralVideoStyleTemplateRepository(session)
def _settle_viral_video_credits(
user_id: int,
job,
session: Session,
*,
action: str,
old_duration: int | None = None,
old_resolution: str | None = None,
old_ratio: str | None = None,
old_model: str | None = None,
new_duration: int | None = None,
new_resolution: str | None = None,
new_ratio: str | None = None,
new_model: str | None = None,
) -> dict:
"""爆款视频积分结算统一入口(confirm-copy / retry 共用)。
# ── Endpoints ────────────────────────────────────────────────────────────
action 取值:
- "deduct_first" 首次扣费(confirm 时还没付过)
- "recompute" 参数变更重算:退旧扣新(confirm_copy 时传 old/new)
- "delta" 参数变更差额结算:多退少补(retry 时传 old/new)
返回 credits_info dict,包含 old_est / new_est / action / diff 等。
积分不足时 raise HTTPException(402),调用方无需再捕获。
"""
from app.config import settings as _settings
info: dict = {"action": action, "old_est": 0.0, "new_est": 0.0, "diff": 0.0, "settled": False}
if not _settings.points_enabled:
return info
@router.post("/generate", response_model=ViralVideoJobResponse)
def create_viral_video(
request: CreateViralVideoRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""创建爆款视频任务,入队 Celery 编排器。"""
from packages.domain.viral_video import ViralVideoJob
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
float(getattr(job, "credits_cost", 0) or 0) > 0
repo = _get_job_repo(session)
# 创建领域实体
job = ViralVideoJob(
user_id=authenticated_user.user.id,
images=list(request.images),
industry=request.industry,
target_customer=request.target_customer,
persona_id=request.persona_id,
viral_structure=request.viral_structure,
marketing_purpose=request.marketing_purpose,
bgm_preference=request.bgm_preference,
duration=request.duration or 15,
user_copy_text=request.user_copy_text,
fusion_level=request.fusion_level,
reference_audio_path=request.reference_audio_path,
reference_video_url=request.reference_video_url,
style_strength=request.style_strength,
style_template_id=request.style_template_id,
voice_id=getattr(request, "voice_id", "") or "",
voice_source=getattr(request, "voice_source", "") or "",
video_ratio=getattr(request, "video_ratio", "9:16") or "9:16",
video_model=getattr(request, "video_model", "") or "",
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
copy_result=None,
)
if action == "deduct_first":
if already_paid:
return info
w, h = resolve_video_dimensions(
getattr(job, "video_resolution", "720p") or "720p", job.video_ratio or "9:16"
)
est = calculate_viral_video_credits(int(job.duration or 15), w, h, job.video_model or "seedance-2.5")
info["new_est"] = est
svc = PointsService()
res = svc.deduct_viral_video(user_id, est, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {est} 积分,当前余额 {balance}",
"required": est,
"balance": balance,
},
)
job.credits_prepaid = est
job.credits_transaction_id = res.get("transaction_id", "") or ""
info["settled"] = True
info["diff"] = est
return info
# 持久化
repo.save(job)
if action == "recompute":
if not already_paid:
# 等价于首次扣费
return _settle_viral_video_credits(user_id, job, session, action="deduct_first")
old_w, old_h = resolve_video_dimensions(old_resolution or "720p", old_ratio or "9:16")
old_est = calculate_viral_video_credits(
int(old_duration or 15), old_w, old_h, old_model or "seedance-2.5"
)
new_w, new_h = resolve_video_dimensions(
getattr(job, "video_resolution", "720p") or "720p", job.video_ratio or "9:16"
)
new_est = calculate_viral_video_credits(
int(job.duration or 15), new_w, new_h, job.video_model or "seedance-2.5"
)
info.update({"old_est": old_est, "new_est": new_est, "diff": new_est - old_est})
svc = PointsService()
if getattr(job, "credits_transaction_id", None):
svc.refund_points(
user_id=user_id,
amount=float(job.credits_prepaid),
source="viral_video",
db=session,
ref_id=job.credits_transaction_id,
description="confirm-copy 参数变更退还旧预扣",
)
if new_est > 0:
res = svc.deduct_viral_video(user_id, new_est, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {new_est} 积分,当前余额 {balance}",
"required": new_est,
"balance": balance,
},
)
job.credits_prepaid = new_est
job.credits_transaction_id = res.get("transaction_id", "") or ""
info["settled"] = True
return info
# 入队 Celery 任务
try:
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 任务已入队: job_id=%s user_id=%s", job.id, job.user_id)
except Exception as e:
logger.error("[爆款视频] 入队失败: %s", e, exc_info=True)
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
if action == "delta":
eff_dur = int(new_duration if new_duration is not None else old_duration or 15)
eff_res = new_resolution if new_resolution is not None else (old_resolution or "720p")
eff_ratio = new_ratio if new_ratio is not None else (old_ratio or "9:16")
eff_model = new_model if new_model is not None else (old_model or "seedance-2.5")
w, h = resolve_video_dimensions(eff_res, eff_ratio)
new_est, _ = calculate_viral_video_credits_with_breakdown(eff_dur, w, h, eff_model)
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
diff = round(new_est - old_prepaid, 2)
info.update({"old_est": old_prepaid, "new_est": new_est, "diff": diff})
if abs(diff) < 0.01:
return info
svc = PointsService()
if diff > 0:
res = svc.deduct_viral_video(user_id, diff, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"重试参数变更后需补扣 {diff} 积分,余额不足(当前 {balance},需 {new_est})",
"required": new_est,
"balance": balance,
"delta": diff,
},
)
job.credits_prepaid = round(old_prepaid + diff, 2)
else:
refund = round(-diff, 2)
txn_id = getattr(job, "credits_transaction_id", "") or ""
svc.refund_points(
user_id=user_id,
amount=refund,
source="viral_video",
db=session,
ref_id=txn_id or job.id,
description="爆款视频重试参数变更退费",
)
job.credits_prepaid = round(old_prepaid - refund, 2)
info["settled"] = True
return info
raise ValueError(f"unknown action: {action}")
# ── Endpoints ────────────────────────────────────────────────────────────
return _to_response(job)
@router.post("/analyze-images", response_model=ViralVideoJobResponse)
@@ -345,7 +243,6 @@ def analyze_images(
style_strength=request.style_strength or "medium",
voice_id=request.voice_id or "",
voice_source=request.voice_source or "",
language=getattr(request, "language", "zh-CN") or "zh-CN",
video_ratio=request.video_ratio or "9:16",
video_model=request.video_model or "",
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
@@ -383,18 +280,11 @@ def generate_copy(
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
# 允许首次进入(IMAGE_ANALYZED/PENDING)、失败重试(FAILED)、文案重新生成(COPY_GENERATED/COMPLETED)
if job.status not in (
ViralVideoStatus.IMAGE_ANALYZED,
ViralVideoStatus.PENDING,
ViralVideoStatus.FAILED,
ViralVideoStatus.COPY_GENERATED,
ViralVideoStatus.COMPLETED,
):
if job.status not in (ViralVideoStatus.IMAGE_ANALYZED, ViralVideoStatus.PENDING, ViralVideoStatus.FAILED):
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能生成文案")
# 失败重试 / 重新生成:retry_count 自增
if job.status in (ViralVideoStatus.FAILED, ViralVideoStatus.COPY_GENERATED, ViralVideoStatus.COMPLETED):
# 允许失败任务重试:重置
if job.status == ViralVideoStatus.FAILED:
job.retry_count += 1
job.error_msg = ""
@@ -417,7 +307,6 @@ def generate_copy(
job.style_guide = request.style_guide
job.voice_id = request.voice_id or job.voice_id
job.voice_source = request.voice_source or job.voice_source
job.language = getattr(request, "language", "") or job.language or "zh-CN"
job.video_ratio = request.video_ratio or job.video_ratio or "9:16"
job.video_model = request.video_model or job.video_model or ""
job.video_resolution = getattr(request, "video_resolution", "") or job.video_resolution or "720p"
@@ -452,59 +341,41 @@ def confirm_copy(
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.COPY_GENERATED:
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能确认文案(需 copy_generated)")
# #2218: 额外校验 copy_result 完整性,防止孤儿/脏数据进入渲染
if not isinstance(job.copy_result, dict) or not job.copy_result:
raise HTTPException(status_code=409, detail="文案数据缺失,请先点击「生成文案」")
# Bug1 fix: 用户 confirm 时允许修改 video_model/video_resolution/video_ratio/duration
old_duration = int(getattr(job, "duration", 15) or 15)
old_resolution = getattr(job, "video_resolution", "720p") or "720p"
old_ratio = getattr(job, "video_ratio", "9:16") or "9:16"
old_model = getattr(job, "video_model", None) or "seedance-2.5"
# 积分预扣(已扣过/重试任务跳过)
from app.config import settings as _settings
if request.duration is not None:
job.duration = max(5, min(30, int(request.duration)))
if request.video_resolution is not None:
job.video_resolution = request.video_resolution
if request.video_ratio is not None:
job.video_ratio = request.video_ratio
if request.video_model is not None:
job.video_model = request.video_model
if request.voice_id is not None:
job.voice_id = request.voice_id
if request.voice_source is not None:
job.voice_source = request.voice_source
if getattr(request, "language", None) is not None:
job.language = request.language
param_changed = (
(request.duration is not None and int(request.duration) != old_duration)
or (request.video_resolution is not None and request.video_resolution != old_resolution)
or (request.video_ratio is not None and request.video_ratio != old_ratio)
or (request.video_model is not None and request.video_model != old_model)
)
# 积分预扣(PR2:统一走 _settle_viral_video_credits)
if param_changed:
credits_info = _settle_viral_video_credits(
authenticated_user.user.id,
job,
session,
action="recompute",
old_duration=old_duration,
old_resolution=old_resolution,
old_ratio=old_ratio,
old_model=old_model,
if _settings.points_enabled:
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
float(getattr(job, "credits_cost", 0) or 0) > 0
)
logger.info(
"[爆款视频][confirm-copy] 参数变更,积分重算: old=%s new=%s job_id=%s",
credits_info.get("old_est"),
credits_info.get("new_est"),
job.id,
)
else:
_settle_viral_video_credits(authenticated_user.user.id, job, session, action="deduct_first")
repo.update(job)
if not already_paid:
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
from packages.domain.points_service import PointsService
w, h = resolve_video_dimensions(
getattr(job, "video_resolution", "720p") or "720p",
job.video_ratio or "9:16",
)
est_credits = calculate_viral_video_credits(
int(job.duration or 15), w, h, job.video_model or "seedance-2.5"
)
svc = PointsService()
res = svc.deduct_viral_video(authenticated_user.user.id, est_credits, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"积分不足,需要 {est_credits} 积分,当前余额 {balance}",
"required": est_credits,
"balance": balance,
},
)
job.credits_prepaid = est_credits
job.credits_transaction_id = res.get("transaction_id", "") or ""
repo.update(job)
job.resume_from_copy_generated(edited_copy=request.edited_copy or None)
repo.update(job)
@@ -514,7 +385,6 @@ def confirm_copy(
logger.info("[爆款视频][阶段3] confirm-copy 入队: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频][阶段3] confirm-copy 入队失败: %s", e, exc_info=True)
# TODO(PR2-followup): 入队失败应返回 HTTP 503 而非 mark_failed + 200,避免前端收到"成功"但 job 已 failed 的不一致状态
job.mark_failed(f"任务入队失败: {e}")
repo.update(job)
@@ -532,6 +402,11 @@ def estimate_credits(
profit_multiplier / model_price / width / height / fps),便于前端展示计费明细。
同时兼容前端传 model 或 video_model、resolution 或 video_resolution、ratio 或 video_ratio。
"""
from packages.domain.points_rules import (
calculate_viral_video_credits_with_breakdown,
resolve_video_dimensions,
)
model = (request.model or "").strip() or "seedance-2.5"
resolution = (request.resolution or "").strip() or "720p"
ratio = (request.ratio or "").strip() or "9:16"
@@ -663,7 +538,25 @@ def retry_viral_video_job(
or (new_model is not None and new_model != old_model)
)
# 写入新参数(即使不开 points 也要允许用户重试时改参数;delta 结算依赖这些新值)
from app.config import settings as _settings
need_points_settle = False
new_est = 0.0
if _settings.points_enabled and param_changed:
from packages.domain.points_rules import (
calculate_viral_video_credits_with_breakdown,
resolve_video_dimensions,
)
eff_dur = int(new_duration if new_duration is not None else old_duration)
eff_res = new_resolution if new_resolution is not None else old_resolution
eff_ratio = new_ratio if new_ratio is not None else old_ratio
eff_model = new_model if new_model is not None else (old_model or "seedance-2.5")
w, h = resolve_video_dimensions(eff_res, eff_ratio)
new_est, _ = calculate_viral_video_credits_with_breakdown(eff_dur, w, h, eff_model or "seedance-2.5")
need_points_settle = True
# 写入新参数(即使不开 points 也要允许用户重试时改参数)
if new_duration is not None:
job.duration = max(5, min(30, int(new_duration)))
if new_resolution is not None:
@@ -673,31 +566,57 @@ def retry_viral_video_job(
if new_model is not None:
job.video_model = new_model
# PR2:统一走 _settle_viral_video_credits(delta 多退少补)
if param_changed:
credits_info = _settle_viral_video_credits(
authenticated_user.user.id,
job,
session,
action="delta",
old_duration=old_duration,
old_resolution=old_resolution,
old_ratio=old_ratio,
old_model=old_model,
new_duration=new_duration,
new_resolution=new_resolution,
new_ratio=new_ratio,
new_model=new_model,
)
if credits_info.get("settled"):
logger.info(
"[爆款视频][retry] 积分差额结算 job_id=%s diff=%.2f new_prepaid=%.2f",
job.id,
credits_info["diff"],
job.credits_prepaid,
)
if need_points_settle:
from packages.domain.points_service import PointsService
# 重置状态(从阶段1重新开始,清空上一次产物避免脏数据)
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
svc = PointsService()
diff = round(new_est - old_prepaid, 2)
if abs(diff) >= 0.01:
if diff > 0:
# 新预扣更多:补扣差额
res = svc.deduct_viral_video(authenticated_user.user.id, diff, job.id, session)
if not res.get("success"):
balance = res.get("balance", 0)
raise HTTPException(
status_code=402,
detail={
"code": "INSUFFICIENT_POINTS",
"message": f"重试参数变更后需补扣 {diff} 积分,余额不足(当前 {balance},需 {new_est})",
"required": new_est,
"balance": balance,
"delta": diff,
},
)
job.credits_prepaid = round(old_prepaid + diff, 2)
logger.info(
"[爆款视频][retry] 补扣差额 job_id=%s diff=%.2f new_prepaid=%.2f",
job.id,
diff,
job.credits_prepaid,
)
else:
# 新预扣更少:退还差额
refund = round(-diff, 2)
txn_id = getattr(job, "credits_transaction_id", "") or ""
svc.refund_points(
user_id=authenticated_user.user.id,
amount=refund,
source="viral_video",
db=session,
ref_id=txn_id or job.id,
description="爆款视频重试参数变更退费",
)
job.credits_prepaid = round(old_prepaid - refund, 2)
logger.info(
"[爆款视频][retry] 退还差额 job_id=%s refund=%.2f new_prepaid=%.2f",
job.id,
refund,
job.credits_prepaid,
)
# 差额为 0 则不调整
# 重置状态
job.retry_count += 1
job.status = ViralVideoStatus.PENDING
job.error_msg = "" if not is_stale_running else "任务执行超时,已重置重试"
@@ -706,18 +625,11 @@ def retry_viral_video_job(
job.current_stage = ""
job.phase_message = ""
job.heartbeat_at = None
# 重试时清空上次产物(参考 resume_from_image_analyzed 逻辑)
job.copy_result = None
job.storyboard = None
job.generated_copy_text = ""
job.result_video_url = ""
job.pre_trusted_images = None
job.credits_cost = 0.0
repo.update(job)
# 重新入队:从图片分析阶段1重新跑
# 重新入队
try:
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
logger.info(
"[爆款视频] 重试入队: job_id=%s retry_count=%d stale=%s params_changed=%s",
job.id,
@@ -727,13 +639,49 @@ def retry_viral_video_job(
)
except Exception as e:
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
# TODO(PR2-followup): 入队失败应返回 HTTP 503 而非 mark_failed + 200
job.mark_failed(f"重试入队失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/confirm-intent", response_model=ViralVideoJobResponse)
def confirm_intent(
job_id: str,
request: ConfirmIntentRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
session: Session = Depends(get_db_session),
) -> ViralVideoJobResponse:
"""用户确认/修改 AI 生成的意图文案,恢复流水线。"""
repo = _get_job_repo(session)
job = repo.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="任务不存在")
if job.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权操作此任务")
if job.status != ViralVideoStatus.WAIT_USER_CONFIRM:
raise HTTPException(status_code=409, detail="任务当前不在等待确认状态")
# 更新文案
if request.confirmed_copy:
job.user_copy_text = request.confirmed_copy
# 恢复流水线
job.resume_from_confirm()
repo.update(job)
# 从断点恢复 Celery 任务
try:
celery_app.send_task("worker.resume_viral_video_pipeline", args=[job.id])
logger.info("[爆款视频] 意图确认,恢复流水线: job_id=%s", job.id)
except Exception as e:
logger.error("[爆款视频] 恢复流水线失败: %s", e, exc_info=True)
job.mark_failed(f"恢复流水线失败: {e}")
repo.update(job)
return _to_response(job)
@router.post("/{job_id}/analyze-style", response_model=AnalyzeStyleResponse)
def analyze_style(
job_id: str,
@@ -932,28 +880,13 @@ async def viral_video_websocket(websocket: WebSocket, job_id: str) -> None:
job = job_repo.get(job_id)
if job is not None:
status_val = job.status.value if hasattr(job.status, "value") else str(job.status)
# #P0: 初始快照必须包含前端重连/刷新所需的业务字段,
# 结构对齐 worker 推送的 image_analyzed / copy_generated 事件。
data: dict = {"status": status_val}
ia = getattr(job, "image_analysis", None)
if isinstance(ia, dict) and ia:
data["image_analysis"] = ia
cr = _build_copy_result(job)
if isinstance(cr, dict) and cr:
data["copy_result"] = cr
gct = getattr(job, "generated_copy_text", "") or ""
if gct:
data["generated_copy_text"] = gct
sb = getattr(job, "storyboard", None) or []
if sb:
data["storyboard"] = sb
initial = {
"type": "viral_video:progress",
"job_id": job_id,
"stage": _stage_from_status(job),
"progress": _estimate_progress(job),
"message": _initial_message(job),
"data": data,
"data": {"status": status_val},
}
await websocket.send_json(initial)
# 已经终态 → 再发一条终态事件后立即关闭,避免占连接
@@ -1000,6 +933,7 @@ _STATUS_STAGE = {
"running": "",
"image_analyzed": "image_analysis",
"copy_generated": "review",
"wait_user_confirm": "intent_parsing",
"completed": "uploading",
"failed": "",
"cancelled": "",
@@ -1010,6 +944,7 @@ _STATUS_PROGRESS = {
"running": 5.0,
"image_analyzed": 15.0,
"copy_generated": 70.0,
"wait_user_confirm": 35.0,
"completed": 100.0,
"failed": 0.0,
"cancelled": 0.0,
@@ -1020,6 +955,7 @@ _STATUS_MESSAGE = {
"running": "任务执行中",
"image_analyzed": "图片分析完成,等待填写营销参数",
"copy_generated": "文案与分镜已生成,等待确认文案",
"wait_user_confirm": "等待用户确认意图文案",
"completed": "视频生成完成",
"failed": "任务失败",
"cancelled": "任务已取消",
-1
View File
@@ -19,7 +19,6 @@ class PointsBalanceResponse(BaseModel):
is_member: bool = Field(default=False, description="是否付费会员")
member_type: Optional[str] = Field(None, description="会员类型: monthly/quarterly/yearly")
member_expires_at: Optional[datetime] = Field(None, description="会员到期时间")
credits_enabled: bool = Field(default=False, description="积分系统是否启用(false=免费放行不扣分)")
# ============ 流水 ============
+49 -10
View File
@@ -13,6 +13,7 @@ VALID_STYLE_STRENGTHS = ("light", "medium", "strict")
VALID_STAGES = (
"image_analysis",
"video_analysis",
"intent_parsing",
"script_generation",
"review",
"tts",
@@ -67,6 +68,46 @@ class CopyResult(BaseModel):
# -- Request Schemas --
class CreateViralVideoRequest(BaseModel):
"""旧接口:一键创建(保留兼容)。"""
images: list[str] = Field(..., min_length=1, max_length=20)
industry: str = ""
target_customer: str = ""
persona_id: str = ""
viral_structure: str = ""
marketing_purpose: str = ""
bgm_preference: str = ""
duration: int = Field(default=15, ge=5, le=30, description="视频时长(秒),5-30")
user_copy_text: str = ""
fusion_level: str = "ai_polish"
reference_audio_path: str = ""
reference_video_url: str = ""
style_strength: str = "medium"
style_template_id: str = ""
voice_id: str = ""
voice_source: str = ""
video_ratio: str = "9:16"
video_model: str = ""
video_resolution: str = "720p"
@field_validator("fusion_level")
@classmethod
def _v_fl(cls, v: str) -> str:
if v == "full_ai":
return "ai_full"
if v not in VALID_FUSION_LEVELS:
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
return v
@field_validator("style_strength")
@classmethod
def _v_ss(cls, v: str) -> str:
if v not in VALID_STYLE_STRENGTHS:
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
return v
class AnalyzeImagesRequest(BaseModel):
"""v1.5+ 阶段1:创建任务 + 图片/视频分析。"""
@@ -80,7 +121,6 @@ class AnalyzeImagesRequest(BaseModel):
video_model: str = ""
video_resolution: str = "720p"
duration: int = Field(default=15, ge=5, le=30)
language: str = "zh-CN"
class GenerateCopyRequest(BaseModel):
@@ -102,7 +142,6 @@ class GenerateCopyRequest(BaseModel):
style_guide: dict | None = None
voice_id: str = ""
voice_source: str = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
video_resolution: str = "720p"
@@ -128,13 +167,13 @@ class ConfirmCopyRequest(BaseModel):
"""v1.5+ 阶段3:用户确认/编辑口播后开始渲染(TTS+单次Seedance)。"""
edited_copy: str = Field(default="", description="用户编辑后的口播文案;为空则用 AI 生成的 voiceover_script")
video_model: str | None = Field(default=None, description="用户选定的视频生成模型(confirm时可选)")
video_resolution: str | None = Field(default=None, description="用户选定的分辨率(confirm时可选)")
video_ratio: str | None = Field(default=None, description="用户选定的比例(confirm时可选)")
duration: int | None = Field(default=None, ge=5, le=30, description="用户选定的时长秒数(confirm时可选,5~30)")
voice_id: str | None = Field(default=None, description="用户选定的音色ID(confirm时可选)")
voice_source: str | None = Field(default=None, description="用户选定的音色来源(confirm时可选)")
language: str | None = Field(default=None, description="用户选定的语言(confirm时可选)")
class ConfirmIntentRequest(BaseModel):
"""旧 confirm-intent(兼容)。"""
confirmed_copy: str = ""
adjustments: str = ""
class AnalyzeStyleRequest(BaseModel):
@@ -179,9 +218,9 @@ class ViralVideoJobResponse(BaseModel):
# 音色/视频参数
voice_id: str = ""
voice_source: str = ""
language: str = "zh-CN"
video_ratio: str = "9:16"
video_model: str = ""
intent_result: dict | None = None
result_video_url: str = ""
pre_trusted_images: list[str] | None = None
video_resolution: str = "720p"
@@ -228,8 +228,6 @@ class GpuLipsyncService:
lipsync_job_id: str = "",
user_id: str = "",
project_id: str = "",
credits_prepaid: float = 0.0,
credits_transaction_id: str = "",
) -> GpuLipsyncTaskModel:
task_id = str(uuid.uuid4())
now = datetime.now(UTC)
@@ -242,8 +240,6 @@ class GpuLipsyncService:
audio_url=audio_url,
status="pending",
attempt=0,
credits_prepaid=float(credits_prepaid or 0.0),
credits_transaction_id=str(credits_transaction_id or ""),
created_at=now,
updated_at=now,
)
+1 -222
View File
@@ -38,7 +38,6 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
from packages.application.cosyvoice_service import CosyVoiceError
from packages.config import get_api_settings
from packages.domain import feature_pricing_service
from packages.domain.sentence_timings import (
compute_sentence_timings,
probe_audio_duration,
@@ -223,47 +222,7 @@ class LipsyncService:
if timings:
job.sentence_timings = timings
# 4. 检查是否走 Ditto(蚂蚁数字人,#2076):开关 + 配置完整
use_ditto = False
if self.settings.use_ditto_lipsync:
try:
from packages.application.ditto_service import get_ditto_client
ditto = get_ditto_client()
if ditto.is_configured:
use_ditto = True
logger.info("[lipsync] 优先走 Ditto 蚂蚁数字人: job_id=%s", job.id)
else:
logger.info(
"[lipsync] Ditto 开关已开但配置不完整(base_url=%s, template=%s),继续判断 GPU: job_id=%s",
bool(ditto.base_url),
bool(ditto.default_video_url),
job.id,
)
except Exception as exc:
logger.warning("[lipsync] Ditto 初始化失败,继续判断 GPU: job_id=%s err=%s", job.id, exc)
if use_ditto:
try:
# Ditto 使用预置人物模板视频,不用用户上传的 video_url;
# 但保留用户 video_url 以便失败回退到 GPU/MediaKit。
job.status = "processing"
job.mediakit_task_id = "ditto:submitted"
job.updated_at = datetime.now(UTC)
self.db.commit()
from app.tasks.lipsync_ditto import lipsync_ditto_process_async
lipsync_ditto_process_async.apply_async(args=(job.id, job.user_id))
logger.info("[lipsync] Ditto 任务已异步派发: job_id=%s", job.id)
return
except Exception as exc:
logger.warning("[lipsync] Ditto 派发失败,回退 GPU/MediaKit: job_id=%s err=%s", job.id, exc)
try:
self.db.rollback()
except Exception:
pass
# 5. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
# 4. 检查是否走 GPU 路径:开关打开 + 有可用 Worker
use_gpu = False
if self.settings.use_gpu_lipsync:
try:
@@ -409,8 +368,6 @@ class LipsyncService:
lipsync_job_id=job.id,
user_id=job.user_id,
project_id=job.project_id,
credits_prepaid=float(getattr(job, "credits_prepaid", 0) or 0),
credits_transaction_id=str(getattr(job, "credits_transaction_id", "") or ""),
)
logger.info(
"[lipsync] 已创建 GPU 任务(异步): job_id=%s gpu_task=%s",
@@ -458,121 +415,6 @@ class LipsyncService:
job.output_duration,
)
# ── lip_sync 计费辅助 ────────────────────────────────────────────────
@staticmethod
def _estimate_duration(
*,
audio_duration: Optional[float] = None,
sentence_timings: Optional[list] = None,
script_text: str = "",
) -> float:
"""预估音频/成片秒数。
优先级:audio_duration(预合成前端已 ffprobe)> timings 末句 end_time >
脚本字数 / 5 字每秒 > 默认 10 秒。
"""
if audio_duration and float(audio_duration) > 0:
return float(audio_duration)
if sentence_timings:
max_end = 0.0
for item in sentence_timings:
if isinstance(item, dict):
end = item.get("end_time") or item.get("end") or 0.0
else:
end = 0.0
try:
max_end = max(max_end, float(end))
except (TypeError, ValueError):
continue
if max_end > 0:
return max_end
text = (script_text or "").strip()
if text:
return max(1.0, len(text) / 5.0)
return 10.0
def _settle_lip_sync(self, job: LipsyncJobModel, actual_duration: float) -> None:
"""按实际时长结算(首期只退不补:final < prepaid 退差额,> 不补)。
幂等:credits_cost 已 > 0 说明结算过,直接跳过。
结算失败不阻塞业务(结果已产出),仅记录日志。
"""
try:
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
if prepaid <= 0:
return
if float(getattr(job, "credits_cost", 0) or 0) > 0:
return
feature_cfg = feature_pricing_service.get_feature_config("lip_sync")
unit_cost = float(feature_cfg.dynamic_unit_cost) if feature_cfg is not None else 0.0
duration = float(actual_duration or 0.0)
if duration <= 0:
duration = self._estimate_duration(
sentence_timings=job.sentence_timings,
script_text=job.script_text,
)
final_price, _bd = feature_pricing_service.calculate_price("lip_sync", duration * unit_cost)
final_price = round(float(final_price), 2)
job.credits_cost = final_price
if final_price < prepaid - 0.009:
refund = round(prepaid - final_price, 2)
from packages.domain.points_service import PointsService
res = PointsService().refund_points(
user_id=job.user_id,
amount=refund,
source="lip_sync",
db=self.db,
ref_id=str(job.credits_transaction_id or job.id),
description="对口型结算退费",
)
if not res.get("success"):
logger.warning(
"[lip_sync] 结算退费失败 job_id=%s refund=%.2f(不阻塞)",
job.id,
refund,
)
# final > prepaid:首期只退不补,不补扣
self.db.commit()
except Exception: # noqa: BLE001
logger.exception("[lip_sync] 结算异常 job_id=%s(不阻塞结果)", job.id)
try:
self.db.rollback()
except Exception: # noqa: BLE001
pass
def _refund_lip_sync(self, job: LipsyncJobModel) -> None:
"""任务失败/取消时全额退还预扣积分(credits_cost 已结算则退实际未消耗部分)。"""
try:
prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
if prepaid <= 0:
return
txn_id = str(getattr(job, "credits_transaction_id", "") or "")
cost = float(getattr(job, "credits_cost", 0) or 0)
refund = round(prepaid - cost, 2) if cost > 0 else round(prepaid, 2)
if refund <= 0:
return
from packages.domain.points_service import PointsService
res = PointsService().refund_points(
user_id=job.user_id,
amount=refund,
source="lip_sync",
db=self.db,
ref_id=txn_id or job.id,
description="对口型失败/取消退款",
)
if res.get("success"):
job.credits_cost = prepaid # 标记已全额退回,防重复退
self.db.commit()
except Exception: # noqa: BLE001
logger.exception("[lip_sync] 退款异常 job_id=%s", job.id)
try:
self.db.rollback()
except Exception: # noqa: BLE001
pass
# ── 创建任务 ──────────────────────────────────────────────────────────
def create_job(
@@ -624,35 +466,6 @@ class LipsyncService:
if not isinstance(sentence_timings, list) or len(sentence_timings) == 0:
raise MediaKitError("预合成模式 sentence_timings 不能为空", code="InvalidInput")
# 0.5 lip_sync 计费预扣(全局 points 开关 + 功能开关均开才扣)
prepaid_credits = 0.0
prepaid_txn_id = ""
if feature_pricing_service.is_feature_enabled("lip_sync"):
est_duration = self._estimate_duration(
audio_duration=audio_duration,
sentence_timings=sentence_timings,
script_text=script_text,
)
feature_cfg = feature_pricing_service.get_feature_config("lip_sync")
unit_cost = float(feature_cfg.dynamic_unit_cost) if feature_cfg is not None else 0.0
dynamic_cost = est_duration * unit_cost
prepaid_credits, _bd = feature_pricing_service.calculate_price("lip_sync", dynamic_cost)
if prepaid_credits > 0:
from packages.domain.points_service import PointsService
res = PointsService().deduct_points(
user_id=user_id,
amount=prepaid_credits,
source="lip_sync",
db=self.db,
description="对口型生成预扣",
)
if not res.get("success"):
raise ValueError(
f"积分不足:本次对口型需 {prepaid_credits:.2f} 积分,当前余额 {res.get('balance', 0)}"
)
prepaid_txn_id = str(res.get("transaction_id") or "")
# 1. 创建数据库记录
job_id = str(uuid.uuid4())
job = LipsyncJobModel(
@@ -669,8 +482,6 @@ class LipsyncService:
emotion=emotion or "",
# 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing
status="tts_processing" if is_tts_mode else "pending",
credits_prepaid=prepaid_credits,
credits_transaction_id=prepaid_txn_id,
)
self.db.add(job)
self.db.flush()
@@ -845,30 +656,6 @@ class LipsyncService:
if job.status in (STATUS_COMPLETED, "failed"):
return job
# Ditto 异步路径:mediakit_task_id 以 "ditto:" 开头,由 Celery 任务异步更新
# 不做 MediaKit 轮询,只检查是否卡住太久(>10 分钟)则标失败
if job.mediakit_task_id and job.mediakit_task_id.startswith("ditto:"):
if job.status in ("processing", "submitted"):
_now = datetime.now(UTC)
_upd = job.updated_at
if _upd is not None and _upd.tzinfo is None:
_upd = _upd.replace(tzinfo=UTC)
stale_minutes = 10
if _upd and (_now - _upd).total_seconds() > stale_minutes * 60:
logger.warning(
"Ditto 异步任务超时(>%d 分钟),标记失败: job_id=%s",
stale_minutes,
job_id,
)
job.status = "failed"
job.error_message = f"Ditto 处理超时(>{stale_minutes} 分钟)"
job.error_code = "DittoTimeout"
job.completed_at = _now
job.updated_at = _now
self.db.commit()
self._refund_lip_sync(job)
return job
# GPU 异步路径:mediakit_task_id 以 "gpu:" 开头,由 Celery 任务异步更新
# 不做 MediaKit 轮询,只检查是否卡住太久(>30 分钟)则标失败
if job.mediakit_task_id and job.mediakit_task_id.startswith("gpu:"):
@@ -890,8 +677,6 @@ class LipsyncService:
job.completed_at = _now
job.updated_at = _now
self.db.commit()
# lip_sync 超时全额退款
self._refund_lip_sync(job)
return job
# 未提交的任务不轮询
@@ -917,8 +702,6 @@ class LipsyncService:
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
self.db.commit()
# lip_sync 结算(只退不补)
self._settle_lip_sync(job, float(job.output_duration or 0.0))
# 异步转存自家 OSS
try:
from app.tasks.lipsync_tts import persist_output_video_task
@@ -936,8 +719,6 @@ class LipsyncService:
job.error_message = error.get("message", "任务执行失败")
job.error_code = error.get("code", "TaskFailed")
job.completed_at = datetime.now(UTC)
# lip_sync 失败全额退款(先退款再统一 commit)
self._refund_lip_sync(job)
else:
# 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted
if isinstance(mk_status, str) and mk_status:
@@ -1031,8 +812,6 @@ class LipsyncService:
job.status = "cancelled"
job.updated_at = datetime.now(UTC)
self.db.commit()
# lip_sync 取消全额退款
self._refund_lip_sync(job)
self.db.refresh(job)
return job
-552
View File
@@ -1,552 +0,0 @@
"""Ditto 蚂蚁数字人口型异步任务 — #2076.
把 Ditto 同步 HTTP 调用(30-120s)从 API 请求移到 Celery 后台执行:
1. 加载 LipsyncJob
2. 确定驱动视频:用户上传的 video_url 优先,无则用 settings.ditto_default_video_url 兜底
3. 视频时长对齐:若视频 < 音频+2s,用 ffmpeg 循环视频到足够长度后上传临时文件
4. 调 DittoClient.generate_and_persist
5. 成功:标记 completed,写入 output_video_url(Ditto 输出自带音频,无需二次混流/超分)
6. 失败:回退 GPU MuseTalk → 再失败回退 MediaKit
注意:
- 保留 MuseTalk 代码不动;Ditto 优先,失败按原链路兜底
- Ditto 默认使用用户上传的视频作为驱动模板,default_video_url 仅作兜底
- 用户视频短于音频时,ffmpeg stream_loop 循环到音频时长+2s余量
- 不传 GFPGAN 超分,不需要 ffmpeg 音视频混流(Ditto 输出已带音视频)
"""
from __future__ import annotations
import logging
import os
import subprocess
import tempfile
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Optional
from celery import shared_task
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
logger = logging.getLogger(__name__)
_DITTO_URL_TTL_SECONDS = 7 * 24 * 3600 # Ditto 结果 OSS URL 7 天有效
_VIDEO_LOOP_MARGIN_SECONDS = 2.0 # 循环视频时比音频多留 2 秒余量
_MAX_VIDEO_PREPROCESS_SIZE = 200 * 1024 * 1024 # 用户视频最大 200MB
def _get_db_session() -> Session:
try:
from worker_app.db import SessionLocal # type: ignore
except ImportError:
from app.db import SessionLocal # type: ignore
return SessionLocal()
def _sign_media_url(url: str) -> str:
"""对自家 OSS URL 签 7 天预签名。"""
if not url:
return url
try:
from urllib.parse import urlparse
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
public_base = getattr(storage, "public_url", "")
if not isinstance(public_base, str) or not public_base:
return url
own_host = urlparse(public_base).netloc.lower()
host = urlparse(url).netloc.lower()
if not own_host or host != own_host:
return url
return storage.get_download_url(url, expires_seconds=_DITTO_URL_TTL_SECONDS)
except Exception:
return url
def _probe_media_duration(path_or_bytes, *, is_bytes: bool = False) -> float:
"""用 ffprobe 探测视频/音频时长(秒);失败返回 0。
Args:
path_or_bytes: 文件路径(str) 或 字节数据(bytes)
is_bytes: 传入的是 bytes 还是文件路径
"""
tmp_path = None
try:
if is_bytes:
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as tmp:
tmp.write(path_or_bytes)
tmp_path = tmp.name
target = tmp_path
else:
target = path_or_bytes
out = subprocess.check_output(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
target,
],
stderr=subprocess.STDOUT,
timeout=15,
)
return float(out.decode().strip() or 0)
except Exception as exc:
logger.warning("[ditto_task] ffprobe 失败: %s", exc)
return 0.0
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except Exception:
pass
def _probe_video_duration(video_bytes: bytes) -> float:
"""用 ffprobe 探测视频时长(秒);失败返回 0。"""
return _probe_media_duration(video_bytes, is_bytes=True)
def _prepare_driver_video(
*,
user_video_url: str,
default_video_url: str,
audio_duration: float,
job_id: str,
user_id: str,
) -> tuple[str, bool]:
"""准备传给 Ditto 的驱动视频 URL。
逻辑:
1. 优先使用用户上传的视频(user_video_url),无则用 default_video_url 兜底
2. 下载视频,探测时长
3. 若视频时长 >= 音频时长+2s 余量:直接用原 URL(签名后)
4. 若视频时长 < 音频时长+2s:ffmpeg stream_loop 循环到目标时长,上传临时 OSS,返回临时 URL
5. 任何异常:回退到 default_video_url(兜底)
Returns:
(video_url_for_ditto, is_temporary) — is_temporary=True 表示 URL 是本次临时生成的
"""
from packages.shared.storage import get_shared_storage_service
from packages.shared.url_security import safe_download_bytes
storage = get_shared_storage_service()
# 1. 选择源 URL(用户视频优先)
source_url = user_video_url or default_video_url
source_label = "user" if user_video_url else "default"
if not source_url:
raise RuntimeError("无可用驱动视频(用户视频和默认模板都为空)")
# 2. 下载视频
try:
# 用户视频需要签名才能下载
signed_source = _sign_media_url(source_url) if user_video_url else source_url
video_bytes = safe_download_bytes(
signed_source,
purpose="ditto-driver-video",
max_size=_MAX_VIDEO_PREPROCESS_SIZE,
allowed_mime_types=("video/mp4", "video/quicktime", "video/x-msvideo", "video/webm"),
timeout=60,
)
except Exception as dl_exc:
logger.warning(
"[ditto_task] 下载驱动视频失败(%s),回退默认模板: job=%s err=%s",
source_label,
job_id,
dl_exc,
)
if user_video_url and default_video_url:
return default_video_url, False
raise
# 3. 探测视频时长
video_duration = _probe_media_duration(video_bytes, is_bytes=True)
target_duration = audio_duration + _VIDEO_LOOP_MARGIN_SECONDS
# 4. 视频足够长:直接用签名后的原 URL
if video_duration >= target_duration:
logger.info(
"[ditto_task] 驱动视频足够长(%.2fs >= %.2fs),直接使用: job=%s source=%s",
video_duration,
target_duration,
job_id,
source_label,
)
return _sign_media_url(source_url) if user_video_url else source_url, False
# 5. 视频不够长:ffmpeg 循环到目标时长
logger.info(
"[ditto_task] 驱动视频不够长(%.2fs < %.2fs),ffmpeg 循环延长: job=%s",
video_duration,
target_duration,
job_id,
)
looped_path = None
try:
# 写临时文件
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as src_tmp:
src_tmp.write(video_bytes)
src_path = src_tmp.name
looped_fd, looped_path = tempfile.mkstemp(suffix=".mp4")
os.close(looped_fd)
# ffmpeg: stream_loop -1 循环输入,-t 截到目标时长
# 使用 -c copy 快速复制流(不重编码),速度快无画质损失
cmd = [
"ffmpeg",
"-y",
"-stream_loop",
"-1",
"-i",
src_path,
"-t",
str(target_duration),
"-c",
"copy",
"-movflags",
"+faststart",
looped_path,
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
except subprocess.CalledProcessError:
# copy 模式失败(编码不兼容),回退重编码
logger.warning("[ditto_task] stream_loop copy 失败,回退重编码: job=%s", job_id)
cmd = [
"ffmpeg",
"-y",
"-stream_loop",
"-1",
"-i",
src_path,
"-t",
str(target_duration),
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-c:a",
"aac",
"-movflags",
"+faststart",
looped_path,
]
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
# 上传临时 OSS
looped_key = f"ditto-tmp/{user_id}/{job_id}_looped.mp4"
with open(looped_path, "rb") as f:
tmp_url = storage.upload_file(
f,
looped_key,
content_type="video/mp4",
)
# 临时文件需要签名(private bucket)
signed_tmp_url = _sign_media_url(tmp_url)
logger.info(
"[ditto_task] 循环视频已上传: job=%s key=%s dur=%.2fs",
job_id,
looped_key,
target_duration,
)
return signed_tmp_url, True
except Exception as loop_exc:
logger.warning(
"[ditto_task] 视频循环处理失败,回退直接使用(Ditto 侧处理): job=%s err=%s",
job_id,
loop_exc,
)
# 兜底:直接用原视频(交给 Ditto 侧处理时长不一致)
return _sign_media_url(source_url) if user_video_url else source_url, False
finally:
for p in (locals().get("src_path"), looped_path):
if p and os.path.exists(p):
try:
os.unlink(p)
except Exception:
pass
def _refund_lip_sync(db: Session, job: "LipsyncJobModel") -> None:
"""Ditto 失败/取消时全额退款(复用 lipsync_service 的退款逻辑)。"""
try:
from app.services.lipsync_service import LipsyncService
LipsyncService(db)._refund_lip_sync(job)
except Exception:
logger.exception("[ditto_task] lip_sync 退款异常 job_id=%s", job.id)
def _settle_lip_sync(db: Session, job: "LipsyncJobModel", duration: float) -> None:
"""Ditto 成功后按实际时长结算。"""
try:
from app.services.lipsync_service import LipsyncService
LipsyncService(db)._settle_lip_sync(job, duration)
except Exception:
logger.exception("[ditto_task] lip_sync 结算异常 job_id=%s(不阻塞)", job.id)
def _fallback_to_gpu_then_mediakit(db: Session, job: "LipsyncJobModel") -> None:
"""Ditto 失败后:优先回退 GPU MuseTalk,再回退 MediaKit 云端。
复用 lipsync_service 现有路径逻辑以保证兜底一致性。
"""
# 先尝试走 GPU MuseTalk(若可用)
try:
from app.services.gpu_lipsync_service import GpuLipsyncService
from app.tasks.lipsync_gpu import lipsync_gpu_process_async
gpu_svc = GpuLipsyncService(db)
if gpu_svc.has_available_worker():
logger.info("[ditto_task] 回退 GPU MuseTalk: job_id=%s", job.id)
from app.services.lipsync_service import LipsyncService
svc = LipsyncService(db)
storage = _shared_storage()
persisted_audio = None
try:
persisted_audio = svc._persist_external_audio_for_gpu(job=job, storage=storage)
except Exception as exc:
logger.warning("[ditto_task] GPU 外部音频转存失败: %s", exc)
audio_url_for_task = persisted_audio or job.audio_url
gpu_task = gpu_svc.create_task(
video_url=job.video_url,
audio_url=audio_url_for_task,
lipsync_job_id=job.id,
user_id=job.user_id,
)
if gpu_task is not None:
job.mediakit_task_id = f"gpu:{gpu_task.id}"
job.status = "processing"
job.updated_at = datetime.now(UTC)
db.commit()
lipsync_gpu_process_async.apply_async(args=(job.id, job.user_id, gpu_task.id))
return
db.rollback()
except Exception as exc:
logger.warning("[ditto_task] GPU MuseTalk 回退失败,转 MediaKit: %s", exc)
try:
db.rollback()
except Exception:
pass
# 最后兜底:MediaKit 云端
try:
from app.services.mediakit_client import get_mediakit_client
client = get_mediakit_client()
video_url = _sign_media_url(job.video_url)
signed_audio_url = _sign_media_url(job.audio_url)
result = client.submit_lipsync(
video_url=video_url,
audio_url=signed_audio_url,
enable_video_loop=job.enable_video_loop,
client_token=job.id,
)
job.mediakit_task_id = result["task_id"]
job.status = "submitted"
job.submitted_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[ditto_task] 已回退 MediaKit: job_id=%s task_id=%s", job.id, result["task_id"])
except Exception as exc:
job.status = "failed"
job.error_message = f"Ditto/GPU/MediaKit 均失败: {exc}"
job.error_code = "AllBackendsFailed"
job.updated_at = datetime.now(UTC)
db.commit()
logger.error("[ditto_task] 所有兜底均失败: job_id=%s err=%s", job.id, exc)
def _shared_storage():
from packages.shared.storage import get_shared_storage_service
return get_shared_storage_service()
@shared_task(
name="lipsync_ditto_process_async",
bind=True,
max_retries=0,
acks_late=True,
time_limit=600,
soft_time_limit=540,
)
def lipsync_ditto_process_async(self, job_id: str, user_id: str) -> None:
"""异步调用 Ditto 生成口型视频。
Args:
job_id: LipsyncJob ID
user_id: 用户 ID
"""
from packages.application.ditto_emotion_service import get_ditto_emotion_service
from packages.application.ditto_service import DittoError, get_ditto_client
from packages.config import get_api_settings
from packages.domain.sentence_timings import probe_audio_duration
from packages.shared.url_security import safe_download_bytes
db: Session = _get_db_session()
job: Optional[LipsyncJobModel] = None
prepared_video_url: str = ""
try:
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
job = db.query(LipsyncJobModel).filter_by(id=job_id, user_id=user_id).first()
if job is None:
logger.error("[ditto_task] job 不存在: job_id=%s", job_id)
return
if job.status != "processing":
logger.warning(
"[ditto_task] job 状态异常(非 processing),跳过: job_id=%s status=%s",
job_id,
job.status,
)
return
audio_url = job.audio_url or ""
script = job.script_text or ""
user_video_url = job.video_url or ""
if not audio_url:
raise DittoError("job.audio_url 为空,无法调用 Ditto", code="InvalidParam")
settings = get_api_settings()
default_video_url = settings.ditto_default_video_url or ""
logger.info(
"[ditto_task] 开始 Ditto 生成: job_id=%s has_user_video=%s script_len=%d",
job_id,
bool(user_video_url),
len(script),
)
# ── 0. 探测音频时长(情绪分析 + 视频循环都需要)──
audio_duration = 0.0
audio_bytes_for_probe = None
try:
audio_bytes_for_probe = safe_download_bytes(
audio_url,
allowed_mime_types=("audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp3"),
timeout=30,
)
audio_duration = probe_audio_duration(audio_bytes_for_probe)
logger.info("[ditto_task] 音频时长: %.2fs", audio_duration)
except Exception as audio_exc:
logger.warning("[ditto_task] 音频时长探测失败: %s", audio_exc)
audio_duration = 0.0
# ── 1. LLM 情绪分析(生成 emo_timeline)──
emo_timeline = ""
try:
emo_svc = get_ditto_emotion_service()
if emo_svc.enabled and script and audio_duration > 0:
sentence_timings = getattr(job, "sentence_timings", None)
emo_timeline = emo_svc.build_timeline(
text=script,
audio_duration=audio_duration,
sentence_timings=sentence_timings,
)
if emo_timeline:
logger.info("[ditto_task] 情绪时间线已生成: segments≈%d", len(emo_timeline) // 50)
except Exception as emo_exc:
logger.warning("[ditto_task] 情绪分析异常(降级中性): %s", emo_exc)
emo_timeline = ""
# ── 2. 准备驱动视频(用户视频优先,必要时循环延长)──
prepared_video_url, _is_tmp = _prepare_driver_video(
user_video_url=user_video_url,
default_video_url=default_video_url,
audio_duration=audio_duration if audio_duration > 0 else 10.0, # 探测失败时按10s估
job_id=job_id,
user_id=user_id,
)
# ── 3. 调用 Ditto ──
client = get_ditto_client()
result = client.generate_and_persist(
job_id=job_id,
user_id=user_id,
audio_url=audio_url,
script=script,
video_url=prepared_video_url,
emo_timeline=emo_timeline,
)
# Ditto 返回的 MP4 自带音频,签名 OSS URL(7天有效)后标记完成
job.output_video_url = _sign_media_url(result.video_url)
# 探测时长(用于计费)
duration = _probe_video_duration(result.video_bytes)
if duration <= 0:
# 兜底:按音频时长估算
if audio_bytes_for_probe is not None:
try:
duration = probe_audio_duration(audio_bytes_for_probe)
except Exception:
duration = 0.0
if duration <= 0 and audio_duration > 0:
duration = audio_duration
job.output_duration = duration
job.status = "completed"
job.completed_at = datetime.now(UTC)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info(
"[ditto_task] Ditto 完成: job_id=%s url=%s duration=%.2fs rtf=%.2f frames=%d",
job_id,
result.video_url[:100],
duration,
result.rtf,
result.frames,
)
_settle_lip_sync(db, job, duration)
except DittoError as exc:
logger.error("[ditto_task] Ditto 失败,回退: job_id=%s code=%s err=%s", job_id, exc.code, exc)
if job is not None:
try:
db.rollback()
job = db.query(type(job)).filter_by(id=job_id).first() if hasattr(job, "id") else job
_fallback_to_gpu_then_mediakit(db, job)
except Exception as fallback_exc:
logger.exception("[ditto_task] 回退也失败 job_id=%s err=%s", job_id, fallback_exc)
try:
if job:
job.status = "failed"
job.error_message = f"Ditto 失败且回退异常: {exc}; fallback: {fallback_exc}"
job.error_code = "FallbackError"
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
pass
except Exception as exc:
logger.exception("[ditto_task] 未预期异常: job_id=%s err=%s", job_id, exc)
if job is not None:
try:
db.rollback()
job = db.query(type(job)).filter_by(id=job_id).first()
_fallback_to_gpu_then_mediakit(db, job)
except Exception as fallback_exc:
logger.exception("[ditto_task] 回退也失败 job_id=%s err=%s", job_id, fallback_exc)
try:
if job:
job.status = "failed"
job.error_message = f"Ditto 异常: {exc}"
job.error_code = "DittoAsyncError"
job.updated_at = datetime.now(UTC)
db.commit()
except Exception:
pass
finally:
db.close()
-29
View File
@@ -104,7 +104,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
job.updated_at = datetime.now(UTC)
db.commit()
logger.info("[lipsync_gpu_async] GPU 任务已被用户取消: job_id=%s", job_id)
_refund_lip_sync(db, job)
return
if final_task.status != "done":
@@ -142,7 +141,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
job_id,
job.output_duration,
)
_settle_lip_sync(db, job, final_task)
except Exception as exc:
logger.exception("[lipsync_gpu_async] 异常: job_id=%s err=%s", job_id, exc)
try:
@@ -159,33 +157,6 @@ def lipsync_gpu_process_async(self, job_id: str, user_id: str, gpu_task_id: str)
db.close()
def _settle_lip_sync(db: Session, job: LipsyncJobModel, gpu_task) -> None:
"""GPU 成功后结算:同步 credits_cost 到 gpu 任务并按实际时长多退少不补。"""
try:
from app.services.lipsync_service import LipsyncService
# GPU 任务表先同步结算结果(标记用)
LipsyncService._settle_lip_sync(job, float(getattr(gpu_task, "result_duration", 0) or 0.0))
gpu_task.credits_cost = float(job.credits_cost or 0.0)
db.commit()
except Exception: # noqa: BLE001
logger.exception("[lipsync_gpu_async] lip_sync 结算异常 job_id=%s(不阻塞)", job.id)
try:
db.rollback()
except Exception: # noqa: BLE001
pass
def _refund_lip_sync(db: Session, job: LipsyncJobModel) -> None:
"""GPU 取消/失败路径全额退款。"""
try:
from app.services.lipsync_service import LipsyncService
LipsyncService(db)._refund_lip_sync(job)
except Exception: # noqa: BLE001
logger.exception("[lipsync_gpu_async] lip_sync 退款异常 job_id=%s", job.id)
def _fallback_to_mediakit(db: Session, job: LipsyncJobModel) -> None:
"""GPU 失败时回退到 MediaKit 云端渲染。"""
try:
+1 -27
View File
@@ -261,33 +261,7 @@ def tts_synthesize_and_submit(
"[lipsync_tts] 句子时间戳计算失败(不影响主流程): job_id=%s err=%s", job_id, _st_err, exc_info=True
)
# 3. 优先走 Ditto(#2076):开关打开且配置完整时,派发 Ditto 异步任务,不再走 MediaKit
ditto_dispatched = False
try:
from packages.config import get_api_settings as _get_settings
_settings = _get_settings()
if _settings.use_ditto_lipsync and _settings.ditto_api_base_url and _settings.ditto_default_video_url:
from app.tasks.lipsync_ditto import lipsync_ditto_process_async
job.status = "processing"
job.mediakit_task_id = "ditto:tts-submitted"
job.updated_at = datetime.now(UTC)
db.commit()
lipsync_ditto_process_async.apply_async(args=(job_id, user_id))
logger.info("[lipsync_tts] TTS 完成,已派发 Ditto 任务: job_id=%s", job_id)
ditto_dispatched = True
except Exception as _ditto_err:
logger.warning("[lipsync_tts] Ditto 派发失败,回退 MediaKit: job_id=%s err=%s", job_id, _ditto_err)
try:
db.rollback()
except Exception:
pass
if ditto_dispatched:
return
# 4. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
# 3. 签名 URL 并提交到 MediaKit(复用模块内 _sign_media_url,避免对 LipsyncService 的耦合)
audio_url = _sign_media_url(job.audio_url)
video_url = _sign_media_url(job.video_url)
+139 -132
View File
@@ -11,34 +11,12 @@ const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".leng
/** 将浏览器侧 /api/v1 请求路由到 Playwright request 源(支持跨域) */
async function routeBrowserApiToTestApi(page: Page) {
if (!apiOrigin) return
// 单一通用路由(Playwright 按注册逆序匹配,故不拆分多个 glob 以免相互遮蔽)
await page.route("**/api/v1/**", async (route) => {
try {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
// /points/balance:Staging 后端积分总开关关闭(credits_enabled=false,免费放行)。
// 旧 bundle 部署的接口暂未返回该字段、且预校验只看余额,这里同时补开关与余额,
// 以验证“免费期不拦截生成”;新后端+新前端部署后读 credits_enabled=false 直接放行,
// 余额被忽略,此补丁随之成为 no-op。
if (sourceUrl.pathname.endsWith("/points/balance")) {
const body = await response.json().catch(() => ({}))
await route.fulfill({
response,
json: { ...body, credits_enabled: false, balance: 999999 },
})
return
}
await route.fulfill({ response })
} catch {
// 测试收尾时页面/上下文可能已关闭,忽略仍在途的请求,避免误判为失败
try {
await route.abort()
} catch {
/* noop */
}
}
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
@@ -67,13 +45,7 @@ async function loginWithRetry(
async function setupFreshUser(
request: APIRequestContext,
label: string,
): Promise<{
token: string
user: Record<string, unknown>
libraryId: string
assetId: string
suffix: string
}> {
): Promise<{ token: string; libraryId: string; assetId: string; suffix: string }> {
const suffix = Math.random().toString(36).slice(2, 8)
const email = `e2e-${label}-${suffix}@example.com`
await request.post(`${apiBase}/auth/register`, {
@@ -81,19 +53,7 @@ async function setupFreshUser(
})
const token = await loginWithRetry(request, email, PASSWORD)
const auth = { Authorization: `Bearer ${token}` }
const me = await request.get(`${apiBase}/auth/me`, { headers: auth })
expect(me.ok(), `fetch profile: ${await me.text()}`).toBeTruthy()
const user = (await me.json()) as Record<string, unknown>
// 预置一条文案:标题下拉候选来自文案库,新用户为空会导致无法选中标题
await request.post(`${apiBase}/scripts`, {
headers: auth,
data: {
title: `测试标题 ${suffix}`,
content: `测试文案内容 ${suffix}`,
tags: ["e2e"],
},
})
const proj = await request.post(`${apiBase}/projects`, {
headers: auth,
data: { name: `Smoke ${label} ${suffix}` },
@@ -133,7 +93,7 @@ async function setupFreshUser(
{ timeout: 90_000, intervals: [3000, 3000, 5000] },
)
.toBe("ready")
return { token, user, libraryId, assetId, suffix }
return { token, libraryId, assetId, suffix }
}
/**
@@ -152,7 +112,7 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
test("random mode: 5-step wizard creates generation task", async ({ page, request }) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, user, suffix } = await setupFreshUser(request, "random")
const { token, suffix } = await setupFreshUser(request, "random")
const authHeader = { Authorization: `Bearer ${token}` }
// 确保默认模板存在(智能剪辑页依赖模板)
@@ -166,24 +126,13 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
expect(templates.length).toBeGreaterThan(0)
// 注入登录态 + 路由 API
await page.addInitScript(
({ token, user }) => {
window.localStorage.setItem("access_token", token)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({
state: {
user,
isAuthenticated: true,
accessToken: token,
refreshToken: null,
},
version: 0,
}),
)
},
{ token, user },
)
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── 提前 mock 配音列表(VoiceSelectModal 查询 /assets?kind=voice) ──
@@ -229,28 +178,16 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await expect(page.getByText("🎙️ 选择配音")).not.toBeVisible()
// ── Step 2:选择素材 ──────────────────────────────────────────
await expect(page.getByText(/选择素材/).first()).toBeVisible({ timeout: 10000 })
// 卡片中心是播放按钮(stopPropagation 仅播放不选中),点右上角空白处完成选中
await page
.getByTestId("material-card")
.first()
.click({ position: { x: 70, y: 12 } })
await expect(page.getByText(/已选 1 个素材|已选[^0]*[1-9]/)).toBeVisible({ timeout: 5000 })
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 3:填写标题 ──────────────────────────────────────────
// (#2048: PreviewCountModal 已移除,生成数量在 Step1 内设置)
await expect(page.getByText(/选择标题/).first()).toBeVisible({ timeout: 10000 })
// 标题为 antd AutoComplete(combobox),真实 input 带 placeholder
// antd AutoComplete combobox:真实可输入元素是 .ant-select-selection-search-input,
// 灰色提示语是单独的 placeholder span(input 自身无 placeholder 属性)
// 标题候选来自文案库(setupFreshUser 已预置一条文案)。
// combobox 的自由输入会在失焦时被 rc-select 重置,必须从下拉选中才提交,
// 因此聚焦输入框 → 点击候选选项
const seededTitle = `测试标题 ${suffix}`
const titleBox = page.locator(".ant-select-selection-search-input:visible").first()
await titleBox.click()
await page.locator(".ant-select-item-option", { hasText: seededTitle }).first().click()
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput).toBeVisible({ timeout: 5000 })
await titleInput.fill(`测试随机剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
@@ -267,11 +204,9 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await confirmBtn.click()
const taskResp = await createTask
expect(taskResp.ok(), `Create task: ${await taskResp.text()}`).toBeTruthy()
const taskBody = await taskResp.json()
const taskId = taskBody.items?.[0]?.id ?? taskBody.id ?? taskBody.task_id
expect(taskId, "created task should return an id").toBeTruthy()
const taskId = (await taskResp.json()).id ?? (await taskResp.json()).task_id
console.log("[random] Generation task created:", taskId)
await expect(page.getByText(/正在生成|提交/).first()).toBeVisible({ timeout: 15000 })
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[random] Wizard flow completed ✓")
})
@@ -281,28 +216,113 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
}) => {
test.setTimeout(600_000)
await page.setViewportSize({ width: 1440, height: 1000 })
const { token, user, suffix } = await setupFreshUser(request, "narrative")
const { token, suffix } = await setupFreshUser(request, "narrative")
await page.addInitScript(
({ token, user }) => {
window.localStorage.setItem("access_token", token)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({
state: {
user,
isAuthenticated: true,
accessToken: token,
refreshToken: null,
},
version: 0,
}),
)
},
{ token, user },
)
await page.addInitScript((t: string) => {
window.localStorage.setItem("access_token", t)
window.localStorage.setItem(
"auth-storage",
JSON.stringify({ state: { token: t, user: null } }),
)
}, token)
await routeBrowserApiToTestApi(page)
// ── Mock 文案列表、音色、TTS 合成(避免真实合成) ──────────────
const mockScriptId = `script-mock-${suffix}`
const mockVoiceId = `preset-voice-${suffix}`
const mockJobId = `tts-job-${suffix}`
// 文案列表(ScriptSelectModal 查询 /scripts)
await page.route("**/api/v1/scripts**", (route) => {
const url = new URL(route.request().url())
if (url.pathname.includes("/extract-from-douyin")) {
route.continue()
return
}
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: mockScriptId,
title: "测试带货文案",
content: "这是一段测试用的带货文案内容,用于 E2E 冒烟测试。",
tags: ["带货"],
title_category: "daihuo",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
total: 1,
page: 1,
page_size: 200,
}),
})
})
// 预设音色(TtsVoiceModal 查询 GET /voices/presets)
await page.route("**/api/v1/voices/presets**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
voice_id: mockVoiceId,
name: "晓晓(女声)",
description: "温柔女声",
gender: "female",
language: "zh-CN",
preview_url: null,
tags: ["温柔"],
},
],
total: 1,
}),
}),
)
// 克隆音色:空列表
await page.route(
(url) => url.pathname.endsWith("/voice-clones"),
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
}),
)
// TTS 合成:直接返回 completed 任务
await page.route("**/api/v1/tts/synthesize", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ job_id: mockJobId, status: "queued" }),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/status`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
job_id: mockJobId,
status: "completed",
progress: 100,
audio_url: "data:audio/mpeg;base64,",
duration: 5,
}),
}),
)
await page.route(`**/api/v1/tts/jobs/${mockJobId}/save-to-library`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: `tts-asset-${suffix}`, name: "AI合成配音" }),
}),
)
await page.goto("/app/generate")
// ── 页面标题 ─────────────────────────────────────────────────
await expect(page.getByText("智能剪辑").first()).toBeVisible({ timeout: 30000 })
@@ -314,38 +334,28 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
// ── 文案选择弹窗:选第一条 → 确认 ─────────────────────────────
await expect(page.getByText("📝 选择文案")).toBeVisible({ timeout: 5000 })
await page.getByText(`测试标题 ${suffix}`).first().click()
await page.getByText("测试带货文案").first().click()
await page.getByRole("button", { name: "确认选择" }).click()
await expect(page.getByText("📝 选择文案")).not.toBeVisible()
// ── TTS 音色弹窗:选系统音色 → 合成 ─────────────────────────
await expect(page.getByText("🎙️ 合成配音")).toBeVisible({ timeout: 5000 })
await page.getByText("龙小夏").first().click()
await page.getByText("晓晓(女声)").first().click()
await page.getByRole("button", { name: "🎧 合成配音" }).click()
// 真实阿里云 CosyVoice 合成耗时偶有波动,放宽到 90s
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 90000 })
await expect(page.getByText("🎙️ 合成配音")).not.toBeVisible({ timeout: 30000 })
// ── Step 2:AI 匹配提示卡可见 + 选素材 ────────────────────────
await expect(page.getByText(/选择素材/).first()).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/).first()).toBeVisible()
// 卡片中心是播放按钮(stopPropagation 仅播放不选中),点右上角空白处完成选中
await page
.getByTestId("material-card")
.first()
.click({ position: { x: 70, y: 12 } })
await expect(page.getByText(/已选 1 个素材|已选[^0]*[1-9]/)).toBeVisible({ timeout: 5000 })
await expect(page.getByText("选择素材", { exact: true })).toBeVisible({ timeout: 10000 })
await expect(page.getByText(/AI智能匹配/)).toBeVisible()
await page.getByTestId("material-card").first().click()
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 3:填写标题(handleScriptModalConfirm 已预填 script.title,但我们再覆盖一次) ─
// (#2048: PreviewCountModal 已移除)
await expect(page.getByText(/选择标题/).first()).toBeVisible({ timeout: 10000 })
// 选中文案后标题框已预填该文案标题,下拉按当前输入过滤,直接选中该选项确认
const titleBox2 = page.locator(".ant-select-selection-search-input:visible").first()
await titleBox2.click()
await page
.locator(".ant-select-item-option", { hasText: `测试标题 ${suffix}` })
.first()
.click()
await expect(page.getByText("选择标题", { exact: true })).toBeVisible({ timeout: 10000 })
const titleInput2 = page.getByPlaceholder("输入或从标题库选择")
await expect(titleInput2).toBeVisible({ timeout: 5000 })
await titleInput2.fill(`测试叙事剪辑 ${suffix}`)
await page.getByRole("button", { name: /下一步/ }).click()
// ── Step 4:确认生成 ──────────────────────────────────────────
@@ -361,11 +371,8 @@ test.describe("Core Smart-Edit Flow (#1970)", () => {
await confirmBtn2.click()
const taskResp2 = await createTask2
expect(taskResp2.ok(), `Create task: ${await taskResp2.text()}`).toBeTruthy()
const taskBody2 = await taskResp2.json()
const taskId2 = taskBody2.items?.[0]?.id ?? taskBody2.id ?? taskBody2.task_id
expect(taskId2, "created task should return an id").toBeTruthy()
console.log("[narrative] Generation task created:", taskId2)
await expect(page.getByText(/正在生成|提交/).first()).toBeVisible({ timeout: 15000 })
console.log("[narrative] Generation task created:", (await taskResp2.json()).id)
await expect(page.getByText(/正在生成|提交/)).toBeVisible({ timeout: 15000 })
console.log("[narrative] Wizard flow completed ✓")
})
})
+7 -15
View File
@@ -11,20 +11,11 @@ const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".leng
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
if (!apiOrigin) return
await page.route("**/api/v1/**", async (route) => {
try {
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
} catch {
// 收尾时页面可能已关闭,忽略在途请求避免误判
try {
await route.abort()
} catch {
/* noop */
}
}
const sourceUrl = new URL(route.request().url())
const response = await route.fetch({
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
})
await route.fulfill({ response })
})
}
@@ -54,7 +45,8 @@ type LibraryResponse = { id: string }
test.describe("Core media upload flow", () => {
test.describe.configure({ timeout: 180_000 })
test("uploads a video asset and shows it in the asset library", async ({ page, request }) => {
// 串行执行时登录可能触发 429,两次退避约 130s,沿用 describe 的 180s 超时
test.setTimeout(120_000)
await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36)
const email = `e2e-mov-${suffix}@example.com`
-12
View File
@@ -14,7 +14,6 @@
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"marked": "^12.0.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -4503,17 +4502,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmmirror.com/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
-1
View File
@@ -25,7 +25,6 @@
"axios": "^1.7.2",
"classnames": "^2.5.1",
"dayjs": "^1.11.23",
"marked": "^12.0.2",
"mp4box": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
-41
View File
@@ -1,41 +0,0 @@
/**
* 运行时开关安全读取
*
* 背景:本项目使用 Vite 构建,浏览器运行时不存在 Node 的全局 `process`。
* 直接写 `process.env.XXX` 会在模块加载阶段抛出 `ReferenceError: process is not defined`,
* 导致整个页面白屏崩溃。
*
* 统一通过本模块读取这类仅在构建/调试期注入的布尔开关:
* - 优先读取 Vite 的 `import.meta.env.VITE_XXX`
* - 兼容历史上未加 VITE_ 前缀、经由 Node 环境(单测 / 旧构建脚本)注入的 `process.env.XXX`
* - 任何情况下访问失败都安全返回 false(默认走真实后端 API,不启用 mock)
*/
/** 从可能不存在的 Node 全局 process 上安全读取环境变量 */
function readNodeEnv(name: string): string | undefined {
try {
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
return proc?.env?.[name]
} catch {
return undefined
}
}
/**
* 读取运行时布尔开关。
*
* @param name 开关名(不含 VITE_ 前缀的历史名称,如 POINTS_API_MOCK)
* @returns 开关是否显式置为 "true";未设置或读取失败时为 false
*/
export function readRuntimeFlag(name: string): boolean {
// Vite 注入的环境变量(需 VITE_ 前缀才会暴露到浏览器)
const viteKey = `VITE_${name}`
const viteEnv = (import.meta as unknown as { env?: Record<string, string | undefined> }).env
const viteVal = viteEnv?.[viteKey] ?? viteEnv?.[name]
// 兼容 Node 环境下无前缀的历史变量名
const nodeVal = readNodeEnv(name)
const raw = viteVal ?? nodeVal
return raw === "true"
}
+8 -9
View File
@@ -8,7 +8,6 @@
* 会员/订阅 API 在 @/api/subscription 中定义,避免重复封装。
*/
import apiClient from "../client"
import { readRuntimeFlag } from "../env-flags"
import type {
PointsBalance,
PointsRulesResponse,
@@ -202,7 +201,7 @@ const MOCK_MEMBERSHIP: MembershipResponse = {
/** 获取积分余额 */
export async function getPointsBalance(): Promise<PointsBalance> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_BALANCE }
}
@@ -212,7 +211,7 @@ export async function getPointsBalance(): Promise<PointsBalance> {
/** 获取积分消耗规则 */
export async function getPointsRules(): Promise<PointsRulesResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { rules: [...MOCK_RULES.rules], free_user_multiplier: MOCK_RULES.free_user_multiplier }
}
@@ -222,7 +221,7 @@ export async function getPointsRules(): Promise<PointsRulesResponse> {
/** 获取充值包列表 */
export async function getPointsPackages(): Promise<PointsPackagesResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { packages: MOCK_PACKAGES.packages.map((p) => ({ ...p })), user_discount: null }
}
@@ -237,7 +236,7 @@ export async function getPointsTransactions(
page = 1,
pageSize = 20,
): Promise<PointsTransactionsResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const start = (page - 1) * pageSize
const items = MOCK_TRANSACTIONS.slice(start, start + pageSize)
@@ -262,7 +261,7 @@ export async function getPointsTransactions(
export async function createPointsOrder(
data: CreateRechargeOrderRequest,
): Promise<CreateRechargeOrderResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY * 2))
const pkg = MOCK_PACKAGES.packages.find((p) => p.code === data.package_id)
if (!pkg) throw new Error("充值包不存在")
@@ -286,7 +285,7 @@ export async function createPointsOrder(
* 积分预检查(消耗前调用)
*/
export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheckResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
const rule = MOCK_RULES.rules.find((r) => r.scene_key === data.scene_key)
if (!rule) {
@@ -327,7 +326,7 @@ export async function checkPoints(data: PointsCheckRequest): Promise<PointsCheck
/** 获取每日免费额度使用情况 */
export async function getDailyUsage(): Promise<DailyUsage> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_DAILY_USAGE }
}
@@ -337,7 +336,7 @@ export async function getDailyUsage(): Promise<DailyUsage> {
/** 获取会员聚合信息(创作页可用来判断 max_resolution) */
export async function getMembership(): Promise<MembershipResponse> {
if (readRuntimeFlag("POINTS_API_MOCK")) {
if (process.env.POINTS_API_MOCK === "true") {
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY))
return { ...MOCK_MEMBERSHIP }
}
-2
View File
@@ -50,8 +50,6 @@ export interface PointsBalance {
member_type: "monthly" | "quarterly" | "yearly" | null
/** 会员到期时间 */
member_expires_at: ISODate | null
/** 后端积分系统是否启用(false=免费放行,不做余额预校验) */
credits_enabled?: boolean
}
/* ================================================================
+1 -2
View File
@@ -3,7 +3,6 @@
* CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true)
*/
import apiClient from "../client"
import { readRuntimeFlag } from "../env-flags"
import type {
ScriptItem,
ScriptListParams,
@@ -17,7 +16,7 @@ import type {
* #1894:文案库接口已上线,默认 false 走真实 API;
* 通过 SCRIPTS_API_MOCK=true 环境变量可本地开启 mock 调试(行为同 POINTS_API_MOCK)。
*/
export const SCRIPTS_API_MOCK = readRuntimeFlag("SCRIPTS_API_MOCK")
export const SCRIPTS_API_MOCK = (process.env.SCRIPTS_API_MOCK as string | undefined) === "true"
// ==================== Mock 数据 ====================
@@ -6,7 +6,6 @@
* 所有请求走 apiClient(已配置 baseURL=/api/v1 和 token 拦截器)。
*/
import apiClient from "../client"
import { readRuntimeFlag } from "../env-flags"
import type {
SubscriptionInfo,
SubscriptionPlan,
@@ -73,7 +72,7 @@ const MOCK_PLANS: SubscriptionPlan[] = [
const MOCK_BILLING: BillingRecord[] = []
const isMock = () => readRuntimeFlag("POINTS_API_MOCK")
const isMock = () => (process.env.POINTS_API_MOCK as string | undefined) === "true"
/** 获取当前订阅 */
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
+16
View File
@@ -1,5 +1,6 @@
import apiClient from "@/api/client"
import type {
GenerateViralVideoRequest,
HistoryResponse,
StyleTemplate,
ViralVideoJob,
@@ -12,11 +13,26 @@ import type {
ViralVideoModelsResponse,
} from "./types"
/** 创建爆款视频任务 */
export function generateViralVideo(payload: GenerateViralVideoRequest) {
return apiClient.post<ViralVideoJob>("/viral-video/generate", payload).then((r) => r.data)
}
/** 查询单个任务 */
export function getViralVideoJob(id: string) {
return apiClient.get<ViralVideoJob>(`/viral-video/${id}`).then((r) => r.data)
}
/** 用户确认/修改 AI 理解的意图后继续 */
export function confirmViralVideoIntent(
id: string,
payload: { confirmed_copy?: string; edits?: Record<string, unknown> },
) {
return apiClient
.post<ViralVideoJob>(`/viral-video/${id}/confirm-intent`, payload)
.then((r) => r.data)
}
/** 重试失败任务 */
export function retryViralVideo(id: string) {
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/retry`).then((r) => r.data)
+69 -52
View File
@@ -21,15 +21,23 @@ export const VALID_RATIOS = ["9:16", "16:9", "1:1"] as const
export type VideoRatio = (typeof VALID_RATIOS)[number]
export type ViralVideoStatus =
"pending" | "running" | "image_analyzed" | "copy_generated" | "completed" | "failed" | "cancelled"
| "pending"
| "running"
| "wait_user_confirm"
| "image_analyzed"
| "copy_generated"
| "completed"
| "failed"
| "cancelled"
/**
* v1.6 后端流水线阶段。单次 Seedance 出片版:
* image_analysis → video_analysis(可选) → script_generation(含意图理解) → review → tts → rendering → uploading
* image_analysis → video_analysis(可选) → intent_parsing → script_generation → review → tts → rendering → uploading
*/
export type ViralVideoStage =
| "image_analysis"
| "video_analysis"
| "intent_parsing"
| "script_generation"
| "review"
| "tts"
@@ -39,7 +47,7 @@ export type ViralVideoStage =
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
const COPY_STAGES = new Set<ViralVideoStage>(["script_generation", "review"])
const COPY_STAGES = new Set<ViralVideoStage>(["intent_parsing", "script_generation", "review"])
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
@@ -57,39 +65,27 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
return isImageAnalysisStage(stage) || isCopyStage(stage)
}
/** 从 job 对象取到有效阶段(兼容 progress_stage/current_stage 两种字段名) */
export function getJobStage(
job: { current_stage?: ViralVideoStage; progress_stage?: ViralVideoStage } | null | undefined,
): ViralVideoStage | undefined {
if (!job) return undefined
return job.current_stage || job.progress_stage
}
/** 从 job 对象取到阶段提示文案(兼容 phase_message/progress_message) */
export function getJobPhaseMessage(
job: { phase_message?: string; progress_message?: string } | null | undefined,
): string {
if (!job) return ""
return job.phase_message || job.progress_message || ""
}
/** 单张图片 VLM 识别结果(v8 叙述优先,仅保留最少结构化字段) */
/** 单张图片 VLM 识别出的商品信息 */
export interface ImageProductAnalysis {
/** store / product / person / scene */
type?: string
name?: string
brand?: string
has_person?: boolean
/** v8: 用户端展示用的叙述 markdown(由提示词控制排版) */
summary_markdown?: string
/** 标题行兼容字段 */
category?: string
brand?: string
colors?: string[]
material_or_texture?: string
key_features?: string[]
visual_style?: string
scene?: string
target_audience_hint?: string
text_on_image?: string
/** 旧字段兼容 */
spec?: string
features?: string[] | string
label_text?: string
selling_points?: string
image_index?: number
}
export interface ImageAnalysisResult {
/** v8 字段 */
images?: ImageProductAnalysis[]
/** 老数据兼容 */
products?: ImageProductAnalysis[]
}
@@ -97,8 +93,6 @@ export interface ImageAnalysisResult {
export interface ShotScript {
/** 时间区间,如 "0-3秒" */
time_range?: string
/** 说话人角色,如 "店主"、"顾客" */
speaker?: string
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
shot_type_angle_movement?: string
/** 场景描述+对白 */
@@ -138,8 +132,6 @@ export interface CopyResult {
/** 向后兼容:= voiceover_script */
suggested_copy?: string
title?: string
/** v3 storyboard: 用户端展示用的 markdown 文案(由提示词控制排版) */
copy_display_markdown?: string
/** v1.5 旧字段兼容(老数据降级时可能出现) */
scenes?: Array<{ shot: string; narration: string; duration?: number }>
}
@@ -153,6 +145,22 @@ export interface StyleTemplate {
tags?: string[]
}
export interface IntentResult {
intent?: string
key_messages?: string[]
tone?: string
target_emotion?: string
call_to_action?: string
suggested_title?: string
/** v1.5 旧字段兼容 */
product?: string
selling_points?: string[]
target_audience?: string
structure?: string
duration?: number
suggested_copy?: string
}
export interface ViralVideoJob {
id: string
status: ViralVideoStatus
@@ -170,6 +178,8 @@ export interface ViralVideoJob {
voice_mode?: "global" | "per_video"
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
bgm_preference?: string
intent_result?: IntentResult
intent_text?: string
/** v1.6 编导分镜脚本(核心产物) */
copy_result?: CopyResult
/** 向后兼容:= copy_result.shots */
@@ -181,14 +191,8 @@ export interface ViralVideoJob {
video_model?: string
/** 视频时长(秒,5-30,默认15) */
duration?: number
/** 后端真实字段名(snake_case):当前细粒度阶段 */
current_stage?: ViralVideoStage
/** 后端真实字段名:当前阶段中文提示文案 */
phase_message?: string
/** @deprecated 兼容旧前端:使用 current_stage */
progress_stage?: ViralVideoStage
progress_percent?: number
/** @deprecated 兼容旧前端:使用 phase_message */
progress_message?: string
output_url?: string
result_video_url?: string
@@ -199,6 +203,31 @@ export interface ViralVideoJob {
updated_at?: string
}
export interface GenerateViralVideoRequest {
images: string[]
reference_video_url?: string
douyin_url?: string
style_strength?: StyleStrength
style_template_id?: string
user_copy_text?: string
fusion_level?: FusionLevel
voice_id?: string
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
bgm_preference?: string
industry?: string
target_customer?: string
language?: string
persona_id?: string
viral_structure?: string
marketing_purpose?: string
/** 视频时长(5-30秒,默认15) */
duration?: number
video_model?: string
video_ratio?: string
/** 三步拆分:step 控制后端执行到哪一步暂停 */
step?: "analyze" | "generate_copy" | "generate_video"
}
export interface HistoryResponse {
items: ViralVideoJob[]
total: number
@@ -274,18 +303,6 @@ export interface ConfirmCopyRequest {
edited_copy?: string
/** 视频模型 key,覆盖默认 */
video_model?: string
/** 分辨率,如 480p/720p/1080p */
video_resolution?: string
/** 画面比例,如 9:16/16:9/1:1 */
video_ratio?: string
/** 成片时长秒数 */
duration?: number
/** 音色 ID(CosyVoice voice_id 或 preset id) */
voice_id?: string
/** 音色来源:preset/library/upload/clone/my_voice */
voice_source?: string
/** 口播语言 */
language?: string
}
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
@@ -8,6 +8,11 @@ export const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/products": "成片库",
"/app/templates": "模板库",
"/app/history": "任务历史",
"/app/admin": "控制台",
"/app/admin/users": "用户管理",
"/app/admin/analytics": "数据分析",
"/app/admin/monitor": "系统监控",
"/app/admin/logs": "系统日志",
"/app/subscription": "订阅管理",
"/app/subscription/upgrade": "升级订阅",
"/app/subscription/billing": "账单管理",
+7
View File
@@ -13,6 +13,7 @@ import {
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
ControlOutlined,
CrownOutlined,
ThunderboltOutlined,
UnorderedListOutlined,
@@ -206,6 +207,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/duplication",
icon: React.createElement(ScanOutlined),
},
{
key: "admin",
label: "控制台",
path: "/app/admin",
icon: React.createElement(ControlOutlined),
},
{
key: "subscription",
label: "会员订阅",
+27
View File
@@ -0,0 +1,27 @@
/* Admin 页面样式(Phase 3 精简)
*
* 原始 477 行 → 精简至仅保留实际使用的 class。
* 已迁移至 global.css / ui.css 的样式不再重复定义:
* .xx-page-head → global.css
* .xx-primary-btn → global.css
* .xx-tag / .xx-card → ui.css / global.css
*
* 以下 class 仅被 AdminComingSoon.tsx 使用。
*/
.admin-coming-soon-page {
padding: 32px;
max-width: 1680px;
margin: 0 auto;
}
.xx-result-center {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.xx-result-center .ant-result {
padding: 48px;
}
@@ -0,0 +1,35 @@
import React from "react"
import { Button, Card, Result } from "antd"
import { useNavigate } from "react-router-dom"
import "./Admin.css"
const AdminComingSoon: React.FC = () => {
const navigate = useNavigate()
return (
<div className="admin-coming-soon-page">
<div className="xx-result-center">
<Card className="xx-card">
<Result
status="info"
title="Admin 后台暂未开放"
subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。"
extra={[
<Button
type="primary"
onClick={() => navigate("/app/dashboard")}
className="xx-primary-btn"
>
返回首页
</Button>,
]}
/>
</Card>
</div>
</div>
)
}
export default AdminComingSoon
export const Component = AdminComingSoon
+4 -8
View File
@@ -38,8 +38,6 @@ const GeneratePage: React.FC = () => {
/* ── 积分状态 ── */
const { balance, dailyUsage, rules, init: initPoints } = usePointsStore()
// 后端积分总开关(由 /points/balance 返回;数据未就绪时保守按 UI 开关处理)
const creditsEnabled = balance?.credits_enabled ?? ENABLE_CREDIT_SYSTEM
useEffect(() => {
initPoints()
}, [initPoints])
@@ -344,8 +342,7 @@ const GeneratePage: React.FC = () => {
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查(积分系统关闭时跳过,直接走生成流程)
let check: ReturnType<typeof hasEnoughPoints> = { sufficient: true, cost: 0 }
// 仅当后端积分系统真实启用时才做余额预校验(后端免费放行时前端不得拦截)
if (creditsEnabled) {
if (ENABLE_CREDIT_SYSTEM) {
const units = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
check = hasEnoughPoints(
balance ?? null,
@@ -391,7 +388,6 @@ const GeneratePage: React.FC = () => {
balance,
dailyUsage,
rules,
creditsEnabled,
])
/* ── 步骤导航 ── */
@@ -506,7 +502,7 @@ const GeneratePage: React.FC = () => {
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
const pointsEstimate = useMemo(() => {
if (!creditsEnabled) return { sufficient: true, cost: 0 }
if (!ENABLE_CREDIT_SYSTEM) return { sufficient: true, cost: 0 }
return hasEnoughPoints(
balance ?? null,
unitsForCost,
@@ -515,8 +511,8 @@ const GeneratePage: React.FC = () => {
"free",
rules?.free_user_multiplier ?? 1.15,
)
}, [unitsForCost, balance, dailyUsage, rules, creditsEnabled])
const insufficientPoints = creditsEnabled && !pointsEstimate.sufficient
}, [unitsForCost, balance, dailyUsage, rules])
const insufficientPoints = ENABLE_CREDIT_SYSTEM && !pointsEstimate.sufficient
/* ================================================================
渲染
@@ -1,128 +0,0 @@
import type { ViralVideoModel } from "@/api/viral-video/types"
export const LANGUAGES = ["中文(普通话)", "粤语", "英语", "日语", "韩语"]
export const TARGET_CUSTOMERS = [
"小镇青年",
"小镇中老年",
"Z世代",
"精致妈妈",
"新锐白领",
"资深中产",
"都市蓝领",
"都市银发",
]
export const PERSONAS = [
"通用个人IP",
"老板型IP",
"专家型IP",
"顾问型IP",
"创始人IP",
"创业者IP",
"从业者经验派",
"避坑顾问型",
"知识科普型",
"测评种草型",
]
export const STRUCTURES = [
"反差破局+亮明观点+还原现状",
"痛点场景植入+给出通关秘籍",
"反常识开场+扎心现状+制造焦虑",
"核心难题+化解焦虑+描绘愿景",
"痛点前置+前后对比+给出期许",
"悬念拉满+揭露危害+明确目的",
"情绪共鸣+勾起好奇+拔高价值",
"颠覆认知+现状拆解+赋予价值",
"结果先行+消除顾虑+保留悬念",
"真实困境+认知反转+传授秘籍",
"反差事实+案例佐证+戳破真相",
"预埋悬念+标杆案例+引出核心",
"危机渲染+放大痛点+输出干货",
"逆袭开场+成功诀窍+低价引流",
"反常识观点+分析危害+描述现状",
"正反对比+解析原因+简易方法",
"真实场景+内心刻画+共情引导+展望未来",
"连续否定+代入感受+打破认知+精准戳痛",
"揭秘行业+类比施压+放大焦虑+精准锁客",
]
export const PURPOSES = [
"获客引流",
"账号涨粉",
"活动通知",
"口播带货",
"促销转化",
"场景种草",
"痛点解决",
"功能演示",
"门店发现",
"到店实录",
"招牌体验",
"同城团购",
"品牌主张",
"品牌故事",
"生活方式",
"创意概念",
"对话短剧",
"反转短剧",
"悬念短剧",
"情绪短片",
]
export const RATIOS = [
{ v: "9:16", label: "9:16 竖屏(抖音/视频号)" },
{ v: "16:9", label: "16:9 横屏(B站/YouTube)" },
{ v: "1:1", label: "1:1 方形(小红书)" },
]
export const DURATIONS = Array.from({ length: 16 }, (_, i) => 15 + i)
/** 兜底模型列表(接口未返回时使用) */
export const FALLBACK_VIDEO_MODELS: ViralVideoModel[] = [
{
key: "seedance-2.5",
display_name: "Seedance 2.5 — 最新最强",
supports_audio: true,
supported_resolutions: ["480p", "720p", "1080p"],
max_duration: 30,
is_default: true,
},
{
key: "seedance-2.0",
display_name: "Seedance 2.0 — 正式首选",
supports_audio: true,
supported_resolutions: ["480p", "720p", "1080p"],
max_duration: 15,
},
{
key: "seedance-2.0-fast",
display_name: "Seedance 2.0 Fast — 快速测试",
supports_audio: true,
supported_resolutions: ["480p", "720p"],
max_duration: 15,
},
{
key: "seedance-2.0-mini",
display_name: "Seedance 2.0 Mini — 低成本",
supports_audio: true,
supported_resolutions: ["480p", "720p"],
max_duration: 15,
},
{
key: "wan-3.0",
display_name: "Wan 3.0 — 通义万相全能",
supports_audio: true,
supported_resolutions: ["480p", "720p", "1080p"],
max_duration: 15,
},
]
export const RESOLUTION_ORDER = ["480p", "720p", "1080p", "4k"]
export const QUALITY_OPTIONS = [
{ v: "480p", label: "480p(快速)" },
{ v: "720p", label: "720p(清晰)" },
{ v: "1080p", label: "1080p(高清)" },
]
export const UPSCALE_OPTIONS = [
{ v: "default", label: "默认" },
{ v: "sd", label: "普清" },
{ v: "hd", label: "高清" },
{ v: "uhd", label: "超清" },
{ v: "omnipotent", label: "全能" },
]
export const VOICE_TIPS =
"支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。"
+188 -418
View File
@@ -138,6 +138,27 @@
border-radius: 12px;
overflow: hidden;
}
.vv-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid #f0f0f5;
gap: 8px;
}
.vv-section.collapsed .vv-section-head {
border-bottom: none;
}
.vv-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.vv-step-badge {
width: 22px;
height: 22px;
@@ -289,6 +310,11 @@
/* antd Select dropdown light */
.vv-page .ant-select-dropdown,
.vv-select-dropdown {
background: #fff !important;
border-radius: 8px !important;
padding: 4px !important;
}
.vv-page .ant-select-item {
color: #1f2937 !important;
border-radius: 6px !important;
@@ -629,6 +655,10 @@
margin-left: auto;
}
.vv-douyin-input {
margin-bottom: 8px;
}
.vv-ref-video-box {
margin-top: 8px;
}
@@ -639,6 +669,44 @@
max-height: 200px;
}
.vv-audio-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.vv-audio-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 14px 8px;
background: #fff;
border: 1px solid #e0e0e8;
border-radius: 8px;
color: #6b7280;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.vv-audio-cell:hover:not(:disabled) {
border-color: #7c3aed;
color: #7c3aed;
}
.vv-audio-cell.selected,
.vv-audio-cell.active {
background: #f5f0ff;
border-color: #7c3aed;
color: #7c3aed;
}
.vv-audio-cell:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.vv-audio-ic {
font-size: 20px;
}
.vv-selected-audio {
display: flex;
align-items: center;
@@ -789,6 +857,12 @@
color: #6b7280;
flex-shrink: 0;
}
.vv-recog-v {
color: #1f2937;
}
.vv-recog-meta {
color: #9ca3af;
}
/* progress mini */
.vv-progress-mini-inline {
@@ -928,6 +1002,11 @@
.vv-form-row {
margin-bottom: 10px;
}
.vv-form-hint {
font-size: 12px;
color: #9ca3af;
line-height: 1.4;
}
@media (max-width: 500px) {
.vv-form-grid {
grid-template-columns: 1fr;
@@ -941,11 +1020,29 @@
border-radius: 10px;
padding: 10px 12px;
margin-top: 8px;
}
.vv-copy-head {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 260px;
overflow: hidden;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.vv-copy-title {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.vv-copy-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 400;
}
.vv-copy-tag-preview {
background: #fef3c7;
color: #b45309;
border: 1px solid #fcd34d;
margin-left: auto;
}
.vv-copy-loading {
@@ -953,14 +1050,10 @@
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1 1 auto;
min-height: 260px;
padding: 28px 16px;
gap: 10px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
margin-top: 8px;
}
.vv-copy-loading .vv-spinner {
width: 28px;
@@ -978,41 +1071,29 @@
color: #6b7280;
}
.vv-copy-locked {
background: #fafafe;
border-color: #d8cafc;
}
.vv-copy-sub {
font-size: 12px;
color: #6b7280;
}
/* ── Storyboard (linear doc style) ── */
.vv-storyboard {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 260px;
padding: 10px 12px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
margin-top: 8px;
overflow: hidden;
padding: 6px 2px;
background: transparent;
border: none;
}
.vv-sb-doc {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 3px;
color: #1f2937;
font-size: 13px;
line-height: 1.55;
overflow-y: auto;
padding-right: 4px;
margin-right: -4px;
}
.vv-sb-doc::-webkit-scrollbar {
width: 6px;
}
.vv-sb-doc::-webkit-scrollbar-thumb {
background: #d8c4ff;
border-radius: 3px;
}
.vv-sb-doc::-webkit-scrollbar-track {
background: transparent;
}
.vv-sb-h {
margin: 6px 0 2px;
@@ -1302,14 +1383,12 @@
/* 口播稿 —— 复用 vv-sb-field 样式,无额外需求 */
.vv-sb-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
background: #fff;
}
.vv-sb-actions .vv-btn-ghost {
padding: 6px 14px;
@@ -1336,8 +1415,7 @@
/* ── Preview ──────────────── */
.vv-preview {
aspect-ratio: 9 / 16;
max-height: 100%;
max-width: 100%;
max-height: 360px;
background: #fff;
border: 1px dashed #e5e7eb;
border-radius: 10px;
@@ -1678,10 +1756,84 @@
}
/* intent (keyword chips) - if used */
.vv-intent {
margin-top: 10px;
}
.vv-intent-title {
font-size: 12px;
color: #6b7280;
margin-bottom: 6px;
}
.vv-intent-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.vv-chip {
padding: 4px 10px;
background: #fff;
border: 1px solid #e0e0e8;
border-radius: 14px;
font-size: 12px;
color: #374151;
cursor: pointer;
}
.vv-chip.picked {
background: #f5f0ff;
color: #7c3aed;
border-color: #7c3aed;
}
/* misc helpers */
.vv-k {
color: #6b7280;
}
.vv-v {
color: #1f2937;
}
.vv-hint-yellow {
background: #fef3c7;
border: 1px solid #fcd34d;
color: #b45309;
font-size: 12px;
padding: 8px;
border-radius: 6px;
line-height: 1.6;
margin-top: 8px;
}
.vv-hint-gray {
color: #9ca3af;
font-size: 12px;
}
/* Segmented tabs (legacy) */
.vv-seg-tabs {
display: inline-flex;
background: #f5f6fa;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 3px;
gap: 2px;
}
.vv-seg-tab {
padding: 6px 14px;
border-radius: 6px;
background: #fff;
border: none;
color: #6b7280;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.vv-seg-tab:hover {
color: #374151;
background: #fff;
}
.vv-seg-tab.active {
background: #f3f0ff;
color: #7c3aed;
font-weight: 500;
}
/* ── v1.6 我的音色(默认主路径) ── */
.vv-voice-section {
@@ -1796,385 +1948,3 @@
padding-bottom: 6px;
border-bottom: 1px dashed #e5e7eb;
}
/* ─────────── markdown 渲染(提示词控制展示格式) ─────────── */
.vv-recog-md {
padding: 4px 0;
}
.vv-copy-preview {
margin-bottom: 14px;
padding: 12px 14px;
background: linear-gradient(180deg, #faf7ff 0%, #f6f2ff 100%);
border: 1px solid #ece4fb;
border-radius: 10px;
}
.vv-copy-preview-h {
margin: 0 0 8px;
border-bottom: none;
padding-bottom: 0;
}
.vv-md-body {
font-size: 13px;
line-height: 1.7;
color: #374151;
word-break: break-word;
}
.vv-md-body h1,
.vv-md-body h2,
.vv-md-body h3,
.vv-md-body h4 {
margin: 10px 0 6px;
font-weight: 600;
color: #1f2937;
line-height: 1.4;
}
.vv-md-body h1 {
font-size: 18px;
}
.vv-md-body h2 {
font-size: 16px;
}
.vv-md-body h3 {
font-size: 15px;
}
.vv-md-body h4 {
font-size: 14px;
}
.vv-md-body p {
margin: 6px 0;
}
.vv-md-body ul,
.vv-md-body ol {
margin: 6px 0;
padding-left: 20px;
}
.vv-md-body li {
margin: 3px 0;
}
.vv-md-body strong {
color: #111827;
font-weight: 600;
}
.vv-md-body blockquote {
margin: 8px 0;
padding: 4px 12px;
border-left: 3px solid #7c3aed;
background: rgba(124, 58, 237, 0.05);
color: #4b5563;
}
.vv-md-body code {
padding: 1px 5px;
background: #f3f4f6;
border-radius: 4px;
font-size: 12px;
color: #be185d;
}
.vv-md-body a {
color: #7c3aed;
text-decoration: none;
}
.vv-md-body a:hover {
text-decoration: underline;
}
.vv-md-body table {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
}
.vv-md-body th,
.vv-md-body td {
border: 1px solid #e5e7eb;
padding: 6px 10px;
text-align: left;
}
.vv-md-body hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 12px 0;
}
/* ── 分镜脚本流式预览(WebSocket delta 实时输出) ── */
.vv-streaming-box {
outline: 2px solid #ede9fe;
background: linear-gradient(180deg, #fafafe 0%, #ffffff 100%);
}
.vv-streaming-header {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0 10px 0;
border-bottom: 1px dashed #ede9fe;
margin-bottom: 8px;
flex-shrink: 0;
}
.vv-streaming-title {
font-size: 13px;
color: #7c3aed;
font-weight: 500;
flex: 1 1 auto;
}
.vv-streaming-count {
font-size: 12px;
color: #9ca3af;
background: #f5f0ff;
padding: 1px 8px;
border-radius: 10px;
}
.vv-streaming-doc {
flex: 1 1 auto;
overflow-y: auto;
padding: 4px 2px 8px 2px;
/* 复用 vv-sb-doc 的滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #d8c4ff transparent;
}
.vv-streaming-doc::-webkit-scrollbar {
width: 6px;
}
.vv-streaming-doc::-webkit-scrollbar-thumb {
background: #d8c4ff;
border-radius: 3px;
}
.vv-streaming-pre {
margin: 0;
padding: 4px 6px;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 13px;
line-height: 1.65;
color: #1f2937;
tab-size: 2;
}
.vv-streaming-caret {
display: inline-block;
width: 2px;
height: 14px;
background: #7c3aed;
vertical-align: middle;
margin-left: 2px;
animation: vv-caret-blink 1s steps(2, start) infinite;
}
@keyframes vv-caret-blink {
to {
visibility: hidden;
}
}
.vv-streaming-actions {
flex-shrink: 0;
background: #fff;
padding-top: 8px;
border-top: 1px solid #f3f4f6;
}
.vv-streaming-hint {
font-size: 12px;
color: #9ca3af;
}
/* ── 三列等高布局(2026-10-10 fix) ──
* vv-page 作为纵向 flex 容器,tabs 固定 + grid 占满剩余高度;
* vv-grid 改用 grid-auto-rows:1fr 强制三列等高;
* 每列的卡片使用 flex column + min-height:0,正文区域 flex:1 并内部滚动。
*/
.vv-page {
display: flex;
flex-direction: column;
/* 减去顶部导航 56px 和自身 padding 16*2=32px,给 Tabs + 三列区留一屏 */
height: calc(100vh - 56px);
min-height: 640px; /* 小窗口保底 */
overflow: hidden;
}
.vv-tabs {
flex-shrink: 0;
}
.vv-grid {
flex: 1 1 auto;
min-height: 0;
align-items: stretch; /* 覆盖原来的 start,强制行内格子撑满行高 */
grid-auto-rows: 1fr; /* 每行所有格子等高 */
overflow: hidden;
}
.vv-col {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
min-width: 0;
}
/* 列内的卡片(.vv-section)默认允许收缩,正文区域接管滚动 */
.vv-col > .vv-section {
display: flex;
flex-direction: column;
min-height: 0;
flex-shrink: 1;
}
/* STEP1/STEP2 唯一卡片:撑满列高 */
.vv-col > .vv-section:only-child {
flex: 1 1 auto;
}
/* STEP3 右侧两卡片:配置固定不缩,预览区吸剩余空间 */
.vv-col > .vv-section-preview {
flex: 1 1 auto;
min-height: 0;
}
.vv-section-preview .vv-section-body.vv-preview-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
overflow: hidden;
}
.vv-section-preview .vv-preview {
max-height: 100%;
height: 100%;
aspect-ratio: auto;
width: 100%;
}
/* 列内 .vv-credits / .vv-error / 主按钮 不参与收缩,永远固定在底部可见 */
.vv-col > .vv-credits,
.vv-col > .vv-error,
.vv-col > .vv-btn-primary {
flex-shrink: 0;
}
.vv-col > .vv-credits {
margin-top: 0;
}
/* 可滚动 section 正文 */
.vv-section-body.vv-section-body-scroll {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 14px 16px 14px;
overflow: hidden;
}
.vv-scroll-inner {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 6px;
display: flex;
flex-direction: column;
/* 美化滚动条(紫白主题) */
scrollbar-width: thin;
scrollbar-color: #d8c4ff transparent;
}
.vv-scroll-inner::-webkit-scrollbar {
width: 6px;
}
.vv-scroll-inner::-webkit-scrollbar-thumb {
background: #d8c4ff;
border-radius: 3px;
}
.vv-scroll-inner::-webkit-scrollbar-thumb:hover {
background: #c4a8ff;
}
.vv-scroll-inner::-webkit-scrollbar-track {
background: transparent;
}
/* 主按钮始终顶在 section-body 底部(在 vv-scroll-inner 之外) */
.vv-section-body.vv-section-body-scroll > .vv-btn-primary {
margin-top: 10px;
flex-shrink: 0;
}
/* STEP1 文件列表改为根据可用空间自适应,不再用 max-height:260px 硬编码 */
.vv-file-list {
max-height: none;
}
/* ── P2 新增 utility / 组件类 ── */
/* 短剧模式提示条(放在 Step2 区域就地显示) */
.vv-shortdrama-hint {
padding: 8px 12px;
margin-bottom: 8px;
background-color: #fff7e6;
border: 1px solid #ffd591;
border-radius: 4px;
font-size: 13px;
color: #d46b08;
}
/* speaker 标签(主角 / 配角) */
.vv-sb-speaker-tag {
display: inline-block;
padding: 2px 8px;
margin: 0 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
vertical-align: middle;
}
.vv-sb-speaker-tag-main {
background-color: #e6f7ff;
color: #1890ff;
}
.vv-sb-speaker-tag-guest {
background-color: #fff7e6;
color: #fa8c16;
}
.vv-sb-speaker-main {
color: #1890ff;
}
.vv-sb-speaker-guest {
color: #fa8c16;
}
/* 间距 / 排版 helpers */
.vv-mt-4 {
margin-top: 4px;
}
.vv-mt-6 {
margin-top: 6px;
}
.vv-mt-8 {
margin-top: 8px;
}
.vv-mb-6 {
margin-bottom: 6px;
}
.vv-mr-6 {
margin-right: 6px;
}
.vv-fs-12 {
font-size: 12px;
}
.vv-fs-13 {
font-size: 13px;
}
.vv-fs-22 {
font-size: 22px;
}
.vv-p-12 {
padding: 12px;
}
.vv-text-center {
text-align: center;
}
.vv-flex-1 {
flex: 1;
}
.vv-w-170 {
width: 170px;
flex: 0 0 auto;
}
.vv-inline-spinner {
margin-right: 6px;
display: inline-block;
vertical-align: middle;
}
.vv-progress-bar-sm {
margin: 6px 0 4px;
}
.vv-loading-ic {
font-size: 36px;
color: #7c3aed;
}
File diff suppressed because it is too large Load Diff
@@ -152,7 +152,8 @@ export default function AssetPickerModal({
<div className="vv-modal-body">
<div className="vv-asset-search">
<select
className="vv-input vv-w-170"
className="vv-input"
style={{ width: 170, flex: "0 0 auto" }}
value={libraryId}
onChange={(e) => setLibraryId(e.target.value)}
disabled={loadingLibs || libraries.length === 0}
@@ -200,8 +201,8 @@ export default function AssetPickerModal({
</div>
{kind === "voice" ? (
<>
<div className="vv-mt-8 vv-fs-13">暂无配音素材</div>
<div className="vv-muted vv-mt-4 vv-fs-12">
<div style={{ marginTop: 8, fontSize: 13 }}>暂无配音素材</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#9ca3af" }}>
请先在「配音/我的音色」中上传音频文件,或在素材库管理中添加
</div>
</>
@@ -1,33 +0,0 @@
import React from "react"
export interface EmptyBlockProps {
icon?: React.ReactNode
title?: string
desc?: React.ReactNode
action?: React.ReactNode
className?: string
style?: React.CSSProperties
}
/**
* 统一空态组件:icon + title + desc + action,避免散落的 vv-muted 空文案
*/
export const EmptyBlock: React.FC<EmptyBlockProps> = ({
icon = "🎬",
title,
desc,
action,
className,
style,
}) => {
return (
<div className={`vv-empty-block ${className || ""}`} style={style}>
<div className="vv-empty-icon">{icon}</div>
{title && <div className="vv-empty-title">{title}</div>}
{desc && <div className="vv-empty-desc vv-muted">{desc}</div>}
{action && <div className="vv-empty-action">{action}</div>}
</div>
)
}
export default EmptyBlock
@@ -1,43 +0,0 @@
import React from "react"
import { WarningFilled, ReloadOutlined } from "@ant-design/icons"
export interface ErrorRetryBarProps {
label?: string
message: string
onRetry?: () => void
retryText?: string
className?: string
style?: React.CSSProperties
}
/**
* 统一错误条:警告图标 + 标签前缀 + 错误消息 + 可选重试按钮
* 替换主页面里散落的 .vv-error 块
*/
export const ErrorRetryBar: React.FC<ErrorRetryBarProps> = ({
label,
message,
onRetry,
retryText = "重试",
className,
style,
}) => {
return (
<div className={`vv-error ${className || ""}`} style={style}>
<WarningFilled style={{ marginRight: 6 }} />
{label ? `${label}:` : ""}
{message}
{onRetry && (
<button
className="vv-btn vv-btn-warn vv-btn-sm"
style={{ marginLeft: 10 }}
onClick={onRetry}
>
<ReloadOutlined /> {retryText}
</button>
)}
</div>
)
}
export default ErrorRetryBar
@@ -1,111 +0,0 @@
import React, { useState, useRef, useEffect, useCallback } from "react"
export interface InlineTextProps {
value: string
onChange?: (v: string) => void // 受控模式
onCommit: (v: string) => void // blur/Enter 提交
onCancel?: () => void
multiline?: boolean
textareaClass?: string
textClass?: string
placeholder?: string
editing: boolean
onStartEdit: () => void
rows?: number
maxLength?: number
disabled?: boolean
}
/**
* 通用"点文字→textarea→blur/Enter保存→Esc取消"内联编辑组件
* 替换 ViralVideoPage 里 11 处反射式 textarea/span 复制粘贴
*/
export const InlineText: React.FC<InlineTextProps> = ({
value,
onChange,
onCommit,
onCancel,
multiline,
textareaClass,
textClass,
placeholder,
editing,
onStartEdit,
rows = 2,
maxLength,
disabled,
}) => {
const taRef = useRef<HTMLTextAreaElement>(null)
const [local, setLocal] = useState(value)
useEffect(() => {
setLocal(value)
}, [value])
useEffect(() => {
if (editing && taRef.current) {
taRef.current.focus()
taRef.current.select()
}
}, [editing])
const commit = useCallback(() => {
const v = (onChange ? value : local).trim()
const cur = (value ?? "").trim()
if (v !== cur) onCommit(v)
else onCancel?.()
}, [local, value, onCommit, onCancel, onChange])
const handleKey = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
onCancel?.()
}
if (!multiline && e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
commit()
}
},
[multiline, commit, onCancel],
)
if (editing) {
return (
<textarea
ref={taRef}
className={textareaClass}
value={onChange ? value : local}
onChange={(e) => (onChange ? onChange(e.target.value) : setLocal(e.target.value))}
onBlur={commit}
onKeyDown={handleKey}
rows={rows}
maxLength={maxLength}
placeholder={placeholder}
/>
)
}
const displayText = value || placeholder || "点击编辑"
const isPlaceholder = !value
return (
<span
className={`${textClass || ""} ${isPlaceholder ? "vv-inline-placeholder" : ""} ${
disabled ? "vv-inline-disabled" : ""
}`}
onClick={() => {
if (!disabled) onStartEdit()
}}
style={
isPlaceholder
? { color: "#9ca3af", cursor: disabled ? "not-allowed" : "text" }
: { cursor: disabled ? "not-allowed" : "text" }
}
>
{displayText}
</span>
)
}
export default InlineText
@@ -106,10 +106,10 @@ const CATEGORY_LABELS: Record<string, string> = {
const GENDER_LABEL = (v: PresetVoice) => {
if (v.gender_label) return v.gender_label
const g = v.gender
if (g === "female") return "女声"
if (g === "male") return "男声"
if (g === "child") return "童声"
return "其他"
if (g === "female") return "女声·女声"
if (g === "male") return "男声·男声"
if (g === "child") return "童声·童声"
return "性别未标注·其他"
}
const AVATAR_BG = (gender?: string) => {
@@ -303,7 +303,7 @@ const PresetVoicePickerModal: React.FC<Props> = ({
{v.avatar_url ? (
<img src={v.avatar_url} alt={v.name} />
) : (
<UserOutlined className="vv-fs-22" />
<UserOutlined style={{ fontSize: 22 }} />
)}
</div>
<div className="vv-voice-card-name" title={v.name}>
@@ -1,315 +0,0 @@
import React, { useMemo } from "react"
import {
UploadOutlined,
FolderOpenOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
CloseOutlined,
} from "@ant-design/icons"
import { VVSpinner } from "./VVSpinner"
import { renderMarkdown } from "../utils/renderMarkdown"
import { CheckCircleFilled } from "@ant-design/icons"
import { VoicePickerSection } from "./VoicePickerSection"
import type { UploadedImage, RefVideo, RefAudio, VoicePanel, ImageAnalysisResult } from "../types"
import type { ImageProductAnalysis, StyleStrength } from "@/api/viral-video/types"
import { STYLE_STRENGTHS } from "@/api/viral-video/types"
import type { VoiceClone } from "@/api/voice-clone"
import type { AssetItem } from "@/api/assets"
export interface Step1UploadPanelProps {
images: UploadedImage[]
refVideo: RefVideo
voicePanel: VoicePanel
refAudio: RefAudio
voiceTipsOpen: boolean
myVoices: VoiceClone[]
myVoicesLoading: boolean
voiceAssets: { id: string; name: string; url: string; desc?: string }[]
playingVoiceId: string | null
styleStrength: StyleStrength
imageAnalysis: ImageAnalysisResult | null
isAnalyzing: boolean
analysisError?: string
canAnalyze: boolean
analyzeBtnText: React.ReactNode
// callbacks - images
onPickLocalImages: () => void
onPickAssetImages: () => void
onRemoveImage: (localId: string) => void
onMoveImage: (idx: number, dir: -1 | 1) => void
onClearImages: () => void
onImagesSelected: (files: FileList) => void
onAnalyze: () => void
// callbacks - ref video
onPickLocalVideo: () => void
onVideoSelected: (file: File) => void
onRemoveRefVideo: () => void
onStyleStrengthChange: (v: StyleStrength) => void
// callbacks - voice (delegated to VoicePickerSection)
onPickLocalAudio: () => void
onPickAssetAudio: () => void
onRemoveRefAudio: () => void
onToggleVoicePanel: (p: VoicePanel) => void
onSelectMyVoice: (v: VoiceClone) => void
onOpenCloneModal: () => void
onSelectVoiceAsset: (a: AssetItem) => void
onToggleVoicePlay: (v: { id: string; url: string }) => void
onVoiceSelected: (file: File) => void
onToggleVoiceTips: () => void
// refs
imgInputRef: React.Ref<HTMLInputElement>
videoInputRef: React.Ref<HTMLInputElement>
voiceInputRef: React.Ref<HTMLInputElement>
}
const fmtSize = (bytes: number | undefined) => {
if (!bytes || bytes <= 0) return "--"
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
function RecognitionBlock({
imageAnalysis,
isAnalyzing,
}: {
imageAnalysis: ImageAnalysisResult | null
isAnalyzing: boolean
}) {
const images: ImageProductAnalysis[] = useMemo(() => {
return (
(imageAnalysis?.images as ImageProductAnalysis[] | undefined) ||
(imageAnalysis?.products as ImageProductAnalysis[] | undefined) ||
[]
)
}, [imageAnalysis])
if (isAnalyzing) {
return (
<div className="vv-recog">
<div className="vv-recog-title">
<VVSpinner color="#7c3aed" className="vv-inline-spinner" />
识别描述汇览
</div>
<div className="vv-muted">AI 正在识别画面…</div>
</div>
)
}
if (images.length === 0) return null
return (
<div className="vv-recog">
<div className="vv-recog-title">
<CheckCircleFilled style={{ color: "#10b981" }} />
识别描述汇览
</div>
{images.map((p, i) => {
const meta = [p.name || "未识别", p.brand, p.category].filter(Boolean)
return (
<div key={i} className="vv-recog-item vv-recog-md">
<div className="vv-recog-line">
<span className="vv-recog-k">图片{i + 1}:</span>
<span>{meta.join(" · ")}</span>
</div>
{p.summary_markdown ? (
<div
className="vv-md-body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(p.summary_markdown) }}
/>
) : (
<div className="vv-muted">(暂无叙述描述)</div>
)}
</div>
)
})}
</div>
)
}
export const Step1UploadPanel: React.FC<Step1UploadPanelProps> = (p) => {
return (
<div className="vv-section">
<div className="vv-step1-head">
<span className="vv-step-badge">1</span>
<span className="vv-step1-title">STEP 1 上传素材并分析图片</span>
</div>
<div className="vv-section-body vv-section-body-scroll">
<div className="vv-scroll-inner">
{/* 图片按钮 */}
<div className="vv-action-row">
<button className="vv-action-btn" onClick={p.onPickLocalImages}>
<UploadOutlined /> 本地上传
</button>
<button className="vv-action-btn" onClick={p.onPickAssetImages}>
<FolderOpenOutlined /> 从素材库选择图片
</button>
</div>
<input
ref={p.imgInputRef}
type="file"
multiple
accept="image/*"
hidden
onChange={(e) => {
if (e.target.files) p.onImagesSelected(e.target.files)
e.target.value = ""
}}
/>
<div className="vv-count-row">
<span className="vv-muted">
已选 <strong style={{ color: "#111827" }}>{p.images.length}</strong>/9
</span>
{p.images.length > 0 && (
<button className="vv-link-btn" onClick={p.onClearImages}>
清空图片
</button>
)}
</div>
{p.images.length > 0 && (
<div className="vv-file-list">
{p.images.map((im, i) => (
<div key={im.localId} className="vv-file-item">
<div className="vv-file-thumb">
<span className="vv-img-badge">{i + 1}</span>
<img src={im.ossUrl || im.url} alt={im.name} />
</div>
<div className="vv-file-info">
<div className="vv-file-name" title={im.name}>
{im.name}
</div>
<div className="vv-file-sub">
{im.uploading ? (
<>
<span className="vv-progress-mini-inline" />
上传中 {im.pct}%
</>
) : (
<>{fmtSize(im.size)}</>
)}
</div>
</div>
<div className="vv-file-actions">
<button
className="vv-icon-btn"
onClick={() => p.onMoveImage(i, -1)}
disabled={i === 0 || im.uploading}
title="上移"
>
<ArrowUpOutlined />
</button>
<button
className="vv-icon-btn"
onClick={() => p.onMoveImage(i, 1)}
disabled={i === p.images.length - 1 || im.uploading}
title="下移"
>
<ArrowDownOutlined />
</button>
<button
className="vv-icon-btn vv-icon-btn-danger"
onClick={() => p.onRemoveImage(im.localId)}
disabled={!!im.uploading}
title="删除"
>
<CloseOutlined />
</button>
</div>
</div>
))}
</div>
)}
{/* 参考视频 */}
<div className="vv-subblock">
<div className="vv-subblock-head">
<span className="vv-subblock-title">参考视频</span>
<span className="vv-opt-tag">选填</span>
<span className="vv-subblock-hint">最大 200MB · 最长 120 秒</span>
</div>
<div className="vv-action-row">
<button className="vv-action-btn" onClick={p.onPickLocalVideo}>
<UploadOutlined /> 本地上传视频
</button>
</div>
<input
ref={p.videoInputRef}
type="file"
accept="video/*"
hidden
onChange={(e) => {
if (e.target.files?.[0]) p.onVideoSelected(e.target.files[0])
e.target.value = ""
}}
/>
{p.refVideo && (
<div className="vv-ref-video-box">
<video
src={p.refVideo.ossUrl || p.refVideo.url}
controls
className="vv-ref-video-el"
/>
<div className="vv-file-info vv-mt-6">
<div className="vv-file-name">{p.refVideo.name}</div>
{p.refVideo.uploading && (
<div className="vv-progress-bar vv-progress-bar-sm">
<div className="vv-progress-fill" style={{ width: `${p.refVideo.pct}%` }} />
</div>
)}
</div>
<button className="vv-link-btn" onClick={p.onRemoveRefVideo}>
删除重传
</button>
{p.refVideo.ossUrl && (
<div className="vv-mt-8">
<div className="vv-label vv-mb-6">风格借鉴强度</div>
<div className="vv-pill-row">
{STYLE_STRENGTHS.map((s) => (
<button
key={s.value}
className={`vv-pill ${p.styleStrength === s.value ? "active" : ""}`}
onClick={() => p.onStyleStrengthChange(s.value)}
>
{s.label}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
<VoicePickerSection
refAudio={p.refAudio}
voicePanel={p.voicePanel}
voiceTipsOpen={p.voiceTipsOpen}
myVoices={p.myVoices}
myVoicesLoading={p.myVoicesLoading}
voiceAssets={p.voiceAssets}
playingVoiceId={p.playingVoiceId}
voiceInputRef={p.voiceInputRef}
onOpenCloneModal={p.onOpenCloneModal}
onToggleVoicePanel={p.onToggleVoicePanel}
onSelectMyVoice={p.onSelectMyVoice}
onSelectVoiceAsset={p.onSelectVoiceAsset}
onToggleVoicePlay={p.onToggleVoicePlay}
onPickLocalAudio={p.onPickLocalAudio}
onPickAssetAudio={p.onPickAssetAudio}
onVoiceSelected={p.onVoiceSelected}
onRemoveRefAudio={p.onRemoveRefAudio}
onToggleVoiceTips={p.onToggleVoiceTips}
/>
<RecognitionBlock imageAnalysis={p.imageAnalysis} isAnalyzing={p.isAnalyzing} />
</div>
<button className="vv-btn vv-btn-primary" disabled={!p.canAnalyze} onClick={p.onAnalyze}>
{p.analyzeBtnText}
</button>
</div>
</div>
)
}
export default Step1UploadPanel
@@ -1,237 +0,0 @@
import React from "react"
import { EditOutlined } from "@ant-design/icons"
import { Select } from "antd"
import { FUSION_LEVELS } from "@/api/viral-video/types"
import type { FusionLevel } from "@/api/viral-video/types"
import { VVSpinner } from "./VVSpinner"
import { ErrorRetryBar } from "./ErrorRetryBar"
import { StoryboardCard } from "./StoryboardCard"
import type { SbDoc, StoryboardEditor } from "../hooks/useStoryboardEditor"
import type { UploadedImage, UIStep, ViralVideoJob } from "../types"
import {
LANGUAGES,
TARGET_CUSTOMERS,
PERSONAS,
STRUCTURES,
PURPOSES,
DURATIONS,
} from "../ViralVideoPage.constants"
import { getJobPhaseMessage } from "@/api/viral-video/types"
export interface Step2CopyPanelProps {
// 启用状态
step2Enabled: boolean
uiStep: UIStep
// 文案参数
fusionLevel: FusionLevel
userCopy: string
industry: string
targetCustomer: string
persona: string
language: string
viralStructure: string
marketingPurpose: string
duration: number
// 回调
onFusionLevelChange: (v: FusionLevel) => void
onUserCopyChange: (v: string) => void
onParamChange: (patch: {
industry?: string
targetCustomer?: string
persona?: string
language?: string
viralStructure?: string
marketingPurpose?: string
duration?: number
}) => void
onGenerateCopy: () => void
// 分镜/流式预览
storyboard: SbDoc | null
scriptStreamText: string
scriptStreamMessage?: string
editor: StoryboardEditor
images: UploadedImage[]
locked: boolean
job: ViralVideoJob | null
copyError?: string
}
export const Step2CopyPanel: React.FC<Step2CopyPanelProps> = (p) => {
const isGenerating = p.uiStep === "step2_generating"
const showGenerateBtn =
p.uiStep !== "step2_copy_ready" && p.uiStep !== "step3_generating" && p.uiStep !== "step3_done"
return (
<div className="vv-section">
<div className="vv-step-subhead">
<span className={`vv-step-badge ${p.step2Enabled ? "" : "vv-step-badge-dim"}`}>2</span>
<span className="vv-step-subtitle">STEP 2 生成视频文案</span>
{!p.step2Enabled && <span className="vv-step-lock">请先完成 STEP 1</span>}
</div>
<div className="vv-section-body vv-section-body-scroll">
<div className="vv-scroll-inner">
{/* 文案融合方式 */}
<div className="vv-section-subtitle">文案融合方式</div>
<div className="vv-fusion-grid">
{FUSION_LEVELS.map((f) => (
<button
key={f.value}
className={`vv-fusion-btn ${p.fusionLevel === f.value ? "active" : ""}`}
onClick={() => p.onFusionLevelChange(f.value)}
>
<strong>{f.label}</strong>
{f.desc}
</button>
))}
</div>
{(p.fusionLevel === "ai_polish" || p.fusionLevel === "user_primary") && (
<div className="vv-form-row">
<label className="vv-label">
{p.fusionLevel === "ai_polish" ? "写下你的文案草稿" : "写下你的文案/草稿"}
</label>
<textarea
key={p.fusionLevel}
className="vv-textarea"
placeholder={
p.fusionLevel === "ai_polish"
? "请输入你的文案草稿,AI将在此基础上润色优化..."
: "请输入你想要的原始文案,AI将严格保留你的表达..."
}
value={p.userCopy}
onChange={(e) => p.onUserCopyChange(e.target.value)}
/>
</div>
)}
{/* 参数(2列网格) */}
<div className="vv-form-grid">
<div className="vv-form-row">
<label className="vv-label">
行业与产品 <span className="vv-label-optional">(选填)</span>
</label>
<input
className="vv-input"
placeholder="如:家具店、宠物店、装修公司"
value={p.industry}
onChange={(e) => p.onParamChange({ industry: e.target.value })}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">目标客户</label>
<Select
className="vv-select"
style={{ width: "100%" }}
placeholder="请选择目标客户"
value={p.targetCustomer || undefined}
onChange={(v) => p.onParamChange({ targetCustomer: v })}
options={TARGET_CUSTOMERS.map((i) => ({ value: i, label: i }))}
allowClear
/>
</div>
<div className="vv-form-row">
<label className="vv-label">台词语言</label>
<Select
className="vv-select"
style={{ width: "100%" }}
value={p.language}
onChange={(v) => p.onParamChange({ language: v })}
options={LANGUAGES.map((i) => ({ value: i, label: i }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">我的人设</label>
<Select
className="vv-select"
style={{ width: "100%" }}
placeholder="请选择人设类型"
optionFilterProp="children"
value={p.persona || undefined}
onChange={(v) => p.onParamChange({ persona: v })}
options={PERSONAS.map((i) => ({ value: i, label: i }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">爆款结构</label>
<Select
className="vv-select"
style={{ width: "100%" }}
value={p.viralStructure}
onChange={(v) => p.onParamChange({ viralStructure: v })}
options={STRUCTURES.map((i) => ({ value: i, label: i }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">营销目的</label>
<Select
className="vv-select"
style={{ width: "100%" }}
placeholder="请选择营销目的"
value={p.marketingPurpose || undefined}
onChange={(v) => p.onParamChange({ marketingPurpose: v })}
options={PURPOSES.map((i) => ({ value: i, label: i }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">文案视频时长</label>
<Select
className="vv-select"
style={{ width: "100%" }}
value={p.duration}
onChange={(v) => p.onParamChange({ duration: v })}
options={DURATIONS.map((n) => ({ value: n, label: `${n}秒` }))}
/>
</div>
</div>
{/* 短剧模式提示 */}
{p.marketingPurpose && p.marketingPurpose.includes("短剧") && (
<div className="vv-shortdrama-hint">
💡 短剧模式已选中,对手戏角色将自动分配配角音色
</div>
)}
{/* 文案审核错误(持久显示) */}
{p.copyError && <ErrorRetryBar label="文案审核未通过" message={p.copyError} />}
{/* 分镜结果区 / 流式预览 */}
<StoryboardCard
uiStep={p.uiStep}
scriptStreamText={p.scriptStreamText}
scriptStreamMessage={p.scriptStreamMessage}
jobPhaseMessage={getJobPhaseMessage(p.job)}
storyboard={p.storyboard}
editor={p.editor}
images={p.images}
locked={p.locked}
onRegenerateCopy={p.onGenerateCopy}
/>
</div>
{showGenerateBtn && (
<button
className={`vv-btn vv-btn-primary ${!p.step2Enabled ? "vv-btn-disabled-gray" : ""}`}
onClick={p.onGenerateCopy}
disabled={isGenerating || !p.step2Enabled}
title={!p.step2Enabled ? "请先完成图片分析" : ""}
>
{isGenerating ? (
<>
<VVSpinner /> AI 生成文案中…
</>
) : !p.step2Enabled ? (
<>
<EditOutlined /> 生成文案(请先完成图片分析)
</>
) : (
<>
<EditOutlined /> 生成文案
</>
)}
</button>
)}
</div>
</div>
)
}
export default Step2CopyPanel
@@ -1,216 +0,0 @@
import React from "react"
import {
VideoCameraOutlined,
SoundOutlined,
ReloadOutlined,
HistoryOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { Select } from "antd"
import { VVSpinner } from "./VVSpinner"
import { ErrorRetryBar } from "./ErrorRetryBar"
import type { UIStep, ViralVideoJob, ViralVideoModel } from "../types"
import { getJobPhaseMessage } from "@/api/viral-video/types"
import { RATIOS, QUALITY_OPTIONS, UPSCALE_OPTIONS } from "../ViralVideoPage.constants"
export interface Step3VideoPanelProps {
step3Enabled: boolean
uiStep: UIStep
videoRatio: string
videoModel: string
quality: string
upscale: string
duration: number
estimatedCredits: number | null
estimatedCreditsError: boolean
isGenerating: boolean
outputUrl: string | null
videoError?: string
creditsReady: boolean
job: ViralVideoJob | null
videoModels: ViralVideoModel[]
locked: boolean
// callbacks
onRatioChange: (v: string) => void
onModelChange: (v: string) => void
onQualityChange: (v: string) => void
onUpscaleChange: (v: string) => void
onGenerate: () => void
onReset: () => void
onRetry: () => void
}
export const Step3VideoPanel: React.FC<Step3VideoPanelProps> = (p) => {
const isDone = p.uiStep === "step3_done"
const isGenerating = p.uiStep === "step3_generating"
const isFailed = p.uiStep === "failed"
const curM = p.videoModels.find((m) => m && m.key === p.videoModel)
const supportedRes = Array.isArray(curM?.supported_resolutions)
? curM!.supported_resolutions
: QUALITY_OPTIONS.map((o) => o.v)
const qualityPool =
supportedRes && supportedRes.length > 0 ? supportedRes : QUALITY_OPTIONS.map((o) => o.v)
const qualityOptions = QUALITY_OPTIONS.filter((o) => qualityPool.includes(o.v))
return (
<>
<div className="vv-section">
<div className="vv-step-subhead vv-step3-head">
<div className="vv-step3-head-left">
<span className={`vv-step-badge ${p.step3Enabled ? "" : "vv-step-badge-dim"}`}>3</span>
<span className="vv-step-subtitle">STEP 3 生成视频</span>
</div>
{p.step3Enabled && (
<button
className="vv-btn vv-btn-ghost vv-btn-xs vv-history-btn"
onClick={() => {
// 历史作品功能待开发,此处保留原行为(message 由父组件触发)
}}
title="历史作品"
>
<HistoryOutlined /> 历史作品
</button>
)}
{!p.step3Enabled && <span className="vv-step-lock">请先完成 STEP 2</span>}
</div>
<div className="vv-step3-divider" />
<div className="vv-section-body vv-section-body-scroll vv-step3-config-body">
<div className="vv-scroll-inner">
<div className="vv-step3-grid">
<div className="vv-form-row">
<label className="vv-label">视频比例</label>
<Select
className="vv-select vv-select-step3"
style={{ width: "100%" }}
value={p.videoRatio}
onChange={p.onRatioChange}
disabled={p.locked}
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">视频模型</label>
<Select
className="vv-select vv-select-step3"
style={{ width: "100%" }}
value={p.videoModel}
disabled={p.locked}
onChange={(v) => p.onModelChange(v)}
options={p.videoModels
.filter((m) => m && typeof m === "object" && m.key)
.map((m) => ({ value: m.key, label: m.display_name || m.key }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">清晰度</label>
<Select
className="vv-select vv-select-step3"
style={{ width: "100%" }}
value={p.quality}
onChange={p.onQualityChange}
disabled={p.locked}
options={qualityOptions.map((o) => ({ value: o.v, label: o.label }))}
/>
</div>
<div className="vv-form-row">
<label className="vv-label">成片超分</label>
<Select
className="vv-select vv-select-step3"
style={{ width: "100%" }}
value={p.upscale}
onChange={p.onUpscaleChange}
disabled={p.locked}
options={UPSCALE_OPTIONS.map((m) => ({ value: m.v, label: m.label }))}
/>
</div>
</div>
</div>
</div>
</div>
{p.creditsReady && (
<div className="vv-credits">
<span>
<SoundOutlined style={{ marginRight: 4 }} />
预计消耗
</span>
<strong>
{p.estimatedCreditsError
? "-- 积分"
: p.estimatedCredits === null
? "… 积分"
: `${p.estimatedCredits.toFixed(2)} 积分`}
</strong>
</div>
)}
{/* 视频预览 */}
<div className="vv-section vv-section-preview">
<div className="vv-section-body vv-preview-body">
<div className="vv-preview">
{isDone && p.outputUrl ? (
<video src={p.outputUrl} controls />
) : isGenerating ? (
<div className="vv-preview-placeholder">
<div className="ph-icon">
<LoadingOutlined className="vv-loading-ic" />
</div>
<div className="ph-txt">
AI 正在生成视频中…
<br />
预计 1-3 分钟,请勿关闭页面
</div>
{getJobPhaseMessage(p.job) && (
<div className="vv-muted vv-mt-6 vv-fs-12">{getJobPhaseMessage(p.job)}</div>
)}
</div>
) : (
<div className="vv-preview-placeholder">
<div className="ph-icon">🎬</div>
<div className="ph-txt">完成前面步骤后视频将在此处预览</div>
</div>
)}
</div>
</div>
</div>
{/* 错误条 */}
{isFailed && p.videoError && (
<ErrorRetryBar label="视频生成失败" message={p.videoError} onRetry={p.onRetry} />
)}
{/* 主按钮 */}
{!isDone ? (
<button
className={`vv-btn vv-btn-primary ${!p.step3Enabled ? "vv-btn-disabled-gray" : ""}`}
onClick={p.onGenerate}
disabled={isGenerating || !p.step3Enabled}
title={!p.step3Enabled ? "请先在 STEP 2 生成文案" : ""}
>
{isGenerating ? (
<>
<VVSpinner /> AI 正在生成视频中…
</>
) : !p.step3Enabled ? (
<>
<VideoCameraOutlined /> 开始生成视频(请先生成文案)
</>
) : (
<>
<VideoCameraOutlined /> 开始生成视频
</>
)}
</button>
) : (
p.outputUrl && (
<button className="vv-btn vv-btn-ghost vv-btn-block" onClick={p.onReset}>
<ReloadOutlined /> 再生成一个
</button>
)
)}
</>
)
}
export default Step3VideoPanel
@@ -1,327 +0,0 @@
import React from "react"
import { Select } from "antd"
import { FileTextOutlined, SoundOutlined, ReloadOutlined } from "@ant-design/icons"
import { InlineText } from "./InlineEditTextarea"
import { StoryboardShotItem } from "./StoryboardShotItem"
import { VVSpinner } from "./VVSpinner"
import type { StoryboardEditor, SbDoc } from "../hooks/useStoryboardEditor"
import type { UploadedImage, UIStep } from "../types"
import { RATIOS } from "../ViralVideoPage.constants"
export interface StoryboardCardProps {
/** 当前 uiStep(仅用于按钮/锁定判定) */
uiStep: UIStep
/** 当前流式预览文本(step2_generating 时优先展示) */
scriptStreamText: string
scriptStreamMessage?: string
jobPhaseMessage?: string
/** 真实 storyboard(已补默认值的 SbDoc) */
storyboard: SbDoc | null
editor: StoryboardEditor
images: UploadedImage[]
/** 是否锁定编辑(视频生成中/完成时为 true) */
locked: boolean
onRegenerateCopy?: () => void
}
import { renderMarkdown } from "../utils/renderMarkdown"
/** 解析 voiceover_script 中的 [speaker:xxx] 标记,渲染为带颜色的 React 片段 */
function renderVoiceoverWithSpeakers(text: string) {
if (!text) return null
const parts = text.split(/\[speaker:([^\]]+)\]/)
if (parts.length === 1) return <span>{text}</span>
const elements: React.ReactNode[] = []
const MAIN_SPEAKERS = ["店主", "主播", "老板", "我", "主讲人"]
for (let i = 1; i < parts.length; i += 2) {
const speaker = parts[i]
const segText = parts[i + 1] || ""
const isMain = MAIN_SPEAKERS.includes(speaker)
elements.push(
<span key={i}>
<strong className={isMain ? "vv-sb-speaker-main" : "vv-sb-speaker-guest"}>{speaker}</strong>
<span>{segText}</span>
</span>,
)
}
return <>{elements}</>
}
export const StoryboardCard: React.FC<StoryboardCardProps> = ({
uiStep,
scriptStreamText,
scriptStreamMessage,
jobPhaseMessage,
storyboard,
editor,
images,
locked,
onRegenerateCopy,
}) => {
const {
editingField,
startEdit,
cancelEdit,
commitField,
updateOverview,
updateConstraint,
addConstraint,
removeConstraint,
updateSbField,
} = editor
/* ── 生成中:流式预览 or 骨架 ── */
if (uiStep === "step2_generating") {
const hasStream = scriptStreamText.trim().length > 0
const stageMsg = scriptStreamMessage || jobPhaseMessage || "🎬 AI 正在创作视频分镜脚本..."
if (!hasStream) {
return (
<div className="vv-copy-box vv-copy-loading">
<VVSpinner />
<span className="vv-copy-loading-text">{stageMsg}</span>
<span className="vv-copy-loading-hint">预计 10-30 秒,请稍候</span>
</div>
)
}
return (
<div className="vv-copy-box vv-storyboard vv-streaming-box">
<div className="vv-streaming-header">
<VVSpinner />
<span className="vv-streaming-title">{stageMsg}</span>
<span className="vv-streaming-count">{scriptStreamText.length} 字</span>
</div>
<div className="vv-sb-doc vv-streaming-doc">
<pre className="vv-streaming-pre">
{scriptStreamText
.replace(/<\/?[a-zA-Z_][\w:-]*(?:\s[^>]*)?>/g, "")
.replace(/\s{3,}/g, "\n\n")
.trim()}
<span className="vv-streaming-caret" aria-hidden="true" />
</pre>
</div>
<div className="vv-sb-actions vv-streaming-actions">
<span className="vv-streaming-hint">AI 实时输出中,完成后可编辑分镜与口播文案</span>
</div>
</div>
)
}
/* ── 没有 storyboard 时的空态 ── */
if (!storyboard) {
if (uiStep === "step2_copy_ready" || uiStep === "step3_generating" || uiStep === "step3_done") {
return (
<div className="vv-copy-box vv-copy-loading">
<span className="vv-muted">暂无分镜数据</span>
</div>
)
}
return null
}
const sb = storyboard
const textCls = "vv-sb-field-val"
const taCls = "vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
return (
<div className="vv-copy-box vv-storyboard">
<div className="vv-sb-doc">
{/* 文案预览 markdown(只读) */}
{sb.copy_display_markdown && (
<div className="vv-copy-preview">
<h4 className="vv-sb-h vv-copy-preview-h">
<FileTextOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
文案预览
</h4>
<div
className="vv-md-body"
dangerouslySetInnerHTML={{ __html: renderMarkdown(sb.copy_display_markdown) }}
/>
</div>
)}
{/* 视频总览 */}
<h4 className="vv-sb-h">视频总览</h4>
<p className="vv-sb-inline-row">
<strong className="vv-sb-field-k">整体主题:</strong>
<InlineText
value={sb.overview.theme}
editing={!locked && editingField === "overview.theme"}
onStartEdit={() => startEdit("overview.theme")}
onCommit={(v) => commitField("overview.theme", v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={`${taCls} vv-sb-inline-edit-ta-sm`}
disabled={locked}
rows={1}
/>
</p>
<p className="vv-sb-inline-row">
<strong className="vv-sb-field-k">总时长:</strong>
<InlineText
value={sb.overview.total_duration}
editing={!locked && editingField === "overview.total_duration"}
onStartEdit={() => startEdit("overview.total_duration")}
onCommit={(v) => commitField("overview.total_duration", v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={`${taCls} vv-sb-inline-edit-ta-sm`}
disabled={locked}
rows={1}
/>
</p>
<p className="vv-sb-inline-row">
<strong className="vv-sb-field-k">画幅:</strong>
<Select
className="vv-select vv-sb-inline-select"
style={{ width: 90 }}
value={sb.overview.aspect_ratio}
disabled={locked}
onChange={(v) => updateOverview({ aspect_ratio: v })}
options={RATIOS.map((r) => ({ value: r.v, label: r.v }))}
variant="borderless"
/>
</p>
{/* 场景与光线 */}
<h4 className="vv-sb-h">场景与光线</h4>
<p className="vv-sb-para">
<InlineText
value={sb.scene_and_lighting}
editing={!locked && editingField === "scene_and_lighting"}
onStartEdit={() => startEdit("scene_and_lighting")}
onCommit={(v) => commitField("scene_and_lighting", v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
placeholder="描述整体场景氛围、光线方向与色温…"
disabled={locked}
multiline
rows={2}
/>
</p>
{/* 逐镜头 */}
<h4 className="vv-sb-h">逐秒镜头拆解</h4>
<div className="vv-sb-doc-shots">
{sb.shots.map((sh, idx) => (
<StoryboardShotItem
key={idx}
index={idx}
shot={sh}
editor={editor}
images={images}
locked={locked}
/>
))}
</div>
{/* 硬性约束 */}
<h4 className="vv-sb-h">硬性约束</h4>
<div className="vv-sb-tags-block">
<div className="vv-sb-taglist">
{sb.hard_constraints.map((c, i) => (
<span key={i} className="vv-sb-tag vv-sb-tag-hard">
<input
className="vv-sb-tag-input"
value={c}
disabled={locked}
onChange={(e) => updateConstraint("hard_constraints", i, e.target.value)}
/>
{!locked && (
<button
className="vv-sb-tag-del"
onClick={() => removeConstraint("hard_constraints", i)}
aria-label="删除"
>
×
</button>
)}
</span>
))}
{!locked && (
<button className="vv-sb-tag-add" onClick={() => addConstraint("hard_constraints")}>
+ 添加
</button>
)}
</div>
</div>
{/* 负面提示词 */}
<h4 className="vv-sb-h">负面提示词</h4>
<div className="vv-sb-tags-block">
<div className="vv-sb-taglist">
{sb.negative_prompts.map((c, i) => (
<span key={i} className="vv-sb-tag vv-sb-tag-neg">
<input
className="vv-sb-tag-input"
value={c}
disabled={locked}
onChange={(e) => updateConstraint("negative_prompts", i, e.target.value)}
/>
{!locked && (
<button
className="vv-sb-tag-del"
onClick={() => removeConstraint("negative_prompts", i)}
aria-label="删除"
>
×
</button>
)}
</span>
))}
{!locked && (
<button className="vv-sb-tag-add" onClick={() => addConstraint("negative_prompts")}>
+ 添加
</button>
)}
</div>
</div>
{/* 完整口播稿 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">
<SoundOutlined style={{ color: "#7c3aed", marginRight: 4 }} />
口播稿:
</strong>
{editingField === "voiceover_script" ? (
<textarea
className={taCls}
value={sb.voiceover_script}
autoFocus
onChange={(e) => updateSbField("voiceover_script", e.target.value)}
onBlur={() => {
// 受控模式:blur 即退出编辑(内容已实时同步)
if (editingField === "voiceover_script") cancelEdit()
}}
onKeyDown={(e) => {
if (e.key === "Escape") cancelEdit()
}}
placeholder="AI 合成配音用的完整口播稿…"
rows={4}
/>
) : (
<span
className={textCls}
onClick={() => {
if (!locked) startEdit("voiceover_script")
}}
style={{ cursor: locked ? "not-allowed" : "text" }}
>
{renderVoiceoverWithSpeakers(sb.voiceover_script)}
</span>
)}
</p>
</div>
<div className="vv-sb-actions">
{uiStep === "step2_copy_ready" && onRegenerateCopy && (
<button className="vv-btn vv-btn-ghost" onClick={onRegenerateCopy}>
<ReloadOutlined /> 重新生成文案
</button>
)}
</div>
</div>
)
}
export default StoryboardCard
@@ -1,182 +0,0 @@
import React from "react"
import { InlineText } from "./InlineEditTextarea"
import type { StoryboardEditor, SbShot } from "../hooks/useStoryboardEditor"
import type { UploadedImage } from "../types"
export interface StoryboardShotItemProps {
index: number
shot: SbShot
editor: StoryboardEditor
images: UploadedImage[]
locked: boolean
}
const MAIN_SPEAKERS = ["店主", "主播", "老板", "我", "主讲人"]
const SHOT_FIELD_KEY = (idx: number, field: string) => `shot.${idx}.${field}`
/** 单个镜头行:时间/说话人/景别/场景/动作/BGM/转场/参考图 */
export const StoryboardShotItem: React.FC<StoryboardShotItemProps> = ({
index,
shot,
editor,
images,
locked,
}) => {
const { editingField, startEdit, cancelEdit, commitField, updateShot } = editor
const refImg =
typeof shot.reference_image_index === "number" ? images[shot.reference_image_index] : undefined
const textCls = "vv-sb-field-val"
const taCls = "vv-textarea vv-sb-doc-ta vv-sb-inline-edit-ta"
const isMainSpeaker = shot.speaker ? MAIN_SPEAKERS.includes(shot.speaker) : false
return (
<div className="vv-sb-doc-shot">
{/* 时间 */}
<InlineText
value={shot.time_range}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "time_range")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "time_range"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "time_range"), v)}
onCancel={cancelEdit}
textClass="vv-sb-time-doc"
textareaClass={`${taCls} vv-sb-time-ta`}
placeholder="0-3秒"
disabled={locked}
rows={1}
/>
{/* 说话人标签(有 speaker 时展示,纯展示不加编辑) */}
{shot.speaker && (
<span
className={`vv-sb-speaker-tag ${isMainSpeaker ? "vv-sb-speaker-tag-main" : "vv-sb-speaker-tag-guest"}`}
>
{shot.speaker}
</span>
)}
{/* 景别/角度与运镜 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">景别/角度与运镜:</strong>
<InlineText
value={shot.shot_type_angle_movement}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "shot_type_angle_movement")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "shot_type_angle_movement"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "shot_type_angle_movement"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={2}
/>
</p>
{/* 场景与对白 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">场景与对白:</strong>
<InlineText
value={shot.scene_and_dialogue}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "scene_and_dialogue")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "scene_and_dialogue"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "scene_and_dialogue"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={2}
/>
</p>
{/* 动作与真人细节 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">动作与真人细节:</strong>
<InlineText
value={shot.action_details}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "action_details")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "action_details"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "action_details"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={2}
/>
</p>
{/* 音效/BGM */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">音效/BGM:</strong>
<InlineText
value={shot.audio_bgm}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "audio_bgm")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "audio_bgm"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "audio_bgm"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={1}
/>
</p>
{/* 转场 */}
<p className="vv-sb-field">
<strong className="vv-sb-field-k">转场:</strong>
<InlineText
value={shot.transition}
editing={!locked && editingField === SHOT_FIELD_KEY(index, "transition")}
onStartEdit={() => startEdit(SHOT_FIELD_KEY(index, "transition"))}
onCommit={(v) => commitField(SHOT_FIELD_KEY(index, "transition"), v)}
onCancel={cancelEdit}
textClass={textCls}
textareaClass={taCls}
disabled={locked}
multiline
rows={1}
/>
</p>
{/* 参考图片 */}
<p className="vv-sb-field vv-sb-ref-row">
<strong className="vv-sb-field-k">参考图片:</strong>
{refImg?.url ? (
<span className="vv-sb-ref-badge">
<img src={refImg.url} alt={`ref-${index}`} className="vv-sb-ref-thumb" />
{!locked && (
<button
className="vv-sb-ref-x"
onClick={() => updateShot(index, { reference_image_index: undefined })}
aria-label="移除参考图"
>
×
</button>
)}
</span>
) : !locked && images.length > 0 ? (
<span className="vv-sb-ref-pick">
{images.map((im, i) => (
<button
key={im.localId}
className="vv-sb-ref-opt"
onClick={() => updateShot(index, { reference_image_index: i })}
title={`@图片${i + 1}`}
>
<img src={im.url} alt={`img${i}`} />
</button>
))}
</span>
) : (
<span className="vv-sb-muted">未指定</span>
)}
</p>
</div>
)
}
export default StoryboardShotItem
@@ -1,26 +0,0 @@
import React from "react"
export interface VVSpinnerProps {
size?: number
color?: string
style?: React.CSSProperties
className?: string
}
/**
* 统一 spinner 组件:复用 vv-spinner CSS 动画类,避免多处散落的 LoadingOutlined
*/
export const VVSpinner: React.FC<VVSpinnerProps> = ({ size, color, style, className }) => {
return (
<span
className={`vv-spinner ${className || ""}`}
style={{
...(size ? { width: size, height: size, borderWidth: Math.max(2, size / 8) } : {}),
...(color ? { borderTopColor: color } : {}),
...style,
}}
/>
)
}
export default VVSpinner
@@ -1,216 +0,0 @@
import React from "react"
import {
UserOutlined,
AudioMutedOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
FileTextOutlined,
FolderOpenOutlined,
UploadOutlined,
CaretDownOutlined,
CaretRightOutlined,
PlusOutlined,
} from "@ant-design/icons"
import { message } from "antd"
import type { RefAudio, VoicePanel } from "../types"
import type { VoiceClone } from "@/api/voice-clone"
import { getVoiceClonePreview } from "@/api/voice-clone"
import type { AssetItem } from "@/api/assets"
import { VOICE_TIPS } from "../ViralVideoPage.constants"
export interface VoicePickerSectionProps {
refAudio: RefAudio
voicePanel: VoicePanel
voiceTipsOpen: boolean
myVoices: VoiceClone[]
myVoicesLoading: boolean
voiceAssets: { id: string; name: string; url: string; desc?: string }[]
playingVoiceId: string | null
voiceInputRef: React.Ref<HTMLInputElement>
onOpenCloneModal: () => void
onToggleVoicePanel: (p: VoicePanel) => void
onSelectMyVoice: (v: VoiceClone) => void
onSelectVoiceAsset: (a: AssetItem) => void
onToggleVoicePlay: (v: { id: string; url: string }) => void
onPickLocalAudio: () => void
onPickAssetAudio: () => void
onVoiceSelected: (file: File) => void
onRemoveRefAudio: () => void
onToggleVoiceTips: () => void
}
export const VoicePickerSection: React.FC<VoicePickerSectionProps> = (p) => {
return (
<div className="vv-subblock">
<div className="vv-subblock-head">
<span className="vv-subblock-title">AI 配音</span>
<span className="vv-opt-tag">选填</span>
<span className="vv-subblock-hint">默认使用我的克隆音色,也可选择内置音色或上传配音</span>
</div>
{/* 我的音色(主路径) */}
<div className="vv-voice-section">
<div className="vv-voice-section-head">
<span className="vv-voice-section-title">
<UserOutlined /> 我的音色
</span>
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onOpenCloneModal}>
<PlusOutlined /> 录制新音色
</button>
</div>
{p.myVoicesLoading ? (
<div className="vv-muted vv-voice-empty">加载中…</div>
) : p.myVoices.length === 0 ? (
<div className="vv-voice-empty">
<AudioMutedOutlined className="vv-voice-empty-ic" />
<div className="vv-voice-empty-text">还没有克隆音色</div>
<button className="vv-btn vv-btn-primary vv-btn-sm" onClick={p.onOpenCloneModal}>
去录制一个
</button>
</div>
) : (
<div className="vv-my-voice-list">
{p.myVoices.map((v) => {
const selected = p.refAudio?.source === "my_voice" && p.refAudio.id === v.id
const playing = p.playingVoiceId === `my-${v.id}`
return (
<div
key={v.id}
className={`vv-my-voice-item ${selected ? "selected" : ""}`}
onClick={() => p.onSelectMyVoice(v)}
>
<div className="vv-voice-radio" />
<div className="vv-voice-info">
<div className="vv-voice-name">{v.name}</div>
<div className="vv-voice-desc">
AI 克隆声音
{v.gender
? ` · ${v.gender === "male" ? "男" : v.gender === "female" ? "女" : ""}`
: ""}
</div>
</div>
<button
className={`vv-voice-play ${playing ? "playing" : ""}`}
onClick={(e) => {
e.stopPropagation()
if (v.sample_url) {
p.onToggleVoicePlay({ id: `my-${v.id}`, url: v.sample_url })
} else {
message.info("正在获取试听音频…")
getVoiceClonePreview(v.id)
.then((r) => {
if (r.audio_url)
p.onToggleVoicePlay({ id: `my-${v.id}`, url: r.audio_url })
})
.catch(() => message.error("试听获取失败"))
}
}}
>
{playing ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
</div>
)
})}
</div>
)}
</div>
{/* 次要选项 */}
<div className="vv-voice-alt-row">
<button
className={`vv-voice-alt-btn ${p.refAudio?.source === "preset" ? "selected" : ""}`}
onClick={() => p.onToggleVoicePanel("preset")}
>
<FileTextOutlined /> 内置音色
</button>
<button
className={`vv-voice-alt-btn ${p.refAudio?.source === "upload" || p.refAudio?.source === "library" ? "selected" : ""}`}
onClick={() => p.onToggleVoicePanel("library")}
>
<FolderOpenOutlined /> 上传配音
</button>
</div>
<input
ref={p.voiceInputRef}
type="file"
accept="audio/*"
hidden
onChange={(e) => {
if (e.target.files?.[0]) p.onVoiceSelected(e.target.files[0])
e.target.value = ""
}}
/>
{p.refAudio && (
<div className="vv-selected-audio">
<span className="vv-mr-6">🔊</span>
<span className="vv-flex-1">
已选:
{p.refAudio.source === "my_voice"
? `我的音色 · ${p.refAudio.name}`
: p.refAudio.source === "preset"
? `内置 · ${p.refAudio.name}`
: p.refAudio.name}
</span>
<button className="vv-link-btn" onClick={p.onRemoveRefAudio}>
移除
</button>
</div>
)}
{p.voicePanel === "library" && (
<div className="vv-voice-panel">
<div className="vv-voice-panel-actions">
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onPickLocalAudio}>
<UploadOutlined /> 本地上传音频文件
</button>
<button className="vv-link-btn vv-link-btn-sm" onClick={p.onPickAssetAudio}>
<FolderOpenOutlined /> 从素材库选择
</button>
</div>
{p.voiceAssets.length === 0 ? (
<div className="vv-muted vv-text-center vv-p-12">暂无已上传的配音文件</div>
) : (
<div className="vv-voice-list">
{p.voiceAssets.map((v) => (
<div
key={v.id}
className={`vv-voice-item ${p.refAudio?.id === v.id && (p.refAudio.source === "library" || p.refAudio.source === "upload") ? "selected" : ""}`}
onClick={() =>
p.onSelectVoiceAsset({ id: v.id, name: v.name, file_url: v.url } as AssetItem)
}
>
<div className="vv-voice-radio" />
<div className="vv-voice-info">
<div className="vv-voice-name">{v.name}</div>
<div className="vv-voice-desc">{v.desc || ""}</div>
</div>
{v.url && (
<button
className={`vv-voice-play ${p.playingVoiceId === v.id ? "playing" : ""}`}
onClick={(e) => {
e.stopPropagation()
p.onToggleVoicePlay(v)
}}
>
{p.playingVoiceId === v.id ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
)}
</div>
))}
</div>
)}
</div>
)}
<div className="vv-tips">
<button className="vv-tips-head" onClick={p.onToggleVoiceTips}>
{p.voiceTipsOpen ? <CaretDownOutlined /> : <CaretRightOutlined />}
支持格式与录音建议
</button>
{p.voiceTipsOpen && <div className="vv-tips-body">{VOICE_TIPS}</div>}
</div>
</div>
)
}
export default VoicePickerSection
@@ -1,140 +0,0 @@
import { useCallback, useState } from "react"
// 最小化的 Storyboard 类型,避免循环依赖(字段名与 api/viral-video/types 对齐)
export interface SbShot {
time_range: string
speaker?: string
shot_type_angle_movement: string
scene_and_dialogue: string
action_details: string
audio_bgm: string
transition: string
reference_image_index?: number
}
export interface SbOverview {
theme: string
total_duration: string
aspect_ratio: string
}
export interface SbDoc {
overview: SbOverview
scene_and_lighting: string
shots: SbShot[]
hard_constraints: string[]
negative_prompts: string[]
voiceover_script: string
copy_display_markdown: string
}
export type SbUpdater = (patch: (sb: SbDoc) => SbDoc) => void
export interface StoryboardEditor {
editingField: string | null
startEdit: (field: string) => void
cancelEdit: () => void
updateShot: (idx: number, patch: Partial<SbShot>) => void
updateOverview: (patch: Partial<SbOverview>) => void
updateSbField: <K extends keyof SbDoc>(key: K, value: SbDoc[K]) => void
updateConstraint: (list: "hard_constraints" | "negative_prompts", idx: number, v: string) => void
addConstraint: (list: "hard_constraints" | "negative_prompts") => void
removeConstraint: (list: "hard_constraints" | "negative_prompts", idx: number) => void
commitField: (field: string, value: string) => void
}
/**
* 分镜脚本编辑器 hook:管理 editingField 状态,以及 storyboard 字段级更新方法。
* 父组件传入 updateSb(fn) 即可,fn 接收当前 sb 返回新 sb(immer 风格)。
*/
export function useStoryboardEditor(updateSb: SbUpdater): StoryboardEditor {
const [editingField, setEditingField] = useState<string | null>(null)
const startEdit = useCallback((field: string) => {
setEditingField(field)
}, [])
const cancelEdit = useCallback(() => {
setEditingField(null)
}, [])
const updateShot = useCallback(
(idx: number, patch: Partial<SbShot>) => {
updateSb((sb) => ({
...sb,
shots: sb.shots.map((s, i) => (i === idx ? { ...s, ...patch } : s)),
}))
},
[updateSb],
)
const updateOverview = useCallback(
(patch: Partial<SbOverview>) => {
updateSb((sb) => ({ ...sb, overview: { ...sb.overview, ...patch } }))
},
[updateSb],
)
const updateSbField = useCallback(
<K extends keyof SbDoc>(key: K, value: SbDoc[K]) => {
updateSb((sb) => ({ ...sb, [key]: value }))
},
[updateSb],
)
const updateConstraint = useCallback(
(list: "hard_constraints" | "negative_prompts", idx: number, v: string) => {
updateSb((sb) => {
const arr = [...sb[list]]
arr[idx] = v
return { ...sb, [list]: arr }
})
},
[updateSb],
)
const addConstraint = useCallback(
(list: "hard_constraints" | "negative_prompts") => {
updateSb((sb) => ({ ...sb, [list]: [...sb[list], ""] }))
},
[updateSb],
)
const removeConstraint = useCallback(
(list: "hard_constraints" | "negative_prompts", idx: number) => {
updateSb((sb) => ({ ...sb, [list]: sb[list].filter((_, i) => i !== idx) }))
},
[updateSb],
)
const commitField = useCallback(
(field: string, value: string) => {
if (field.startsWith("shot.")) {
const parts = field.split(".")
const idx = Number(parts[1])
const shotField = parts.slice(2).join(".") as keyof SbShot
updateShot(idx, { [shotField]: value } as Partial<SbShot>)
} else if (field.startsWith("overview.")) {
const f = field.split(".")[1] as keyof SbOverview
updateOverview({ [f]: value } as Partial<SbOverview>)
} else if (field === "scene_and_lighting" || field === "voiceover_script") {
updateSbField(field, value)
}
setEditingField(null)
},
[updateShot, updateOverview, updateSbField],
)
return {
editingField,
startEdit,
cancelEdit,
updateShot,
updateOverview,
updateSbField,
updateConstraint,
addConstraint,
removeConstraint,
commitField,
}
}
export default useStoryboardEditor
@@ -1,403 +0,0 @@
import { useCallback, useEffect } from "react"
import axios from "axios"
import { message } from "antd"
import { isVideoStage, isCopyStage, getJobStage } from "@/api/viral-video/types"
import type { UIStep, TabTask } from "../types"
import {
retryViralVideo,
analyzeViralImages,
generateViralCopy,
confirmViralCopy,
} from "@/api/viral-video"
import type { CopyResult } from "@/api/viral-video/types"
import type { ViralVideoJob } from "@/api/viral-video/types"
import { applyPollUpdate } from "../utils/applyPollUpdate"
import {
useViralVideoMedia,
type UseViralVideoMediaOptions,
type MediaActions,
} from "./useViralVideoMedia"
import { resolveModelSwitch } from "../utils/resolveModelSwitch"
export interface ViralVideoActionsApi extends MediaActions {
resetTask: () => void
handleAnalyzeImages: () => void
handleGenerateCopy: () => void
handleGenerateVideo: () => void
handleRetry: () => void
onPollUpdate: (job: ViralVideoJob) => void
onScriptDelta: (ev: { data: { full_text?: string }; progress?: number; message?: string }) => void
handleModelChange: (
v: string,
videoModels: { key: string; supported_resolutions?: string[]; max_duration?: number }[],
) => void
setTask: (patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => void
setAssetPicker: React.Dispatch<
React.SetStateAction<{ open: boolean; kind: "image" | "video" | "voice"; multiple: boolean }>
>
setCloneModalOpen: React.Dispatch<React.SetStateAction<boolean>>
setPresetVoicePickerOpen: React.Dispatch<React.SetStateAction<boolean>>
refreshVoiceAssets: () => void
reloadMyVoices: () => void
}
export interface UseViralVideoHandlersOptions extends Omit<UseViralVideoMediaOptions, "setTask"> {
activeId: string
setTasks: React.Dispatch<React.SetStateAction<TabTask[]>>
videoModels: {
key: string
supported_resolutions?: string[]
max_duration?: number
display_name?: string
}[]
}
function emptyTask(id: string, title: string): TabTask {
return {
id,
title,
images: [],
refVideo: null,
voicePanel: null,
refAudio: null,
fusionLevel: "ai_full",
styleStrength: "medium",
userCopy: "",
industry: "",
targetCustomer: "",
language: "中文(普通话)",
viralStructure: "反差破局+亮明观点+还原现状",
marketingPurpose: "",
duration: 20,
persona: "",
videoRatio: "9:16",
videoModel: "seedance-2.5",
quality: "480p",
upscale: "default",
estimatedCredits: null,
estimatedCreditsError: false,
uiStep: "step1_upload",
imageAnalysis: null,
storyboard: null,
voiceTipsOpen: false,
job: null,
jobId: null,
playingVoiceId: null,
scriptStreamText: "",
scriptStreamProgress: 0,
scriptStreamMessage: "",
}
}
const CLEAR_STREAM = {
scriptStreamText: "",
scriptStreamProgress: 0,
scriptStreamMessage: "",
} as const
/**
* 业务 action hook:聚合素材操作 + 三步骤 submit/retry/polling/model switch 等业务动作。
*/
export function useViralVideoHandlers(opts: UseViralVideoHandlersOptions): ViralVideoActionsApi {
const {
task,
activeId,
setTasks,
setAssetPicker,
setCloneModalOpen,
refreshVoiceAssets,
reloadMyVoices,
setPresetVoicePickerOpen,
} = opts
const setTask = useCallback(
(patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => {
setTasks((ts) =>
ts.map((t) =>
t.id === activeId ? (typeof patch === "function" ? patch(t) : { ...t, ...patch }) : t,
),
)
},
[activeId, setTasks],
)
const media = useViralVideoMedia({
task,
setTask,
setAssetPicker,
setCloneModalOpen,
setPresetVoicePickerOpen,
refreshVoiceAssets,
reloadMyVoices,
})
// 卸载时停掉正在播放的音频
useEffect(() => () => media.audioRef.current?.pause(), [media.audioRef])
const resetTask = () => {
media.audioRef.current?.pause()
setTasks((ts) => ts.map((t) => (t.id === activeId ? emptyTask(t.id, t.title) : t)))
}
const handleAnalyzeImages = async () => {
const readyImgs = task.images.filter((i) => i.ossUrl && !i.uploading)
if (readyImgs.length === 0) {
message.warning("请至少上传 1 张产品图")
return
}
if (task.images.some((i) => i.uploading)) {
message.warning("图片上传中,请稍候")
return
}
if (task.refVideo?.uploading) {
message.warning("参考视频上传中,请稍候")
return
}
setTask({
uiStep: "step1_analyzing",
jobId: null,
job: null,
analysisError: undefined,
copyError: undefined,
videoError: undefined,
imageAnalysis: undefined,
storyboard: undefined,
...CLEAR_STREAM,
estimatedCredits: null,
estimatedCreditsError: false,
})
try {
const job = await analyzeViralImages({
images: readyImgs.map((i) => i.ossUrl as string),
reference_video_url: task.refVideo?.ossUrl || undefined,
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
})
setTask({ job, jobId: job.id })
message.success("已提交图片分析…")
} catch (err: unknown) {
message.error(err instanceof Error ? err.message : "分析失败")
setTask({
uiStep: "failed",
analysisError: err instanceof Error ? err.message : "分析失败",
copyError: undefined,
videoError: undefined,
})
}
}
const handleGenerateCopy = async () => {
if (!task.jobId) {
message.warning("请先完成图片分析")
return
}
setTask({
uiStep: "step2_generating",
copyError: undefined,
videoError: undefined,
analysisError: undefined,
...CLEAR_STREAM,
})
try {
const job = await generateViralCopy(task.jobId, {
industry: task.industry || undefined,
target_customer: task.targetCustomer || undefined,
viral_structure: task.viralStructure || undefined,
marketing_purpose: task.marketingPurpose || undefined,
duration: task.duration,
persona_id: task.persona || undefined,
user_copy_text:
(task.fusionLevel === "user_primary" || task.fusionLevel === "ai_polish") && task.userCopy
? task.userCopy
: undefined,
fusion_level: task.fusionLevel,
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
voice_id:
task.refAudio?.source === "preset"
? task.refAudio.id
: task.refAudio?.source === "my_voice"
? task.refAudio.ttsVoiceId || task.refAudio.id
: undefined,
voice_source: task.refAudio?.source || undefined,
video_ratio: task.videoRatio,
video_model: task.videoModel,
})
setTask({ job })
} catch (err: unknown) {
message.error(err instanceof Error ? err.message : "文案生成失败")
setTask({
uiStep: "failed",
copyError: err instanceof Error ? err.message : "文案生成失败",
videoError: undefined,
analysisError: undefined,
})
}
}
const handleGenerateVideo = async () => {
setTask({
uiStep: "step3_generating",
videoError: undefined,
copyError: undefined,
analysisError: undefined,
...CLEAR_STREAM,
})
try {
let job
if (task.jobId && task.storyboard && task.storyboard.voiceover_script.trim()) {
const edited = task.storyboard.voiceover_script.trim()
const originalCopy =
(task.job?.copy_result as CopyResult | null | undefined)?.voiceover_script ||
task.job?.copy_result?.final_copy ||
""
job = await confirmViralCopy(task.jobId, {
edited_copy: edited && edited !== originalCopy.trim() ? edited : undefined,
video_model: task.videoModel,
video_resolution: task.quality,
video_ratio: task.videoRatio,
duration: task.duration,
voice_id:
task.refAudio?.source === "preset"
? task.refAudio.id
: task.refAudio?.source === "my_voice"
? task.refAudio.ttsVoiceId || task.refAudio.id
: undefined,
voice_source: task.refAudio?.source || undefined,
language: task.language || undefined,
})
} else {
message.warning("请先生成文案后再生成视频")
setTask({
uiStep: task.storyboard
? "step2_copy_ready"
: task.imageAnalysis
? "step1_done"
: "step1_upload",
})
return
}
setTask({ job, jobId: job.id })
message.success("视频已提交生成,预计 1-3 分钟…")
} catch (err: unknown) {
if (axios.isAxiosError(err) && err.response?.status === 402) {
const detail =
(err.response.data as { detail?: string; message?: string; msg?: string } | undefined)
?.detail ||
(err.response.data as { detail?: string; message?: string; msg?: string } | undefined)
?.message ||
"积分不足,请充值"
message.error(detail)
setTask({
uiStep: "step2_copy_ready",
videoError: detail,
copyError: undefined,
analysisError: undefined,
...CLEAR_STREAM,
})
return
}
const msg = err instanceof Error ? err.message : "提交失败"
message.error(msg)
setTask({ uiStep: "failed", videoError: msg, copyError: undefined, analysisError: undefined })
}
}
const handleRetry = async () => {
if (task.jobId) {
try {
const j = await retryViralVideo(task.jobId)
const retryStage = getJobStage(j)
let retryStep: UIStep = "step1_analyzing"
if (isVideoStage(retryStage)) retryStep = "step3_generating"
else if (isCopyStage(retryStage)) retryStep = "step2_generating"
else if (task.storyboard) retryStep = "step2_copy_ready"
else if (task.imageAnalysis) retryStep = "step1_done"
setTask({
job: j,
jobId: j.id,
uiStep: retryStep,
videoError: undefined,
copyError: undefined,
analysisError: undefined,
imageAnalysis: undefined,
storyboard: undefined,
...CLEAR_STREAM,
estimatedCredits: null,
estimatedCreditsError: false,
})
message.success("已重试")
} catch (err: unknown) {
message.error(err instanceof Error ? err.message : "重试失败")
}
} else {
setTask({
uiStep: task.storyboard ? "step2_copy_ready" : "step1_done",
jobId: null,
job: null,
videoError: undefined,
copyError: undefined,
analysisError: undefined,
imageAnalysis: undefined,
storyboard: undefined,
...CLEAR_STREAM,
estimatedCredits: null,
estimatedCreditsError: false,
})
}
}
const onPollUpdate = useCallback(
(job: ViralVideoJob) => {
setTask((t) => applyPollUpdate(t, job))
},
[setTask],
)
const onScriptDelta = useCallback(
(ev: { data: { full_text?: string }; progress?: number; message?: string }) => {
setTask((t) => ({
...t,
scriptStreamText: ev.data.full_text || t.scriptStreamText,
scriptStreamProgress:
typeof ev.progress === "number" ? ev.progress : t.scriptStreamProgress,
scriptStreamMessage: ev.message || t.scriptStreamMessage,
}))
},
[setTask],
)
const handleModelChange = (
v: string,
vms: { key: string; supported_resolutions?: string[]; max_duration?: number }[],
) => {
const m = vms.find((x) => x && x.key === v)
if (!m) {
setTask({ videoModel: v })
return
}
const { quality, duration } = resolveModelSwitch(
{ quality: task.quality, duration: task.duration },
m,
)
setTask({ videoModel: v, quality, duration })
}
return {
...media,
resetTask,
handleAnalyzeImages,
handleGenerateCopy,
handleGenerateVideo,
handleRetry,
onPollUpdate,
onScriptDelta,
handleModelChange,
setTask,
setAssetPicker,
setCloneModalOpen,
setPresetVoicePickerOpen,
refreshVoiceAssets,
reloadMyVoices,
}
}
export default useViralVideoHandlers
@@ -1,349 +0,0 @@
import { useCallback, useRef } from "react"
import { message } from "antd"
import { getAssetLibraries, uploadAssetDirect, type AssetItem } from "@/api/assets"
import type { UploadedImage, RefVideo, RefAudio, VoicePanel, TabTask } from "../types"
import type { VoiceClone } from "@/api/voice-clone"
export interface UseViralVideoMediaOptions {
task: TabTask
setTask: (patch: Partial<TabTask> | ((t: TabTask) => TabTask)) => void
setAssetPicker: React.Dispatch<
React.SetStateAction<{ open: boolean; kind: "image" | "video" | "voice"; multiple: boolean }>
>
setCloneModalOpen: React.Dispatch<React.SetStateAction<boolean>>
setPresetVoicePickerOpen: React.Dispatch<React.SetStateAction<boolean>>
refreshVoiceAssets: () => void
reloadMyVoices: () => void
}
export interface MediaActions {
imgInputRef: React.MutableRefObject<HTMLInputElement | null>
videoInputRef: React.MutableRefObject<HTMLInputElement | null>
voiceInputRef: React.MutableRefObject<HTMLInputElement | null>
audioRef: React.MutableRefObject<HTMLAudioElement | null>
appendImagesFromFiles: (files: FileList | File[]) => void
appendImagesFromAssets: (assets: AssetItem[]) => void
clearImages: () => void
removeImg: (localId: string) => void
moveImg: (idx: number, dir: -1 | 1) => void
handleLocalRefVideo: (file: File) => void
removeRefVideo: () => void
handleVoiceFile: (file: File) => void
onCloneSuccess: (v: VoiceClone | { id?: string; name?: string; voice_id?: string | null }) => void
pickVoiceAsset: (a: AssetItem) => void
selectMyVoice: (v: VoiceClone) => void
toggleVoicePanel: (p: VoicePanel) => void
toggleVoice: (v: { id: string; url: string }) => void
openAssetPicker: (kind: "image" | "video" | "voice", multiple: boolean) => void
}
/**
* 素材上传/音色选择/图片列表/参考视频等操作:独立 hook,保持每个文件 ≤400 行。
*/
export function useViralVideoMedia(opts: UseViralVideoMediaOptions): MediaActions {
const {
task,
setTask,
setAssetPicker,
setCloneModalOpen,
refreshVoiceAssets,
setPresetVoicePickerOpen,
} = opts
const imgInputRef = useRef<HTMLInputElement>(null)
const videoInputRef = useRef<HTMLInputElement>(null)
const voiceInputRef = useRef<HTMLInputElement>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const uploadFile = useCallback(
async (file: File, kind: "image" | "video" | "voice", onProgress?: (pct: number) => void) => {
const libs = await getAssetLibraries()
const lib = (libs as { kind: string; id: string }[]).find((l) => l.kind === kind) || libs[0]
if (!lib) throw new Error("未找到素材库")
const res = await uploadAssetDirect({
file,
library_id: lib.id,
onProgress: (p) => onProgress?.(Math.round(p)),
})
return res.url
},
[],
)
const appendImagesFromFiles = useCallback(
async (files: FileList | File[]) => {
const remaining = 9 - task.images.length
if (remaining <= 0) {
message.warning("最多上传 9 张产品图")
return
}
const arr = Array.from(files)
.filter((f) => f.type.startsWith("image/"))
.slice(0, remaining)
if (arr.length === 0) return
const newImgs: UploadedImage[] = arr.map((f) => ({
localId: `${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
url: URL.createObjectURL(f),
name: f.name,
size: f.size,
uploading: true,
pct: 0,
file: f,
}))
setTask((t) => ({
...t,
images: [...t.images, ...newImgs.map(({ file: _f, ...rest }) => rest)],
}))
for (const item of newImgs) {
try {
const url = await uploadFile(item.file!, "image", (p) => {
setTask((t) => ({
...t,
images: t.images.map((im) => (im.localId === item.localId ? { ...im, pct: p } : im)),
}))
})
setTask((t) => ({
...t,
images: t.images.map((im) =>
im.localId === item.localId
? {
...im,
uploading: false,
pct: 100,
ossUrl: url,
size: item.file!.size || im.size,
}
: im,
),
}))
} catch {
message.error(`${item.name} 上传失败`)
setTask((t) => ({ ...t, images: t.images.filter((im) => im.localId !== item.localId) }))
}
}
},
[task.images.length, setTask, uploadFile],
)
const appendImagesFromAssets = useCallback(
(assets: AssetItem[]) => {
const remaining = 9 - task.images.length
const picked = assets.slice(0, remaining)
if (picked.length === 0) return
const newImgs: UploadedImage[] = picked.map((a) => ({
localId: `lib_${a.id}_${Math.random().toString(36).slice(2, 6)}`,
url: a.thumbnail_url || a.file_url || "",
name: a.name || "未命名",
size: a.file_size || undefined,
uploading: false,
pct: 100,
ossUrl: a.file_url || undefined,
}))
setTask((t) => ({ ...t, images: [...t.images, ...newImgs] }))
message.success(`已添加 ${picked.length} 张图片`)
},
[task.images.length, setTask],
)
const clearImages = () => {
setTask((t) => {
t.images.forEach((im) => {
if (im.url?.startsWith("blob:")) URL.revokeObjectURL(im.url)
})
return {
...t,
images: [],
imageAnalysis: null,
uiStep: "step1_upload",
storyboard: null,
job: null,
jobId: null,
}
})
}
const removeImg = (localId: string) => {
setTask((t) => {
const im = t.images.find((i) => i.localId === localId)
if (im?.url?.startsWith("blob:")) URL.revokeObjectURL(im.url)
const newImgs = t.images.filter((i) => i.localId !== localId)
return {
...t,
images: newImgs,
imageAnalysis: newImgs.length === 0 ? null : t.imageAnalysis,
storyboard: newImgs.length === 0 ? null : t.storyboard,
}
})
}
const moveImg = (idx: number, dir: -1 | 1) => {
setTask((t) => {
const imgs = [...t.images]
const target = idx + dir
if (target < 0 || target >= imgs.length) return t
;[imgs[idx], imgs[target]] = [imgs[target], imgs[idx]]
return { ...t, images: imgs }
})
}
const handleLocalRefVideo = useCallback(
async (file: File) => {
if (file.size > 200 * 1024 * 1024) {
message.warning("视频不能超过 200MB")
return
}
const localUrl = URL.createObjectURL(file)
setTask({
refVideo: {
source: "local",
url: localUrl,
name: file.name,
uploading: true,
pct: 0,
file,
} as RefVideo,
})
try {
const url = await uploadFile(file, "video", (p) => {
setTask((t) => (t.refVideo ? { ...t, refVideo: { ...t.refVideo, pct: p } } : t))
})
setTask((t) =>
t.refVideo
? { ...t, refVideo: { ...t.refVideo, uploading: false, pct: 100, ossUrl: url } }
: t,
)
} catch {
message.error("参考视频上传失败")
if (localUrl.startsWith("blob:")) URL.revokeObjectURL(localUrl)
setTask({ refVideo: null })
}
},
[setTask, uploadFile],
)
const removeRefVideo = () => {
setTask((t) => {
if (t.refVideo?.url.startsWith("blob:")) URL.revokeObjectURL(t.refVideo.url)
return { ...t, refVideo: null }
})
}
const handleVoiceFile = useCallback(
async (file: File) => {
if (file.size > 10 * 1024 * 1024) {
message.warning("音频不能超过 10MB")
return
}
const localUrl = URL.createObjectURL(file)
try {
const url = await uploadFile(file, "voice")
setTask({
refAudio: { source: "upload", name: file.name, url, id: url } as RefAudio,
voicePanel: null,
})
message.success("音频已上传")
refreshVoiceAssets()
} catch {
message.error("音频上传失败")
URL.revokeObjectURL(localUrl)
}
},
[setTask, uploadFile, refreshVoiceAssets],
)
const onCloneSuccess = (
v: VoiceClone | { id?: string; name?: string; voice_id?: string | null },
) => {
setCloneModalOpen(false)
opts.reloadMyVoices()
const vid = "voice_id" in v ? v.voice_id : undefined
if (v?.id) {
setTask({
refAudio: {
source: "my_voice",
id: v.id,
name: v.name || "我录制的音色",
ttsVoiceId: vid || undefined,
} as RefAudio,
voicePanel: null,
})
refreshVoiceAssets()
message.success("音色克隆完成,已自动选中")
}
}
const pickVoiceAsset = (asset: AssetItem) => {
setTask({
refAudio: {
source: "library",
id: asset.id,
name: asset.name,
url: asset.file_url || "",
} as RefAudio,
voicePanel: null,
})
}
const selectMyVoice = (v: VoiceClone) => {
setTask({
refAudio: {
source: "my_voice",
id: v.id,
name: v.name,
ttsVoiceId: v.voice_id || undefined,
} as RefAudio,
voicePanel: null,
})
}
const toggleVoicePanel = (p: VoicePanel) => {
if (p === "preset") {
setPresetVoicePickerOpen(true)
return
}
setTask({ voicePanel: task.voicePanel === p ? null : p })
}
const openAssetPicker = (kind: "image" | "video" | "voice", multiple: boolean) => {
setAssetPicker({ open: true, kind, multiple })
}
const toggleVoice = (v: { id: string; url: string }) => {
if (!v.url) return
if (task.playingVoiceId === v.id) {
audioRef.current?.pause()
audioRef.current = null
setTask({ playingVoiceId: null })
return
}
audioRef.current?.pause()
const a = new Audio(v.url)
a.dataset.playingId = v.id
a.onended = () => setTask({ playingVoiceId: null })
a.play().catch(() => {})
audioRef.current = a
setTask({ playingVoiceId: v.id })
}
return {
imgInputRef,
videoInputRef,
voiceInputRef,
audioRef,
appendImagesFromFiles,
appendImagesFromAssets,
clearImages,
removeImg,
moveImg,
handleLocalRefVideo,
removeRefVideo,
handleVoiceFile,
onCloneSuccess,
pickVoiceAsset,
selectMyVoice,
toggleVoicePanel,
toggleVoice,
openAssetPicker,
}
}
export default useViralVideoMedia
@@ -1,11 +1,6 @@
import { useCallback, useEffect, useRef } from "react"
import { getViralVideoJob } from "@/api/viral-video"
import {
isAnalysisStage,
getJobStage,
type ViralVideoJob,
type ViralVideoStatus,
} from "@/api/viral-video/types"
import { isAnalysisStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
@@ -16,9 +11,8 @@ export interface UseViralVideoPollingOptions {
/**
* 爆款视频任务 HTTP 轮询 hook。
* - onUpdate 通过 ref 持有,避免 useCallback 依赖变化导致重启轮询。
* - 任务进入终态(completed/failed/cancelled)后自动停止。
* - 连续失败 20 次(约 2 分钟)后熔断。
* 负责持续拉取任务状态并回调给上层;上层负责根据状态/阶段切换 UI 文案。
* 任务进入终态(completed/failed/cancelled)后自动停止。
*/
export function useViralVideoPolling(
jobId: string | null | undefined,
@@ -29,9 +23,6 @@ export function useViralVideoPolling(
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const stoppedRef = useRef(false)
const failCountRef = useRef(0)
// ref 持有最新 onUpdate,避免依赖变化导致 pollOnce 重建、轮询被重启
const onUpdateRef = useRef(onUpdate)
onUpdateRef.current = onUpdate
const stop = useCallback(() => {
stoppedRef.current = true
@@ -46,30 +37,26 @@ export function useViralVideoPolling(
try {
const job = await getViralVideoJob(id)
failCountRef.current = 0
onUpdateRef.current(job)
onUpdate(job)
if (TERMINAL.includes(job.status)) {
stop()
return
}
if (stoppedRef.current) return
const stage = getJobStage(job)
const inRender = stage === "rendering"
const isAnalyzing = isAnalysisStage(stage)
// 视频渲染阶段(Seedance 多段视频生成较慢)拉长轮询间隔
const inRender = job.progress_stage === "rendering"
// 分析阶段走默认间隔即可
const isAnalyzing = isAnalysisStage(job.progress_stage)
const nextDelay = inRender ? 3000 : isAnalyzing ? 2000 : intervalMs
timerRef.current = setTimeout(() => pollOnce(id), nextDelay)
} catch (_err) {
failCountRef.current += 1
if (stoppedRef.current) return
if (failCountRef.current >= 20) {
// 轮询连续失败 20 次,停止轮询
stop()
return
}
const delay = Math.min(intervalMs * 2 ** Math.min(failCountRef.current, 3), 10000)
timerRef.current = setTimeout(() => pollOnce(id), delay)
}
},
[intervalMs, stop],
[intervalMs, onUpdate, stop],
)
useEffect(() => {
@@ -1,151 +0,0 @@
import { useCallback, useEffect, useRef } from "react"
export interface ViralVideoDeltaEvent {
type: "viral_video:script_delta"
job_id: string
stage: string
progress: number
message?: string
data: {
delta: string
full_text: string
text_length: number
}
}
export interface ViralVideoWSError {
message: string
}
export interface ViralVideoTerminalEvent {
type: "viral_video:completed" | "viral_video:failed" | "viral_video:copy_generated"
job_id: string
}
export interface UseViralVideoWSOptions {
/** delta 事件回调(script_delta 推送时触发) */
onDelta?: (ev: ViralVideoDeltaEvent) => void
/** 连接关闭/失败回调 */
onError?: (err: ViralVideoWSError) => void
/** 连接建立回调 */
onOpen?: () => void
/** P2-6: 收到终态事件(copy_generated/completed/failed)时回调,上层应立即主动拉取一次最新 job,减少终态延迟 */
onTerminal?: (ev: ViralVideoTerminalEvent) => void
}
const WS_SCHEME =
typeof window !== "undefined" && window.location.protocol === "https:" ? "wss:" : "ws:"
function buildWsUrl(jobId: string): string {
const token = localStorage.getItem("access_token") || ""
const host = window.location.host
return `${WS_SCHEME}//${host}/api/v1/viral-video/ws/${jobId}?token=${encodeURIComponent(token)}`
}
/**
* 爆款视频 WebSocket 订阅 hook。
* 仅在 jobId 非空时建立连接;组件卸载/ jobId 变更时自动关闭。
* 失败静默:后端已说明流式异常会自动 fallback 到 HTTP 轮询的 copy_generated 终态,
* 前端不需要中断主流程,连接失败时只记录不影响轮询继续推进 UI。
*/
export function useViralVideoWS(
jobId: string | null | undefined,
{ onDelta, onError, onOpen, onTerminal }: UseViralVideoWSOptions = {},
) {
const wsRef = useRef<WebSocket | null>(null)
const manualCloseRef = useRef(false)
const onDeltaRef = useRef(onDelta)
const onErrorRef = useRef(onError)
const onOpenRef = useRef(onOpen)
const onTerminalRef = useRef<UseViralVideoWSOptions["onTerminal"]>()
onDeltaRef.current = onDelta
onErrorRef.current = onError
onOpenRef.current = onOpen
onTerminalRef.current = onTerminal
const close = useCallback(() => {
manualCloseRef.current = true
if (wsRef.current) {
try {
wsRef.current.close()
} catch {
/* noop */
}
wsRef.current = null
}
}, [])
useEffect(() => {
if (!jobId) {
close()
return
}
manualCloseRef.current = false
let closed = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
let startTimer: ReturnType<typeof setTimeout> | null = null
let retries = 0
const MAX_RETRIES = 3
const connect = () => {
if (closed) return
try {
const ws = new WebSocket(buildWsUrl(jobId))
wsRef.current = ws
ws.onopen = () => {
if (closed) return
retries = 0
onOpenRef.current?.()
}
ws.onmessage = (ev) => {
try {
const payload = JSON.parse(ev.data)
if (payload?.type === "viral_video:script_delta" && payload.data) {
onDeltaRef.current?.(payload as ViralVideoDeltaEvent)
} else if (
payload?.type === "viral_video:completed" ||
payload?.type === "viral_video:failed" ||
payload?.type === "viral_video:copy_generated"
) {
// P2-6: 收到终态事件时主动通知上层立即拉取,降低终态延迟(不用等下一个轮询周期)
onTerminalRef.current?.(payload as ViralVideoTerminalEvent)
}
// 心跳/其他事件忽略
} catch {
// 心跳/非 JSON 消息忽略
}
}
ws.onerror = () => {
onErrorRef.current?.({ message: "websocket error" })
}
ws.onclose = () => {
wsRef.current = null
if (closed || manualCloseRef.current) return
if (retries < MAX_RETRIES) {
retries += 1
const delay = 500 * 2 ** (retries - 1)
retryTimer = setTimeout(connect, delay)
}
}
} catch (e) {
onErrorRef.current?.({ message: (e as Error).message })
}
}
// 延迟 100ms 再连,给后端 job 初始化留一点时间
startTimer = setTimeout(connect, 100)
return () => {
closed = true
if (startTimer) clearTimeout(startTimer)
if (retryTimer) clearTimeout(retryTimer)
close()
}
}, [jobId, close])
return { close }
}
-132
View File
@@ -1,132 +0,0 @@
import type {
FusionLevel,
StyleStrength,
ViralVideoJob,
ImageAnalysisResult,
CopyResult,
ShotScript,
ViralVideoModel,
} from "@/api/viral-video/types"
/* ─────────── UI 阶段(三步骤独立交互) ─────────── */
export type UIStep =
| "step1_upload" // 初始:上传素材
| "step1_analyzing" // 图片分析中
| "step1_done" // 图片分析完成
| "step2_generating" // 文案生成中
| "step2_copy_ready" // 文案已生成,等待确认
| "step3_generating" // 视频生成中
| "step3_done" // 视频完成
| "failed" // 失败
export const TERMINAL_UI_STEPS: UIStep[] = ["step3_done", "failed"]
export type UploadedImage = {
localId: string
url: string
name: string
size: number | undefined
uploading: boolean
pct: number
ossUrl?: string
file?: File
}
export type RefVideo = {
source: "local"
url: string
name: string
uploading: boolean
pct: number
ossUrl?: string
file?: File
} | null
export type RefAudio = {
source: "preset" | "library" | "upload" | "clone" | "my_voice"
id?: string
name: string
url?: string
/** 克隆音色的 CosyVoice voice_id(source=my_voice 时使用) */
ttsVoiceId?: string
} | null
export type VoicePanel = "my_voices" | "preset" | "upload" | "library" | "record" | null
// ── 分镜脚本数据模型(新后端 copy_result 结构) ──
export interface StoryboardShot {
time_range: string
speaker?: string
shot_type_angle_movement: string
scene_and_dialogue: string
action_details: string
audio_bgm: string
transition: string
reference_image_index?: number
}
export interface Storyboard {
overview: { theme: string; total_duration: string; aspect_ratio: string }
scene_and_lighting: string
shots: StoryboardShot[]
hard_constraints: string[]
negative_prompts: string[]
voiceover_script: string
/** v3: 用户端展示用 markdown 文案(由提示词控制排版) */
copy_display_markdown: string
}
export type TabTask = {
id: string
title: string
// 素材
images: UploadedImage[]
refVideo: RefVideo
voicePanel: VoicePanel
refAudio: RefAudio
// STEP2 参数
fusionLevel: FusionLevel
styleStrength: StyleStrength
userCopy: string
industry: string
targetCustomer: string
persona: string
language: string
viralStructure: string
marketingPurpose: string
duration: number
// STEP3 设置
videoRatio: string
videoModel: string
quality: string
upscale: string
// 积分预估
estimatedCredits: number | null
estimatedCreditsError: boolean
// UI 状态
uiStep: UIStep
imageAnalysis: ImageAnalysisResult | null
storyboard: Storyboard | null
voiceTipsOpen: boolean
copyError?: string
videoError?: string
analysisError?: string
// 运行态(后端 job)
job: ViralVideoJob | null
jobId: string | null
playingVoiceId: string | null
// 分镜脚本流式预览
scriptStreamText: string
scriptStreamProgress: number
scriptStreamMessage: string
}
// re-export api types for convenience
export type {
FusionLevel,
StyleStrength,
ViralVideoJob,
ImageAnalysisResult,
CopyResult,
ShotScript,
ViralVideoModel,
}
@@ -1,99 +0,0 @@
import { message } from "antd"
import {
isVideoStage,
isCopyStage,
isImageAnalysisStage,
getJobStage,
type ViralVideoJob,
} from "@/api/viral-video/types"
import type { UIStep, TabTask } from "../types"
import { TERMINAL_UI_STEPS } from "../types"
import { copyResultToStoryboard } from "./copyResultToStoryboard"
import { classifyJobError, cleanErrorMessage } from "./errorClassification"
/**
* 根据 job 状态计算下一个 TabTask 快照:单调性守卫 + 阶段推进 + 错误分类。
* 返回 null 表示丢弃此次更新(终态守卫命中,使用旧状态即可)。
*/
export function applyPollUpdate(t: TabTask, job: ViralVideoJob): TabTask {
if (
TERMINAL_UI_STEPS.includes(t.uiStep) &&
job.status !== "completed" &&
job.status !== "failed"
) {
return t
}
let nextStep: UIStep = t.uiStep
let copyError = t.copyError
let videoError = t.videoError
let analysisError = t.analysisError
const err = job.error_message || job.error_msg
const nextImageAnalysis = job.image_analysis || t.imageAnalysis
const nextCopy = job.copy_result
const nextStoryboard = nextCopy ? copyResultToStoryboard(nextCopy) || t.storyboard : t.storyboard
const stage = getJobStage(job)
switch (job.status) {
case "image_analyzed":
nextStep = "step1_done"
copyError = undefined
break
case "copy_generated":
nextStep = "step2_copy_ready"
copyError = undefined
break
case "running":
if (isVideoStage(stage)) nextStep = "step3_generating"
else if (isCopyStage(stage)) nextStep = "step2_generating"
else if (isImageAnalysisStage(stage)) nextStep = "step1_analyzing"
copyError = undefined
videoError = undefined
break
case "completed":
nextStep = "step3_done"
copyError = undefined
videoError = undefined
break
case "failed": {
if (stage === "review" && err && (err.includes("合规") || err.includes("审核未通过"))) {
nextStep = "step2_copy_ready"
copyError = err
videoError = undefined
analysisError = undefined
message.warning(err)
break
}
nextStep = "failed"
const cleaned = cleanErrorMessage(err || "")
const kind = classifyJobError(cleaned, stage)
if (kind === "video") {
videoError = cleaned || "视频生成失败"
copyError = undefined
analysisError = undefined
} else if (kind === "image") {
analysisError = cleaned || "图片分析失败"
copyError = undefined
videoError = undefined
} else {
copyError = cleaned || "文案生成失败"
videoError = undefined
analysisError = undefined
}
break
}
case "pending":
break
}
return {
...t,
job,
uiStep: nextStep,
imageAnalysis: nextImageAnalysis,
storyboard: nextStoryboard,
copyError,
videoError,
analysisError,
}
}
export default applyPollUpdate
@@ -1,71 +0,0 @@
import type { Storyboard } from "../types"
import type { CopyResult, ShotScript } from "@/api/viral-video/types"
export function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard | null {
if (!cr) return null
if (Array.isArray(cr.shots) && cr.overview) {
return {
overview: {
theme: cr.overview.theme || cr.title || "",
total_duration: (() => {
const d = cr.overview.total_duration
if (!d) return ""
return String(d).endsWith("s") ? String(d) : `${d}s`
})(),
aspect_ratio: cr.overview.aspect_ratio || "9:16",
},
scene_and_lighting: cr.scene_and_lighting || "",
shots: ((cr.shots ?? []) as ShotScript[]).map((s) => ({
time_range: s.time_range || "",
speaker: s.speaker || "",
shot_type_angle_movement: s.shot_type_angle_movement || "",
scene_and_dialogue: s.scene_and_dialogue || "",
action_details: s.action_details || "",
audio_bgm: s.audio_bgm || "",
transition: s.transition || "",
reference_image_index:
typeof s.reference_image_index === "number" ? s.reference_image_index : undefined,
})),
hard_constraints: Array.isArray(cr.hard_constraints) ? cr.hard_constraints : [],
negative_prompts: Array.isArray(cr.negative_prompts) ? cr.negative_prompts : [],
voiceover_script: cr.voiceover_script || cr.final_copy || cr.suggested_copy || "",
copy_display_markdown: cr.copy_display_markdown || "",
}
}
const finalCopy = cr.final_copy || cr.suggested_copy || ""
const title = cr.title || ""
return {
overview: { theme: title || "AI 分镜脚本", total_duration: "", aspect_ratio: "9:16" },
scene_and_lighting: "",
shots:
cr.scenes && cr.scenes.length
? cr.scenes.map((sc, i: number) => ({
time_range: sc.duration
? `${i * sc.duration}-${(i + 1) * sc.duration}s`
: `镜头${i + 1}`,
shot_type_angle_movement: sc.shot || "",
scene_and_dialogue: sc.narration || "",
action_details: "",
audio_bgm: "",
transition: "",
}))
: finalCopy
? [
{
time_range: "0-15s",
shot_type_angle_movement: "",
scene_and_dialogue: finalCopy,
action_details: "",
audio_bgm: "",
transition: "",
},
]
: [],
hard_constraints: [],
negative_prompts: [],
voiceover_script: finalCopy,
copy_display_markdown: cr.copy_display_markdown || "",
}
}
export default copyResultToStoryboard
@@ -1,55 +0,0 @@
import {
isCopyStage,
isImageAnalysisStage,
isVideoStage,
getJobStage,
} from "@/api/viral-video/types"
import type { ViralVideoJob, ViralVideoStage } from "@/api/viral-video/types"
/**
* 统一的视频/文案/图片阶段错误分类。
* 优先按 stage 字段判定;stage 丢失时基于错误消息关键词兜底。
*/
export type JobErrorKind = "image" | "copy" | "video"
const VIDEO_ERR_RE =
/wan 3\.0|wan-?3|seedance|视频生成|视频下载|视频渲染|tts|配音|render|musetalk|lip.?sync/i
const IMAGE_ERR_RE = /vlm|图片分析|vision|商品识别|ocr|image analysis|图片识别|图像分析/i
export function classifyJobError(err: string, stage?: string | null): JobErrorKind {
const msg = err || ""
if (stage) {
const s = stage as ViralVideoStage
if (isVideoStage(s)) return "video"
if (isImageAnalysisStage(s)) return "image"
if (isCopyStage(s)) return "copy"
}
if (VIDEO_ERR_RE.test(msg)) return "video"
if (IMAGE_ERR_RE.test(msg)) return "image"
return "copy"
}
/**
* 去掉后端错误消息中常见的"XX失败:"前缀,避免前端 UI 重复前缀。
*/
const ERROR_PREFIX_RE =
/^(视频生成失败|文案生成失败|图片分析失败|Wan 3\.0 视频生成失败|Seedance[0-9. ]*视频生成失败)[::]\s*/g
export function cleanErrorMessage(err: string): string {
if (!err) return ""
return err.replace(ERROR_PREFIX_RE, "").trim()
}
/**
* 基于 job 统一提取错误分类和清理后的消息;无错误返回 null。
*/
export function extractJobError(
job: ViralVideoJob | null | undefined,
): { kind: JobErrorKind; msg: string } | null {
if (!job) return null
const raw = job.error_message || job.error_msg
if (!raw) return null
const stage = getJobStage(job)
const kind = classifyJobError(raw, stage)
return { kind, msg: cleanErrorMessage(raw) }
}
@@ -1,70 +0,0 @@
/**
* 极简 markdown 渲染器:仅支持粗体、斜体、标题、列表、换行。
* 不依赖 marked 包(node_modules 中暂未安装),满足 viral-video 文案预览的
* 简单排版需求。
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
function inline(md: string): string {
let s = escapeHtml(md)
// bold **xxx**
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
// italic *xxx*
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
// inline code `xxx`
s = s.replace(/`([^`]+)`/g, "<code>$1</code>")
return s
}
export function renderMarkdown(md: string): string {
if (!md) return ""
const lines = md.split(/\r?\n/)
const out: string[] = []
let inList = false
for (const raw of lines) {
const line = raw.trimEnd()
if (!line.trim()) {
if (inList) {
out.push("</ul>")
inList = false
}
continue
}
// headings
const h = line.match(/^(#{1,4})\s+(.*)$/)
if (h) {
if (inList) {
out.push("</ul>")
inList = false
}
const lvl = h[1].length
out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`)
continue
}
// unordered list
const li = line.match(/^\s*[-*]\s+(.*)$/)
if (li) {
if (!inList) {
out.push("<ul>")
inList = true
}
out.push(`<li>${inline(li[1])}</li>`)
continue
}
if (inList) {
out.push("</ul>")
inList = false
}
out.push(`<p>${inline(line)}</p>`)
}
if (inList) out.push("</ul>")
return out.join("\n")
}
export default renderMarkdown
@@ -1,30 +0,0 @@
import { RESOLUTION_ORDER } from "../ViralVideoPage.constants"
/**
* resolveModelSwitch 所需的最小模型信息(结构性类型,兼容 ViralVideoModel)。
*/
export interface ModelSwitchInfo {
supported_resolutions?: string[]
max_duration?: number
}
/**
* 切换视频模型时,联动修正分辨率/时长:新模型不支持当前分辨率则回退到最低支持,
* 当前时长超过新模型 max_duration 则截断到 max_duration。
*/
export function resolveModelSwitch(
cur: { quality: string; duration: number },
m: ModelSwitchInfo,
): { quality: string; duration: number } {
const mRes = Array.isArray(m.supported_resolutions)
? m.supported_resolutions
: ["480p", "720p", "1080p"]
const sortedRes = mRes.slice().sort((a, b) => {
const ra = RESOLUTION_ORDER.indexOf(a)
const rb = RESOLUTION_ORDER.indexOf(b)
return (rb === -1 ? -1 : rb) - (ra === -1 ? -1 : ra)
})
const newRes = mRes.includes(cur.quality) ? cur.quality : sortedRes[0] || "480p"
const newDur = Math.min(cur.duration, typeof m.max_duration === "number" ? m.max_duration : 15)
return { quality: newRes, duration: newDur }
}
+25
View File
@@ -116,6 +116,31 @@ const appChildren: RouteObject[] = [
path: "profile",
lazy: lazyRoute(() => import("@/pages/profile/Settings")),
},
{
path: "admin",
children: [
{
index: true,
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
{
path: "users",
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
{
path: "analytics",
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
{
path: "monitor",
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
{
path: "logs",
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
},
],
},
]
export const appRoutes: RouteObject = {
+21 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import {
generateViralVideo,
getViralVideoJob,
confirmViralVideoIntent,
retryViralVideo,
getViralVideoHistory,
getViralStyleTemplates,
@@ -74,10 +76,10 @@ describe("viral-video constants & stage helpers", () => {
})
it("isCopyStage", () => {
expect(isCopyStage("intent_parsing")).toBe(true)
expect(isCopyStage("script_generation")).toBe(true)
expect(isCopyStage("review")).toBe(true)
expect(isCopyStage("tts")).toBe(false)
expect(isCopyStage("image_analysis")).toBe(false)
})
it("isAnalysisStage is union", () => {
@@ -99,6 +101,14 @@ describe("viral-video API wrappers", () => {
mockPost.mockResolvedValue({ data: {} })
})
it("generateViralVideo", async () => {
mockPost.mockResolvedValue({ data: { id: "j1" } })
const r = generateViralVideo({ images: ["img1"] } as never)
vi.runAllTimersAsync()
expect(await r).toEqual({ id: "j1" })
expect(mockPost).toHaveBeenCalledWith("/viral-video/generate", { images: ["img1"] })
})
it("getViralVideoJob", async () => {
mockGet.mockResolvedValue({ data: { id: "j2" } })
const r = getViralVideoJob("j2")
@@ -107,6 +117,16 @@ describe("viral-video API wrappers", () => {
expect(mockGet).toHaveBeenCalledWith("/viral-video/j2")
})
it("confirmViralVideoIntent", async () => {
mockPost.mockResolvedValue({ data: { id: "j3" } })
const r = confirmViralVideoIntent("j3", { confirmed_copy: "hi" })
vi.runAllTimersAsync()
await r
expect(mockPost).toHaveBeenCalledWith("/viral-video/j3/confirm-intent", {
confirmed_copy: "hi",
})
})
it("retryViralVideo", async () => {
mockPost.mockResolvedValue({ data: { id: "j4" } })
await retryViralVideo("j4")
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import AdminComingSoon from "@/pages/admin/AdminComingSoon"
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
}
})
describe("AdminComingSoon Page", () => {
it("should render without crashing", () => {
render(
<MemoryRouter>
<AdminComingSoon />
</MemoryRouter>,
)
expect(screen.getByText("Admin 后台暂未开放")).toBeTruthy()
})
it("should render back button", () => {
render(
<MemoryRouter>
<AdminComingSoon />
</MemoryRouter>,
)
expect(screen.getByText("返回首页")).toBeTruthy()
})
})
-3
View File
@@ -53,9 +53,6 @@ celery_app.conf.imports = (
# #1998 GPU MuseTalk 异步推理:wait_for_result→签名 URL→回写 lipsync_jobs
# 必须在 Worker 侧注册,否则 apply_async 消息无人消费,job 永远卡在 processing
"app.tasks.lipsync_gpu",
# #2076 Ditto 蚂蚁数字人异步推理:同步 HTTP 调用 Ditto → MP4 流转存 OSS → 回写 lipsync_jobs
# 必须在 Worker 侧注册;失败回退 GPU MuseTalk → MediaKit
"app.tasks.lipsync_ditto",
)
# Celery Beat 定时任务调度
@@ -387,41 +387,6 @@ BATCH_RENDER_SIMILARITY_LIMIT = 0.20
"""批次内成片查重相似度阈值:超过则重选独立 plan 重渲一次(20%)。"""
def _refund_smart_edit_prepaid(task_id: str) -> None:
"""智能剪辑任务最终失败时退还预扣积分(幂等)。"""
session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.domain.points_service import PointsService
repo = SQLAlchemyGenerationTaskRepository(session)
task = repo.get(task_id)
if not task:
return
prepaid = float(getattr(task, "credits_prepaid", 0) or 0)
if prepaid <= 0:
return
txn_id = getattr(task, "credits_transaction_id", "") or ""
res = PointsService().refund_points(
user_id=task.user_id,
amount=prepaid,
source="smart_edit",
db=session,
ref_id=task.id,
related_transaction_id=txn_id or None,
description="智能剪辑任务失败退回",
)
task.credits_cost = 0.0
task.credits_prepaid = 0.0
repo.update(task)
if not res.get("success"):
logger.warning("[task_id=%s] 失败退积分未成功: %s", task_id, res)
finally:
session.close()
def should_rerender_for_batch_dedup(*, batch_id: str, render_attempt: int, batch_similarity) -> bool:
"""批次内查重后判定是否需要重选 plan 重渲。
@@ -1202,10 +1167,6 @@ def generate_video(self, task_id: str) -> dict:
"mark_failed",
error_message="source_edit_plan_id is required. Please create a preview task first.",
)
try:
_refund_smart_edit_prepaid(task_id)
except Exception:
logger.warning("[task_id=%s] 失败退积分异常", task_id, exc_info=True)
return {
"status": "failed",
"task_id": task_id,
@@ -1244,7 +1205,6 @@ def generate_video(self, task_id: str) -> dict:
)
# ── 自动重试逻辑 ──────────────────────────────────────────────────
will_retry = False
try:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
@@ -1257,7 +1217,6 @@ def generate_video(self, task_id: str) -> dict:
if _task and _task.auto_retry_enabled and _task.auto_retry_max > 0:
current_retry = _task.retry_count or 0
if current_retry < _task.auto_retry_max:
will_retry = True
logger.info(
"[task_id=%s] 触发自动重试: 当前重试次数=%d, 最大重试次数=%d",
task_id,
@@ -1291,13 +1250,6 @@ def generate_video(self, task_id: str) -> dict:
exc_info=True,
)
# 最终失败(不再重试):退还 smart_edit 预扣积分
if not will_retry:
try:
_refund_smart_edit_prepaid(task_id)
except Exception:
logger.warning("[task_id=%s] 失败退积分异常", task_id, exc_info=True)
return {
"status": "failed",
"task_id": task_id,
File diff suppressed because it is too large Load Diff
@@ -1,95 +0,0 @@
# -*- coding: utf-8 -*-
"""V2 prompt 解析:优先读后台 viral_video_prompt_templates(prompt_type='image_analysis'
且 is_active=true),30s TTL 热加载;DB 无有效记录/异常时,fallback 到 prompts.py 的
image_analysis v8 默认 system/user。
规则:
- DB 有 is_active=true 的 image_analysis 记录:system 原样用 DB.system_prompt
(自带完整输出格式,不追加任何硬编码 schema),user 用 DB.user_prompt_template
渲染(填入 image_url / ocr_text);
- DB 无记录/异常:system/user 用 prompts.py 里的 v8 默认模板。
"""
from __future__ import annotations
import logging
import threading
import time
logger = logging.getLogger(__name__)
def _default_template() -> dict:
# 延迟导入:避免模块加载时拉起整个 packages 依赖链(也便于旧 Python 收集测试)
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
for item in DEFAULT_TEMPLATES:
if item["prompt_type"] == "image_analysis":
return item
raise RuntimeError("image_analysis 默认模板缺失")
_cache_lock = threading.Lock()
_cache: dict[str, tuple[float, tuple[str, str]]] = {}
_CACHE_TTL = 30.0
def _load_db_template():
"""查 DB is_active=true 的 image_analysis 记录;不可达/无记录返回 None。"""
try:
from packages.application.viral_video.prompt_loader import _load_from_db
return _load_from_db("image_analysis")
except Exception as e: # noqa: BLE001
logger.warning("[vision.v2] 查询DB image_analysis prompt失败: %s", e)
return None
def _render_user(user_tpl: str, image_url: str, ocr_text: str) -> str:
try:
return user_tpl.format(image_url=image_url, ocr_text=ocr_text or "无")
except Exception: # noqa: BLE001
return user_tpl
def _resolve(kind: str, image_url: str = "", ocr_text: str = "") -> tuple[str, str]:
now = time.time()
cache_key = f"prompt_{kind}"
with _cache_lock:
hit = _cache.get(cache_key)
if hit and now - hit[0] < _CACHE_TTL:
sys_prompt, usr_prompt = hit[1]
return sys_prompt, _render_user(usr_prompt, image_url, ocr_text)
default = _default_template()
sys_prompt = default["system_prompt"]
usr_prompt = default["user_prompt_template"]
tpl = _load_db_template()
if tpl is not None:
db_sys = (getattr(tpl, "system_prompt", "") or "").strip()
if db_sys:
sys_prompt = db_sys
db_usr = getattr(tpl, "user_prompt_template", "") or usr_prompt
usr_prompt = db_usr or usr_prompt
logger.info(
"[vision.v2] 使用DB image_analysis prompt version=%s",
getattr(tpl, "version", "?"),
)
with _cache_lock:
_cache[cache_key] = (now, (sys_prompt, usr_prompt))
return sys_prompt, _render_user(usr_prompt, image_url, ocr_text)
def resolve_fast_prompt(image_url: str = "", ocr_text: str = "") -> tuple[str, str]:
return _resolve("fast", image_url, ocr_text)
def resolve_pro_prompt(image_url: str = "", ocr_text: str = "") -> tuple[str, str]:
return _resolve("pro", image_url, ocr_text)
def invalidate_cache() -> None:
with _cache_lock:
_cache.clear()
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
"""V2 VLM 多后端 provider 切换:ark(火山方舟/豆包,默认)/ dashscope(阿里云百炼/qwen)。
通过环境变量 VISION_V2_PROVIDER 切换,默认 dashscope(对比测试用)。
各后端的 fast(小模型 JSON-only)和 pro(大模型兜底)模型配置如下:
- ark: doubao-seed-2-1-lite-260915 / doubao-seed-2-1-pro-260915
- dashscope: qwen3.8-flash / qwen3.7-plus
"""
from __future__ import annotations
import logging
import os
from typing import Any
logger = logging.getLogger(__name__)
# 默认 dashscope 做对比测试(灵应10-05指示)
PROVIDER = os.environ.get("VISION_V2_PROVIDER", "dashscope").lower()
# fast 模型(JSON-only,强约束输出)
_FAST_MODEL_MAP = {
"ark": "doubao-seed-2-1-lite-260915",
"dashscope": "qwen3.8-flash",
}
# pro 模型(兜底)
_PRO_MODEL_MAP = {
"ark": "doubao-seed-2-1-pro-260915",
"dashscope": "qwen3.7-plus",
}
# base URL
_BASE_URL_MAP = {
"ark": "https://ark.cn-beijing.volces.com/api/v3",
"dashscope": "https://dashscope.aliyuncs.com/compatible-mode/v1",
}
# API key 环境变量
_API_KEY_ENVS = {
"ark": "DOUBAO_API_KEY",
"dashscope": "DASHSCOPE_API_KEY",
}
# 关 thinking 参数(各后端不一样)
_THINKING_PARAMS = {
# 豆包 Seed 2.x 用 thinking={type:"disabled"},不要和 reasoning_effort 同时传
"ark": {"thinking": {"type": "disabled"}},
# 百炼 qwen3 用 enable_thinking:false(顶层字段,不是 OpenAI 标准)
"dashscope": {"enable_thinking": False},
}
# fast/pro 超时
_FAST_TIMEOUT_MAP = {"ark": 8, "dashscope": 8}
_PRO_TIMEOUT_MAP = {"ark": 45, "dashscope": 20}
def get_provider() -> str:
return PROVIDER
def fast_model() -> str:
return _FAST_MODEL_MAP.get(PROVIDER, _FAST_MODEL_MAP["ark"])
def pro_model() -> str:
return _PRO_MODEL_MAP.get(PROVIDER, _PRO_MODEL_MAP["ark"])
def base_url() -> str:
return _BASE_URL_MAP.get(PROVIDER, _BASE_URL_MAP["ark"])
def api_key() -> str | None:
"""返回当前 provider 的 API key;ark 从 shared_settings 读,dashscope 从环境变量读。"""
if PROVIDER == "ark":
try:
from packages.shared import get_shared_settings
return get_shared_settings().doubao_api_key
except Exception:
return os.environ.get("DOUBAO_API_KEY")
return os.environ.get(_API_KEY_ENVS.get(PROVIDER, "DASHSCOPE_API_KEY"))
def thinking_params() -> dict[str, Any]:
return dict(_THINKING_PARAMS.get(PROVIDER, {}))
def is_dashscope() -> bool:
return PROVIDER == "dashscope"
def fast_timeout_default() -> int:
return _FAST_TIMEOUT_MAP.get(PROVIDER, 8)
def pro_timeout_default() -> int:
return _PRO_TIMEOUT_MAP.get(PROVIDER, 45)
def is_400_thinking_error(body_text: str) -> bool:
"""400 响应是否是因为 thinking 参数不被支持(触发降级重试)。"""
b = body_text.lower()
if PROVIDER == "ark":
return "thinking" in b or "reasoning" in b
if PROVIDER == "dashscope":
return "enable_thinking" in b or "thinking" in b
return False
def pop_thinking_param(payload: dict[str, Any]) -> None:
"""从 payload 里移除 thinking 相关参数(降级重试用)。"""
if PROVIDER == "ark":
payload.pop("thinking", None)
payload.pop("reasoning_effort", None)
elif PROVIDER == "dashscope":
payload.pop("enable_thinking", None)
payload.pop("thinking", None)
+285 -84
View File
@@ -1,106 +1,307 @@
"""V2 结果组装(v8 叙述优先,大幅精简)。
# -*- coding: utf-8 -*-
"""把 fast_json VLM 输出 + OCR 文本组装为与旧 _normalize() 完全一致的 dict。
设计原则:VLM 直接输出最终给用户看的 summary_markdown,assembler 只负责
- 解析 fast JSON(兼容顶层 {"images":[...]} 与 {"products":[...]} 两种键);
- 补齐 5 个必备字段(type/name/brand/has_person/summary_markdown);
- summary_markdown 缺失(异常)时才拼一句最基础的兜底文字。
正常情况下不改写、不“润色”VLM 输出,不做 brand 多级兜底,不处理任何
colors/material/key_features 等细分字段。
目标:下游(信任链t2i/intent_parsing/script_generation)零改动。
必出字段:name, brand, category, appearance, packaging, text_on_package,
key_features, scene, mood, portrait_prompt, summary, _source
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
_VALID_TYPES = ("store", "product", "person", "scene")
# ---------- portrait_prompt 模板 ----------
# 目标:60-100 字的人物穿搭描述,用于 Seedream 纯文生图。要求具体、风格化、视觉细节丰富。
# 旧 VLM 输出格式参考:"一位25岁左右的亚洲女性,身穿白色V领短袖T恤,黑色高腰阔腿裤,
# 搭配银色项链,长发披肩,表情自信,街拍风格,阳光明媚的城市街头"
def _coerce_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in ("true", "1", "yes", "是")
return False
def _join_parts(*parts: str | None) -> str:
return "".join(p for p in parts if p)
def _basic_markdown(image: dict[str, Any]) -> str:
"""异常兜底:VLM 没给 summary_markdown 时只拼一句基础文字。"""
name = (image.get("name") or "").strip() or "未识别"
brand = (image.get("brand") or "").strip()
typ = image.get("type") or "scene"
label = f"{brand}{name}" if brand and brand not in name else (brand or name)
if typ == "store":
return f"这是{label}的门店场景,画面细节识别不完整。"
if typ == "person":
return f"画面中的人物与{label}相关,细节识别不完整。"
if typ == "product":
return f"这是{label}的商品图片,具体外观细节识别不完整。"
return f"画面内容为{label},细节识别不完整。"
_AGE_PREFIX = {
"青年": "年轻",
"中年": "中年",
"老年": "老年",
}
# gender 后缀
_GENDER_WORD = {"男": "男性", "女": "女性"}
def _normalize_image(raw: Any, idx: int) -> dict[str, Any]:
"""把一条 VLM 输出归一化为 5 字段 dict。"""
if not isinstance(raw, dict):
raw = {}
def _person_subject(fj: dict[str, Any]) -> str:
"""人物主语:年轻女性 / 中年男性 / 少女 / 小男孩 / 人物 等。"""
gender = fj.get("gender") or ""
age = fj.get("age_range") or ""
gw = _GENDER_WORD.get(gender, "")
if age == "儿童":
if gender == "女":
return "小女孩"
if gender == "男":
return "小男孩"
return "儿童"
if age == "青少年":
if gender == "女":
return "少女"
if gender == "男":
return "少年"
return "青少年"
prefix = _AGE_PREFIX.get(age, "")
if gw:
return f"{prefix}{gw}" if prefix else gw
return f"{prefix}人物" if prefix else "人物"
typ = str(raw.get("type") or "").strip().lower()
if typ not in _VALID_TYPES:
typ = "scene"
name = str(raw.get("name") or "").strip() or "未识别"
brand = str(raw.get("brand") or "").strip()
has_person = _coerce_bool(raw.get("has_person"))
if typ == "person" and not has_person:
# type=person 通常意味着主体是人,保持一致(仅异常补全)
has_person = True
def _build_wear_sentence(fj: dict[str, Any]) -> str:
"""穿搭段:上装+下装/连衣裙,带颜色+材质+图案。"""
upper = fj.get("upper_wear") or ""
upper_color = fj.get("upper_color") or ""
lower = fj.get("lower_wear") or ""
lower_color = fj.get("lower_color") or ""
dress_color = fj.get("dress_color") or ""
material = fj.get("material") or ""
pattern = fj.get("pattern") or ""
summary = raw.get("summary_markdown")
summary = summary.strip() if isinstance(summary, str) else ""
is_dress = ("连衣裙" in upper) or ("裙" in upper and not lower)
if is_dress:
c = dress_color or upper_color
wear = f"{c}{upper}" if c else upper
if material and material not in wear:
wear = f"{material}{wear}"
if pattern and pattern not in wear and pattern != "纯色":
wear += f",{pattern}图案"
return f"身穿{wear}"
image: dict[str, Any] = {
"type": typ,
parts: list[str] = []
if upper:
up = f"{upper_color}{upper}" if upper_color else upper
if material and material not in up:
up = f"{material}{up}"
if pattern and pattern != "纯色" and pattern not in up:
up += f"({pattern})"
parts.append(f"上身{up}" if up else "")
if lower:
lo = f"{lower_color}{lower}" if lower_color else lower
parts.append(f"下身{lo}" if lo else "")
return ",".join(p for p in parts if p)
def _build_portrait_prompt(fj: dict[str, Any]) -> str:
"""组装最终 portrait_prompt(目标 60-100 字,用于 Seedream 纯文生图)。"""
if not fj.get("has_person"):
# 非人像:用商品+场景+mood 拼一段
name = fj.get("product_name") or "商品"
brand = fj.get("brand") or ""
colors = fj.get("colors") or []
style = fj.get("style") or ""
scene = fj.get("scene") or ""
mood = fj.get("mood") or ""
pieces = []
if brand:
pieces.append(brand)
pieces.append(name)
if colors:
pieces.append("、".join(colors[:3]) + "配色")
if style:
pieces.append(style + "风格")
if mood:
pieces.append(mood + "氛围")
if scene and scene not in ("通用",):
pieces.append(scene + "场景")
pieces.append("产品特写")
prompt = ",".join(p for p in pieces if p)
return prompt if len(prompt) >= 10 else "产品展示图,特写镜头"
subject = _person_subject(fj)
wear = _build_wear_sentence(fj)
accessories = fj.get("accessories") or []
if isinstance(accessories, str):
accessories = [accessories]
acc_str = ""
if accessories:
acc_str = ",佩戴" + "、".join(str(a) for a in accessories if a)
hairstyle = fj.get("hairstyle") or ""
expression = fj.get("expression") or ""
pose = fj.get("pose") or ""
style = fj.get("style") or ""
scene = fj.get("scene") or ""
mood = fj.get("mood") or ""
detail_parts: list[str] = []
if hairstyle:
detail_parts.append(hairstyle)
if expression and expression not in ("自然", "平静"):
detail_parts.append(f"神情{expression}")
if pose and pose not in ("站立",):
detail_parts.append(pose)
style_parts: list[str] = []
if style:
style_parts.append(style)
if mood:
style_parts.append(mood)
if scene and scene not in ("通用",):
style_parts.append(scene)
pieces = [f"一位{subject}"]
if wear:
pieces.append(wear)
if acc_str:
pieces.append(acc_str.lstrip(","))
if detail_parts:
pieces.append(",".join(detail_parts))
if style_parts:
# 风格词之间不用逗号,用空格紧凑
pieces.append("".join(style_parts) + "风格")
else:
pieces.append("人像写真")
full = ",".join(p for p in pieces if p)
# 过短补充镜头词
if len(full) < 40:
full += ",自然光线下人像特写,画面清晰"
# 过长截断
if len(full) > 120:
full = full[:120].rstrip(",") + "。"
return full
# ---------- 商品字段 ----------
def _infer_name(fj: dict[str, Any], ocr_texts: list[str]) -> str:
pname = fj.get("product_name")
if pname and pname != "未识别":
return str(pname)
# 人物图 → name 用穿搭主件
if fj.get("has_person"):
up = fj.get("upper_wear") or ""
if "连衣裙" in up:
return up
return up or "人物穿搭"
if ocr_texts:
# 商品名可能是 OCR 最长的一行(品牌/产品名)
return max(ocr_texts, key=len)
return "未识别"
def _infer_brand(fj: dict[str, Any], ocr_texts: list[str]) -> str:
brand = fj.get("brand")
if brand:
return str(brand)
# OCR 里短的、纯字母/汉字短串可能是 brand
for t in ocr_texts:
if 1 < len(t) <= 12:
return t
return "无法判断"
def _infer_category(fj: dict[str, Any]) -> str:
cat = fj.get("category")
if cat:
return str(cat)
if fj.get("has_person"):
return "服饰"
return "非产品图"
def _build_appearance(fj: dict[str, Any]) -> str:
"""外观描述:颜色+款式+材质+图案 拼成一段。"""
parts: list[str] = []
for key, _label in [
("upper_color", "主色"),
("upper_wear", "款式"),
("material", "材质"),
("pattern", "图案"),
]:
v = fj.get(key)
if v and v not in ("无法判断", "未知", "纯色"):
parts.append(str(v))
if not parts:
if fj.get("has_person"):
return "人像穿搭整体造型"
return "无法判断"
return "、".join(parts)
def _build_key_features(fj: dict[str, Any], ocr_texts: list[str]) -> list[str]:
feats: list[str] = []
for key in (
"upper_wear",
"lower_wear",
"upper_color",
"lower_color",
"dress_color",
"material",
"pattern",
"style",
"accessories",
):
v = fj.get(key)
if not v:
continue
if isinstance(v, list):
feats.extend(str(x) for x in v if x)
elif isinstance(v, str) and v not in ("无法判断", "未知", "纯色"):
feats.append(v)
if ocr_texts:
feats.append(f"画面文字: {'/'.join(ocr_texts[:3])}")
# 去重
out: list[str] = []
seen: set[str] = set()
for f in feats:
f = f.strip()
if f and f not in seen and len(f) <= 30:
seen.add(f)
out.append(f)
return out[:6] if out else ["无法判断"]
def assemble_result(
idx: int,
fast_json: dict[str, Any] | None,
ocr_texts: list[str],
) -> dict[str, Any]:
"""把 fast_json 结果 + OCR 文本组装成下游兼容的 product dict。"""
fj = fast_json or {}
ocr_texts = ocr_texts or []
portrait_prompt = _build_portrait_prompt(fj)
name = _infer_name(fj, ocr_texts)
brand = _infer_brand(fj, ocr_texts)
category = _infer_category(fj)
appearance = _build_appearance(fj)
key_features = _build_key_features(fj, ocr_texts)
scene = fj.get("scene") or "通用"
mood = fj.get("mood") or ""
packaging = "无法判断" # 包装细节专用API无,保留占位
text_on_package = ocr_texts[:8]
summary = _build_summary(fj, name, brand, category)
return {
"name": name,
"brand": brand,
"has_person": has_person,
"summary_markdown": summary,
"category": category,
"appearance": appearance,
"packaging": packaging,
"text_on_package": text_on_package,
"key_features": key_features,
"scene": scene,
"mood": mood,
"portrait_prompt": portrait_prompt,
"summary": summary,
"_source": "v2_fast_json",
}
if not summary:
image["summary_markdown"] = _basic_markdown(image)
image["_source"] = "summary_missing"
logger.info("[assembler] 图片 #%s 缺少 summary_markdown,使用基础兜底", idx)
return image
def _extract_items(fast_json: Any) -> list[Any]:
"""从 fast JSON 中取出图片条目:优先 images,兼容 products。"""
if not isinstance(fast_json, dict):
return []
items = fast_json.get("images")
if not isinstance(items, list):
items = fast_json.get("products")
return items if isinstance(items, list) else []
def assemble_result(idx: int, fast_json: Any, ocr_texts: list[str] | None = None) -> dict[str, Any]:
"""组装单张图片分析结果。
每次调用对应一张图片;fast_json 形如 {"images": [{...}]}(v8)。
返回单条 image dict(5 字段,必要时带 _source)。
"""
items = _extract_items(fast_json)
if items:
image = _normalize_image(items[0], idx)
else:
# 极端异常:fast 无任何可用条目,OCR 文字可作为名称线索
ocr_hint = ""
if ocr_texts:
ocr_hint = "、".join(t for t in ocr_texts if t)[:40]
image = _normalize_image({"name": ocr_hint or "未识别", "summary_markdown": ""}, idx)
image["_source"] = "empty_fast_json"
return image
def _build_summary(fj: dict, name: str, brand: str, category: str) -> str:
if fj.get("has_person"):
up = fj.get("upper_wear") or "穿搭"
style = fj.get("style") or ""
base = f"{style}{up}" if style and style not in up else up
return base
if brand != "无法判断" and name != brand:
return f"{brand} {name}"
return name
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""V2 图片分析主路径(v8 叙述优先):每图并行 OCR(火山 MediaKit,未配置自动跳过)
+ fast VLM 强约束 JSON;失败时单次 pro VLM 兜底。
"""V2 图片分析主路径:每图并行 OCR(火山专用API,未配置时自动跳过)+ lite JSON VLM,失败时单次 pro VLM 兜底。
架构:
- 单图 2 路并行(OCR + fast VLM),外层 N 图全并发(workers=8);
- 兜底单次 pro VLM,无竞速/复杂重试;
- 输出统一为 5 字段 image dict(type/name/brand/has_person/summary_markdown)。
支持双后端(环境变量 VISION_V2_PROVIDER=ark|dashscope,默认 dashscope)。
设计原则:
- 主力路径简洁:单图2路并行,外层N图全并发
- 兜底简单:单次 pro VLM 调用,无竞速/重试/复杂超时
- 输出 dict 格式与旧版完全一致,下游零改动
"""
from __future__ import annotations
@@ -16,28 +16,40 @@ import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from . import assembler, ocr_volc, vlm_fallback, vlm_fast_json
from . import _provider, assembler, ocr_volc, vlm_fallback, vlm_fast_json
logger = logging.getLogger(__name__)
# 可通过环境变量调参(默认值随 provider 变化)
_IMG_WORKERS = int(os.environ.get("VISION_V2_IMG_WORKERS", "8"))
_FAST_TIMEOUT = float(os.environ.get("VISION_V2_FAST_TIMEOUT", "20"))
_FAST_JSON_TIMEOUT = float(os.environ.get("VISION_V2_FAST_JSON_TIMEOUT", "20"))
_FAST_TIMEOUT = float(os.environ.get("VISION_V2_FAST_TIMEOUT", "8"))
_FAST_JSON_TIMEOUT = float(os.environ.get("VISION_V2_FAST_JSON_TIMEOUT", str(_provider.fast_timeout_default())))
_OCR_TIMEOUT = float(os.environ.get("VISION_V2_OCR_TIMEOUT", "6"))
_PRO_TIMEOUT = float(os.environ.get("VISION_V2_PRO_TIMEOUT", "45"))
_PRO_TIMEOUT = float(os.environ.get("VISION_V2_PRO_TIMEOUT", str(_provider.pro_timeout_default())))
_FALLBACK_RESULT = {
"name": "未识别",
"brand": "无法判断",
"category": "非产品图",
"appearance": "无法判断",
"packaging": "无法判断",
"text_on_package": [],
"key_features": ["无法判断"],
"scene": "通用",
"mood": "",
"portrait_prompt": "无法判断",
"summary": "未识别",
}
def _is_usable(r: dict[str, Any] | None) -> bool:
if not isinstance(r, dict):
return False
return bool((r.get("summary_markdown") or "").strip())
def _basic_failure(ocr_result: list[str], fast_elapsed: float, source: str) -> dict[str, Any]:
image = assembler.assemble_result(-1, {}, ocr_result)
image["_source"] = source
image["_fast_elapsed"] = round(fast_elapsed, 2)
return image
def _is_usable(r: dict[str, Any]) -> bool:
pp = (r.get("portrait_prompt") or "").strip()
if pp and pp not in ("无人像", "无法判断", "未识别"):
return True
name = (r.get("name") or "").strip()
if name and name not in ("未识别", "无法判断", "未知"):
return True
return False
def analyze_image_v2(idx: int, img_url: str) -> dict[str, Any]:
@@ -45,45 +57,60 @@ def analyze_image_v2(idx: int, img_url: str) -> dict[str, Any]:
fj_result: dict[str, Any] | None = None
ocr_result: list[str] = []
pool = ThreadPoolExecutor(max_workers=2)
f_fj = pool.submit(vlm_fast_json.call_fast_json, img_url, timeout=_FAST_JSON_TIMEOUT)
f_ocr = pool.submit(ocr_volc.call_ocr, img_url, timeout=_OCR_TIMEOUT)
try:
for fut in as_completed([f_fj, f_ocr], timeout=_FAST_TIMEOUT):
try:
res = fut.result(timeout=1)
except Exception as e: # noqa: BLE001
logger.warning("[vision.v2] 图片 #%d 子任务异常: %s", idx, e)
continue
if fut is f_fj and isinstance(res, dict):
fj_result = res
elif fut is f_ocr and isinstance(res, list):
ocr_result = res
except TimeoutError:
for f in (f_fj, f_ocr):
if not f.done():
f.cancel()
logger.warning("[vision.v2] 图片 #%d fast路径超时(%.0fs),走pro兜底", idx, _FAST_TIMEOUT)
finally:
fast_elapsed = time.time() - t0
pool.shutdown(wait=False)
with ThreadPoolExecutor(max_workers=2) as pool:
f_fj = pool.submit(vlm_fast_json.call_fast_json, img_url, timeout=_FAST_JSON_TIMEOUT)
f_ocr = pool.submit(ocr_volc.call_ocr, img_url, timeout=_OCR_TIMEOUT)
try:
for fut in as_completed([f_fj, f_ocr], timeout=_FAST_TIMEOUT):
try:
res = fut.result(timeout=1)
except Exception as e:
logger.warning("[vision.v2] 图片 #%d 子任务异常: %s", idx, e)
continue
if fut is f_fj and isinstance(res, dict):
fj_result = res
elif fut is f_ocr and isinstance(res, list):
ocr_result = res
except TimeoutError:
for f in (f_fj, f_ocr):
if not f.done():
f.cancel()
logger.warning("[vision.v2] 图片 #%d fast路径超时(%.0fs),走pro兜底", idx, _FAST_TIMEOUT)
fast_elapsed = time.time() - t0
if fj_result:
assembled = assembler.assemble_result(idx, fj_result, ocr_result)
if _is_usable(assembled):
assembled["_fast_elapsed"] = round(fast_elapsed, 2)
logger.info("[vision.v2] 图片 #%d fast命中 elapsed=%.2fs", idx, fast_elapsed)
logger.info(
"[vision.v2] 图片 #%d fast命中 provider=%s elapsed=%.2fs pp=%s",
idx,
_provider.get_provider(),
fast_elapsed,
(assembled.get("portrait_prompt") or "")[:40],
)
return assembled
pro_result = vlm_fallback.call_pro_vlm(img_url, idx, ocr_hint=ocr_result, timeout=_PRO_TIMEOUT)
if _is_usable(pro_result):
pro_t0 = time.time()
pro_result = vlm_fallback.call_pro_vlm(img_url, idx, timeout=_PRO_TIMEOUT)
if pro_result and _is_usable(pro_result):
pro_result["_fallback_used"] = True
pro_result["_fast_elapsed"] = round(fast_elapsed, 2)
pro_result["_pro_elapsed"] = round(time.time() - pro_t0, 2)
if ocr_result and not pro_result.get("text_on_package"):
pro_result["text_on_package"] = ocr_result[:8]
logger.info("[vision.v2] 图片 #%d pro兜底命中 total=%.2fs", idx, time.time() - t0)
return pro_result
logger.warning("[vision.v2] 图片 #%d 全路径失败 elapsed=%.2fs", idx, time.time() - t0)
return _basic_failure(ocr_result, fast_elapsed, "v2_all_failed")
logger.warning(
"[vision.v2] 图片 #%d 全路径失败 provider=%s elapsed=%.2fs", idx, _provider.get_provider(), time.time() - t0
)
out = dict(_FALLBACK_RESULT)
out["_source"] = "v2_all_failed"
out["text_on_package"] = ocr_result[:8]
out["_fast_elapsed"] = round(fast_elapsed, 2)
return out
def analyze_images_v2(img_urls: list[str]) -> list[dict[str, Any]]:
@@ -93,9 +120,10 @@ def analyze_images_v2(img_urls: list[str]) -> list[dict[str, Any]]:
results: list[dict[str, Any] | None] = [None] * len(img_urls)
logger.info(
"[vision.v2] 开始图片分析 n=%d workers=%d fast_timeout=%.0fs pro_timeout=%.0fs",
"[vision.v2] 开始图片分析 n=%d workers=%d provider=%s fast_timeout=%.0fs pro_timeout=%.0fs",
len(img_urls),
workers,
_provider.get_provider(),
_FAST_TIMEOUT,
_PRO_TIMEOUT,
)
@@ -106,19 +134,14 @@ def analyze_images_v2(img_urls: list[str]) -> list[dict[str, Any]]:
idx = future_to_idx[fut]
try:
results[idx] = fut.result()
except Exception as e: # noqa: BLE001
except Exception as e:
logger.warning("[vision.v2] 图片 #%d future异常: %s", idx, e, exc_info=True)
results[idx] = assembler.assemble_result(idx, {}, [])
results[idx]["_source"] = "v2_future_exception" # type: ignore[index]
r = dict(_FALLBACK_RESULT)
r["_source"] = "v2_future_exception"
results[idx] = r
elapsed = time.time() - t0
succ = sum(1 for r in results if _is_usable(r))
succ = sum(1 for r in results if r and _is_usable(r))
fb = sum(1 for r in results if r and r.get("_fallback_used"))
logger.info(
"[vision.v2] 完成 n=%d usable=%d pro_fallback=%d elapsed=%.2fs",
len(img_urls),
succ,
fb,
elapsed,
)
return [r for r in results if r is not None] # type: ignore[misc]
logger.info("[vision.v2] 完成 n=%d usable=%d pro_fallback=%d elapsed=%.2fs", len(img_urls), succ, fb, elapsed)
return [r for r in results if r is not None]
@@ -1,138 +0,0 @@
# -*- coding: utf-8 -*-
"""VLM 返回文本的稳健 JSON 提取工具。
背景:复杂门店图 VLM 输出经常被 max_tokens 截断(finish_reason=length),
json.loads 失败后整个结果被丢弃,导致"未识别"。本工具提供:
1. markdown 代码块剥离(含只开不闭的截断场景)
2. 最外层 { } 切片
3. 非法控制字符清理
4. 直接 json.loads
5. 截断 JSON 括号/引号栈补全修复
6. 尾部逐字符截断重试(去除最后一个不完整 token 后修复)
成功返回 dict;截断修复产物带 _partial=True 标记;彻底失败返回 None。
"""
from __future__ import annotations
import json
import logging
import re
logger = logging.getLogger(__name__)
_CODE_FENCE_RE = re.compile(r"^```(?:json)?\s*\n?(.*?)\n?```\s*$", re.DOTALL)
def _strip_code_fence(s: str) -> str:
s = s.strip()
m = _CODE_FENCE_RE.match(s)
if m:
return m.group(1).strip()
# 兼容开头 ```json 但结尾无 ```(截断场景)
if s.startswith("```"):
lines = s.split("\n")
if lines and lines[0].startswith("```"):
lines = lines[1:]
s = "\n".join(lines).strip()
return s
def _repair_truncated_json(text: str) -> str:
"""尝试补全被截断的JSON:维护 bracket/quote 栈,在末尾补闭合符。"""
stack: list[str] = []
in_string = False
escape = False
for ch in text:
if escape:
escape = False
continue
if ch == "\\" and in_string:
escape = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch in "{[":
stack.append(ch)
elif ch == "}":
if stack and stack[-1] == "{":
stack.pop()
elif ch == "]":
if stack and stack[-1] == "[":
stack.pop()
repair = ""
if in_string:
repair += '"'
for opener in reversed(stack):
repair += "}" if opener == "{" else "]"
if repair:
logger.info(
"[json_utils] 截断JSON修复: 补全%d个闭合符 in_string=%s",
len(repair),
in_string,
)
return text + repair
def _clean_invalid_chars(text: str) -> str:
"""清理JSON中非法的控制字符(tab/newline 之外的 0x00-0x1f 段)。"""
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text)
def extract_json_object(text: str) -> dict | None:
"""从VLM返回文本中稳健提取JSON对象。
返回 dict 或 None。成功的 dict 可能带 _partial=True 标记,
表示原始文本被截断、经括号补全后得到的产物。
"""
if not text or not isinstance(text, str):
return None
# 1. 剥离 markdown
text = _strip_code_fence(text)
# 2. 找最外层 { }
lpos = text.find("{")
if lpos < 0:
return None
rpos = text.rfind("}")
if rpos > lpos:
text = text[lpos : rpos + 1]
else:
# 截断场景:无任何闭合 },取到末尾交给修复器
text = text[lpos:]
# 3. 清理非法控制字符
text = _clean_invalid_chars(text)
# 4. 直接 loads
try:
obj = json.loads(text)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
pass
# 5. 尝试截断修复
repaired = _repair_truncated_json(text)
try:
obj = json.loads(repaired)
if isinstance(obj, dict):
obj["_partial"] = True
return obj
except json.JSONDecodeError:
pass
# 6. 尾部逐字符截断重试(去除最后一个不完整 token)
for _ in range(50):
last_comma = repaired.rfind(",")
last_brace = max(repaired.rfind("}"), repaired.rfind("]"))
cut = max(last_comma, last_brace)
if cut < 10:
break
repaired = repaired[: cut + 1]
repaired = _repair_truncated_json(repaired)
try:
obj = json.loads(repaired)
if isinstance(obj, dict):
obj["_partial"] = True
return obj
except json.JSONDecodeError:
continue
return None
@@ -1,109 +1,353 @@
# -*- coding: utf-8 -*-
"""V2 兜底路径:vision client(fallback 变体)单图调用,走 v8 叙述优先 prompt。
"""VLM pro 兜底:fast 路径失败时的单次调用,支持双后端。
fast 超时/非 JSON/为空时单次调用;输出统一走 assembler.assemble_result 组装,
与 fast 路径同为 5 字段 image dict。
- ark(火山/豆包):通过 ai_client 走 doubao-seed-2-1-pro,保留 XML+JSON 双解析和 prompt_loader
- dashscope(百炼/qwen):直接 httpx 走 qwen3.7-plus,用精简 JSON-only prompt 提升速度
设计原则:简单、直接、无竞速、无复杂重试。
"""
from __future__ import annotations
import json
import logging
import re
import time
from typing import Any
from . import _prompt, assembler
from . import _provider
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 45
DEFAULT_TIMEOUT = _provider.pro_timeout_default()
DEFAULT_MAX_TOKENS = 800
# DashScope 兜底用的精简 JSON-only prompt(比 prompt_loader 模板短很多,减少延迟)
_DS_PRO_SYSTEM = (
"你是图片分析助手。仔细观察图片,严格按JSON schema返回一个对象,不要任何解释、"
"不要markdown、不要代码块、不要前后缀文字。字段值不确定时填null或空数组。\n"
"{\n"
' "has_person": true/false,\n'
' "gender": "男"/"女"/null,\n'
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
' "outfit": "人物穿搭描述,60字以内(例:白色T恤+牛仔裤)",\n'
' "hair": "发型",\n'
' "pose": "姿态",\n'
' "expression": "表情",\n'
' "scene": "场景",\n'
' "mood": "氛围",\n'
' "has_product": true/false,\n'
' "category": "类目",\n'
' "product_name": "产品名称",\n'
' "brand": "品牌",\n'
' "key_features": ["特征数组"]\n'
"}"
)
_DS_PRO_USER = "分析这张图片,返回符合schema的JSON。"
def _strip_code_fence(s: str) -> str:
s = s.strip()
if s.startswith("```"):
lines = s.split("\n")
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
s = "\n".join(lines).strip()
return s
def _xml_text(tag: str, xml: str) -> str:
m = re.search(rf"<{tag}[^>]*>(.*?)</{tag}>", xml, re.S)
return (m.group(1) if m else "").strip()
def _xml_attr(tag: str, attr: str, xml: str) -> str:
m = re.search(rf"<{tag}[^>]*\b{attr}\s*=\s*[\"']([^\"']*)[\"']", xml)
return (m.group(1) if m else "").strip()
def _xml_to_product(raw: str, idx: int) -> dict[str, Any]:
"""解析 ARK pro 返回的 XML 格式。"""
scene = _xml_text("scene", raw) or "通用"
mood = _xml_text("mood", raw) or ""
portrait_prompt = "无人像"
p_has = _xml_attr("people", "has_person", raw)
if p_has and p_has.lower() != "false":
gender = _xml_attr("people", "gender", raw) or ""
age = _xml_attr("people", "age_range", raw) or ""
outfit = _xml_attr("people", "outfit", raw) or ""
hair = _xml_attr("people", "hair", raw) or "自然发型"
pose = _xml_attr("people", "pose", raw) or ""
expr = _xml_attr("people", "expression", raw) or "自然"
parts: list[str] = []
if gender:
parts.append(gender + ("性" if not gender.endswith("性") else ""))
if age:
parts.append(age)
parts.append("人物")
parts.append(hair)
if outfit:
parts.append(f"身着{outfit}")
if pose:
parts.append(f"姿态{pose}")
parts.append(f"表情{expr}")
portrait_prompt = ",".join(parts)
m = re.search(r"<product[^>]*>(.*?)</product>", raw, re.S)
if m:
name = _xml_attr("product", "name", raw) or "未识别"
brand = _xml_attr("product", "brand", raw) or "无法判断"
category = _xml_attr("product", "category", raw) or "无法判断"
appearance = _xml_attr("product", "appearance", raw) or "无法判断"
packaging = _xml_attr("product", "packaging", raw) or "无法判断"
feat = _xml_attr("product", "features", raw) or ""
feat_list = [x.strip() for x in re.split(r"[,,;;]", feat) if x.strip()] if feat else ["无法判断"]
top_text = _xml_attr("product", "text_on_package", raw) or ""
text_list = [x.strip() for x in re.split(r"[,,;;]", top_text) if x.strip()] if top_text else []
summary = _xml_attr("product", "summary", raw) or f"{brand} {name}"
return {
"name": name,
"brand": brand,
"category": category,
"appearance": appearance,
"packaging": packaging,
"text_on_package": text_list,
"key_features": feat_list,
"scene": scene,
"mood": mood,
"portrait_prompt": portrait_prompt,
"summary": summary,
"_source": "vlm_pro_xml",
}
if portrait_prompt != "无人像":
return {
"name": "未识别",
"brand": "无法判断",
"category": "无法判断",
"appearance": "无法判断",
"packaging": "无法判断",
"text_on_package": [],
"key_features": ["无法判断"],
"scene": scene,
"mood": mood,
"portrait_prompt": portrait_prompt,
"summary": "未识别",
"_source": "vlm_pro_no_product",
}
return {
"name": "未识别",
"brand": "无法判断",
"category": "无法判断",
"appearance": "无法判断",
"packaging": "无法判断",
"text_on_package": [],
"key_features": ["无法判断"],
"scene": scene,
"mood": mood,
"portrait_prompt": "无人像",
"summary": "未识别",
"_source": "vlm_pro_no_tag",
}
def _assemble_pp_from_json(obj: dict[str, Any]) -> str:
"""从 DashScope pro 返回的 JSON 组装 portrait_prompt。"""
if not obj.get("has_person", False):
return "无人像"
parts: list[str] = []
gender = obj.get("gender")
age = obj.get("age_range")
if gender:
parts.append(gender + ("性" if not gender.endswith("性") else ""))
if age:
parts.append(age)
parts.append("人物")
hair = obj.get("hair")
if hair:
parts.append(hair)
outfit = obj.get("outfit")
if outfit:
parts.append(f"身着{outfit}")
pose = obj.get("pose")
if pose:
parts.append(f"姿态{pose}")
expr = obj.get("expression")
if expr:
parts.append(f"表情{expr}")
return ",".join(parts) if parts else "无人像"
def _call_dashscope_pro(img_url: str, idx: int, timeout: int) -> dict[str, Any] | None:
"""DashScope qwen3.7-plus 兜底,直接 httpx 发精简 JSON prompt。"""
import httpx
t0 = time.time()
api_key = _provider.api_key()
if not api_key:
logger.warning("[vision.vlm] dashscope api_key 未配置")
return None
url = f"{_provider.base_url().rstrip('/')}/chat/completions"
payload: dict[str, Any] = {
"model": _provider.pro_model(),
"messages": [
{"role": "system", "content": _DS_PRO_SYSTEM},
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": img_url}},
{"type": "text", "text": _DS_PRO_USER},
],
},
],
"temperature": 0.3,
"max_tokens": DEFAULT_MAX_TOKENS,
"stream": False,
}
payload.update(_provider.thinking_params())
try:
r = httpx.post(
url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=payload,
timeout=timeout,
)
elapsed = time.time() - t0
if r.status_code != 200:
logger.warning(
"[vision.vlm] dashscope pro HTTP %d elapsed=%.1fs body=%s", r.status_code, elapsed, r.text[:200]
)
return None
data = r.json()
raw = (data.get("choices") or [{}])[0].get("message", {}).get("content")
if not raw:
logger.warning("[vision.vlm] dashscope pro 返回空 elapsed=%.1fs", elapsed)
return None
usage = data.get("usage") or {}
logger.info(
"[vision.vlm] dashscope pro 完成 idx=%d elapsed=%.1fs in=%d out=%d",
idx,
elapsed,
usage.get("prompt_tokens", 0),
usage.get("completion_tokens", 0),
)
text = _strip_code_fence(raw)
l, r_pos = text.find("{"), text.rfind("}")
if l < 0 or r_pos <= l:
logger.warning("[vision.vlm] dashscope pro 无JSON elapsed=%.1fs head=%s", elapsed, raw[:200])
return None
obj = json.loads(text[l : r_pos + 1])
if not isinstance(obj, dict):
return None
scene = obj.get("scene") or "通用"
mood = obj.get("mood") or ""
pp = _assemble_pp_from_json(obj)
name = obj.get("product_name") or "未识别"
brand = obj.get("brand") or "无法判断"
category = obj.get("category") or ("非产品图" if obj.get("has_person") else "无法判断")
return {
"name": name,
"brand": brand,
"category": category,
"appearance": obj.get("outfit") or "无法判断",
"packaging": "无法判断",
"text_on_package": [],
"key_features": obj.get("key_features") or ["无法判断"],
"scene": scene,
"mood": mood,
"portrait_prompt": pp,
"summary": f"{brand} {name}" if name != "未识别" else "未识别",
"_source": "vlm_pro_dashscope_json",
}
except Exception as e:
logger.warning("[vision.vlm] dashscope pro 异常 idx=%d elapsed=%.1fs err=%s", idx, time.time() - t0, e)
return None
def _call_ark_pro(img_url: str, idx: int, model: str | None, timeout: int) -> dict[str, Any] | None:
"""ARK 豆包 pro 兜底,保留 ai_client + prompt_loader + XML/JSON 双解析。"""
t0 = time.time()
try:
from packages.application.viral_video.prompt_loader import (
get_template,
render_system_prompt,
render_user_prompt,
)
from packages.shared.ai_client import get_doubao_client
except ImportError as e:
logger.warning("[vision.vlm] 导入失败: %s", e)
return None
try:
template = get_template("image_analysis")
system = render_system_prompt(template)
user = render_user_prompt(template, image_count=1, industry="通用", image_urls=f"第1张:{img_url}")
except Exception as e:
logger.warning("[vision.vlm] 模板加载失败: %s", e)
return None
client = get_doubao_client()
if not client.is_available:
return None
use_model = model or _provider.pro_model()
_orig_retries = client.max_retries
client.max_retries = 0
try:
raw = client.vision_completion(
messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
images=[img_url],
temperature=0.3,
max_tokens=DEFAULT_MAX_TOKENS,
timeout=timeout,
model=use_model,
)
except Exception as e:
logger.warning("[vision.vlm] ark pro 调用失败 idx=%d elapsed=%.1fs err=%s", idx, time.time() - t0, e)
client.max_retries = _orig_retries
return None
client.max_retries = _orig_retries
elapsed = time.time() - t0
if not raw:
logger.warning("[vision.vlm] ark pro 返回空 elapsed=%.1fs", elapsed)
return None
text = _strip_code_fence(raw)
l, r_pos = text.find("{"), text.rfind("}")
if l >= 0 and r_pos > l:
try:
obj = json.loads(text[l : r_pos + 1])
if isinstance(obj, dict):
logger.info("[vision.vlm] ark pro JSON 完成 idx=%d elapsed=%.1fs", idx, elapsed)
return {
"name": obj.get("name") or "未识别",
"brand": obj.get("brand") or "无法判断",
"category": obj.get("category") or "无法判断",
"appearance": obj.get("appearance") or "无法判断",
"packaging": obj.get("packaging") or "无法判断",
"text_on_package": obj.get("text_on_package") or [],
"key_features": obj.get("key_features") or obj.get("features") or ["无法判断"],
"scene": obj.get("scene") or "通用",
"mood": obj.get("mood") or "",
"portrait_prompt": obj.get("portrait_prompt") or "无人像",
"summary": obj.get("summary") or f"{obj.get('brand','')} {obj.get('name','')}",
"_source": "vlm_pro_json",
}
except json.JSONDecodeError:
pass
try:
return _xml_to_product(text, idx)
except Exception as e:
logger.warning("[vision.vlm] ark pro 解析失败 idx=%d elapsed=%.1fs err=%s head=%s", idx, elapsed, e, raw[:200])
return None
def call_pro_vlm(
img_url: str,
idx: int,
*,
ocr_hint: list[str] | None = None,
timeout: int = _DEFAULT_TIMEOUT,
max_tokens: int | None = None,
model: str | None = None,
timeout: int = DEFAULT_TIMEOUT,
) -> dict[str, Any] | None:
t0 = time.time()
ocr_text = "、".join(t for t in (ocr_hint or []) if t)[:200]
try:
from packages.shared.ai_router import ai_router
client = ai_router.get_vision_client("image_analysis", variant="fallback")
if not client or not client.is_available:
logger.warning("[vision.v2] pro vision client 不可用,跳过")
return None
except Exception as e: # noqa: BLE001
logger.warning("[vision.v2] ai_router 获取失败: %s", e)
return None
system_prompt, user_prompt = _prompt.resolve_pro_prompt(img_url, ocr_text)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": img_url}},
{"type": "text", "text": user_prompt},
],
},
]
try:
call_kwargs: dict[str, Any] = {
"messages": messages,
"images": None,
"temperature": 0.3,
"timeout": timeout,
"enable_thinking": False,
"response_format": {"type": "json_object"},
"max_tokens": max_tokens if max_tokens is not None else 4000,
}
from .json_utils import extract_json_object
obj = None
for _outer in range(2):
kw = dict(call_kwargs)
if _outer == 1:
kw.pop("response_format", None)
msgs2 = [dict(messages[0]), dict(messages[1])]
cont = [dict(c) for c in msgs2[1]["content"]]
cont[-1] = {
"type": "text",
"text": user_prompt + "\n严格只输出JSON对象,不要解释或markdown。",
}
msgs2[1] = {"role": "user", "content": cont}
kw["messages"] = msgs2
raw = client.vision_completion(**kw)
if not raw:
continue
obj = extract_json_object(raw)
if obj is not None:
break
logger.warning("[vision.v2] pro 非JSON(100字) outer=%s: %s", _outer, raw[:100])
elapsed = time.time() - t0
if obj is None:
logger.warning("[vision.v2] pro 两次均未得到JSON elapsed=%.1fs", elapsed)
return None
if obj.get("_partial"):
logger.warning("[vision.v2] pro 截断JSON(partial) elapsed=%.1fs", elapsed)
result = assembler.assemble_result(idx, obj, ocr_hint or [])
result["_source"] = "vlm_pro"
result["_fallback_used"] = True
logger.info("[vision.v2] pro 完成 model=%s elapsed=%.1fs", client.model, elapsed)
return result
except Exception as e: # noqa: BLE001
logger.warning(
"[vision.v2] pro 异常 elapsed=%.1fs err=%s",
time.time() - t0,
e,
exc_info=True,
)
return None
"""单次调用 pro VLM,解析后返回 product dict;失败返回 None。"""
if _provider.is_dashscope():
return _call_dashscope_pro(img_url, idx, timeout)
return _call_ark_pro(img_url, idx, model, timeout)
@@ -1,110 +1,202 @@
# -*- coding: utf-8 -*-
"""V2 快速路径:vision client(默认 image_analysis 能力)强约束 JSON-only 调用。
"""V2 快速路径 JSON-only VLM 调用。
要点:
- 通过 ai_router.get_vision_client() 获取 client;
- enable_thinking=False 关闭推理链,response_format=json_object 强约束 JSON;
- system/user prompt 优先读后台模板(v8 叙述优先),DB 不可用时用 prompts.py 默认;
- temperature=0.1(稳定输出 JSON);两次尝试(第二次去 json_object 约束)。
支持双后端切换(通过环境变量 VISION_V2_PROVIDER=ark|dashscope):
- ark(火山方舟/豆包):doubao-seed-2-1-lite-260915,thinking={type:"disabled"}
- dashscope(阿里云百炼/qwen,默认对比测试):qwen3.8-flash,enable_thinking=false
设计要点:
- 直接用 httpx 发最小 payload,不走 ai_client 包装
- 关闭 thinking/推理链(reasoning_tokens 是延迟主因)
- system prompt 极致精简,只给字段 schema 和强约束
- max_tokens=350、temperature=0.1(稳定输出 JSON)
- 单次调用不重试(失败由外层走 pro 兜底)
- 期望返回纯 JSON object(无 ```json 包裹、无解释文字)
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any
from . import _prompt
from . import _provider
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 20
# 极简 system prompt:只给字段定义 + 硬性输出要求
_FAST_SYSTEM = (
"你是图片结构化识别器。严格按下方 JSON schema 返回一个对象,不要任何解释、"
"不要markdown、不要代码块、不要前后缀文字。字段值不确定时填 null 或空数组。\n"
"{\n"
' "has_person": true/false,\n'
' "gender": "男"/"女"/null,\n'
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
' "upper_wear": "上装款式,如T恤/衬衫/卫衣/毛衣/西装/夹克/连衣裙/吊带/背心/外套等",\n'
' "upper_color": "上装主色",\n'
' "lower_wear": "下装款式;穿连衣裙时填null",\n'
' "lower_color": "下装主色",\n'
' "dress_color": "连衣裙主色(穿连衣裙时填)",\n'
' "accessories": ["眼镜"/"帽子"/"项链"/"耳环"/"背包"/"手表"等数组],\n'
' "hairstyle": "发型,如短发/长发/马尾/卷发/丸子头/光头等",\n'
' "expression": "表情,如微笑/严肃/酷/开心等",\n'
' "pose": "姿势,如站立/坐姿/侧身/行走等",\n'
' "scene": "场景,如室内/街拍/户外/办公室/家居/海边/雪景/森林等",\n'
' "style": "风格,如休闲/商务/运动/复古/潮流/甜美/酷飒/优雅/街头/法式等",\n'
' "has_product": true/false,\n'
' "category": "产品类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n'
' "product_name": "产品名称,非产品图填null",\n'
' "brand": "品牌或文字标识,无则null",\n'
' "material": "材质,如棉质/牛仔/皮革/真丝/针织/涤纶等",\n'
' "pattern": "图案,如纯色/条纹/波点/格子/印花/碎花/Logo等",\n'
' "colors": ["主色数组"],\n'
' "mood": "整体氛围/情绪,如清新/活力/高级/温暖/冷峻/甜美/复古等"\n'
"}"
)
_FAST_USER = "识别这张图片的人物穿搭与主体信息,只返回JSON对象。"
DEFAULT_TIMEOUT = _provider.fast_timeout_default()
DEFAULT_MAX_TOKENS = 350
def _strip_code_fence(s: str) -> str:
s = s.strip()
if s.startswith("```"):
lines = s.split("\n")
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
s = "\n".join(lines).strip()
return s
def call_fast_json(
img_url: str,
*,
timeout: int = _DEFAULT_TIMEOUT,
max_tokens: int | None = None,
model: str | None = None,
timeout: int = DEFAULT_TIMEOUT,
max_tokens: int = DEFAULT_MAX_TOKENS,
) -> dict[str, Any] | None:
"""调用 fast VLM 返回结构化 dict;失败/非 JSON 返回 None。"""
t0 = time.time()
import httpx
try:
from packages.shared.ai_router import ai_router
client = ai_router.get_vision_client("image_analysis", variant="primary")
if not client or not client.is_available:
logger.warning("[vision.v2] vision client 不可用,跳过 fast_json")
api_key = _provider.api_key()
base_url = _provider.base_url().rstrip("/")
if not api_key:
logger.warning("[vision.v2] provider=%s api_key 未配置,跳过 fast_json", _provider.get_provider())
return None
except Exception as e: # noqa: BLE001
logger.warning("[vision.v2] ai_router 获取失败: %s", e)
return None
system_prompt, user_prompt = _prompt.resolve_fast_prompt(img_url, "")
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": img_url}},
{"type": "text", "text": user_prompt},
use_model = model or _provider.fast_model()
url = f"{base_url}/chat/completions"
payload: dict[str, Any] = {
"model": use_model,
"messages": [
{"role": "system", "content": _FAST_SYSTEM},
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": img_url}},
{"type": "text", "text": _FAST_USER},
],
},
],
},
]
try:
call_kwargs: dict[str, Any] = {
"messages": messages,
"images": None,
"temperature": 0.1,
"timeout": timeout,
"enable_thinking": False,
"response_format": {"type": "json_object"},
"max_tokens": max_tokens,
"stream": False,
}
if max_tokens is not None:
call_kwargs["max_tokens"] = max_tokens
from .json_utils import extract_json_object
obj = None
for _outer in range(2):
kw = dict(call_kwargs)
if _outer == 1:
kw.pop("response_format", None)
msgs2 = [dict(messages[0]), dict(messages[1])]
cont = [dict(c) for c in msgs2[1]["content"]]
cont[-1] = {
"type": "text",
"text": user_prompt + "\n严格只输出JSON对象,不要解释或markdown。",
}
msgs2[1] = {"role": "user", "content": cont}
kw["messages"] = msgs2
raw = client.vision_completion(**kw)
if not raw:
continue
obj = extract_json_object(raw)
if obj is not None:
break
logger.warning("[vision.v2] fast_json 非JSON(100字) outer=%s: %s", _outer, raw[:100])
# 按 provider 设置关 thinking 参数
payload.update(_provider.thinking_params())
resp = httpx.post(
url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=payload,
timeout=timeout,
)
elapsed = time.time() - t0
if obj is None:
logger.warning("[vision.v2] fast_json 两次均未得到JSON elapsed=%.1fs", elapsed)
if resp.status_code == 400:
logger.warning(
"[vision.v2] fast_json HTTP 400 provider=%s elapsed=%.1fs body=%s",
_provider.get_provider(),
elapsed,
resp.text[:200],
)
if _provider.is_400_thinking_error(resp.text[:500]):
_provider.pop_thinking_param(payload)
resp = httpx.post(
url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=payload,
timeout=timeout,
)
elapsed = time.time() - t0
if resp.status_code != 200:
logger.warning("[vision.v2] fast_json 降级重试 HTTP %d elapsed=%.1fs", resp.status_code, elapsed)
return None
else:
return None
elif resp.status_code != 200:
logger.warning(
"[vision.v2] fast_json HTTP %d provider=%s elapsed=%.1fs body=%s",
resp.status_code,
_provider.get_provider(),
elapsed,
resp.text[:200],
)
return None
if obj.get("_partial"):
logger.warning("[vision.v2] fast_json 截断JSON(partial) elapsed=%.1fs", elapsed)
data = resp.json()
raw = (data.get("choices") or [{}])[0].get("message", {}).get("content")
if raw is None:
logger.warning("[vision.v2] fast_json 返回 None elapsed=%.1fs", elapsed)
return None
usage = data.get("usage") or {}
reasoning_tokens = usage.get("reasoning_tokens", 0)
ctd = usage.get("completion_tokens_details") or {}
if not reasoning_tokens:
reasoning_tokens = ctd.get("reasoning_tokens", 0)
logger.info(
"[vision.v2] fast_json 完成 model=%s elapsed=%.1fs type=%s",
client.model,
"[vision.v2] fast_json 完成 provider=%s model=%s elapsed=%.1fs in=%d out=%d reasoning=%d",
_provider.get_provider(),
use_model,
elapsed,
obj.get("type"),
usage.get("prompt_tokens", 0),
usage.get("completion_tokens", 0),
reasoning_tokens,
)
text = _strip_code_fence(raw)
l = text.find("{")
r = text.rfind("}")
if l >= 0 and r > l:
text = text[l : r + 1]
try:
obj = json.loads(text)
except json.JSONDecodeError:
logger.warning("[vision.v2] fast_json JSON 解析失败 elapsed=%.1fs head=%s", elapsed, raw[:200])
return None
if not isinstance(obj, dict):
logger.warning("[vision.v2] fast_json 非 dict: %s", type(obj))
return None
logger.info(
"[vision.v2] fast_json 完成 provider=%s model=%s elapsed=%.1fs has_person=%s has_product=%s category=%s",
_provider.get_provider(),
use_model,
elapsed,
obj.get("has_person"),
obj.get("has_product"),
obj.get("category"),
)
return obj
except Exception as e: # noqa: BLE001
except Exception as e:
elapsed = time.time() - t0
logger.warning(
"[vision.v2] fast_json 异常 elapsed=%.1fs err=%s",
time.time() - t0,
"[vision.v2] fast_json 异常 provider=%s elapsed=%.1fs err=%s",
_provider.get_provider(),
elapsed,
e,
exc_info=True,
)
-10
View File
@@ -252,9 +252,6 @@ DOUBAO_TIMEOUT=${DOUBAO_TIMEOUT}
# 最大重试次数
DOUBAO_MAX_RETRIES=${DOUBAO_MAX_RETRIES}
# Ditto 情绪分析 LLM 调用超时(秒)
DITTO_EMOTION_TIMEOUT=${DITTO_EMOTION_TIMEOUT}
# 视觉模型(支持图片/视频理解的模型,model name 格式)
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
@@ -314,10 +311,3 @@ GPU_ENCODE_CRF=23
GPU_ENCODE_FALLBACK_CPU=true
GPU_ENCODE_MEZZANINE_TRANSPORT=oss
GPU_ENCODE_OSS_TMP_PREFIX=tmp/gpu-mezzanine/
# ==================== Ditto 蚂蚁数字人口型 ====================
# 注意:这些值必须写死在模板里(不是 CI Secret),否则每次 CI 重新渲染 .env 都会被丢弃,
# 导致 staging 发版后 Ditto 口型服务静默降级到 GPU/MediaKit(P0 防复发)。
USE_DITTO_LIPSYNC=true
DITTO_API_BASE_URL=http://100.76.80.23:8000
DITTO_DEFAULT_VIDEO_URL=https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/uploads/default_avatar.mp4
+1 -3
View File
@@ -40,9 +40,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
+1 -3
View File
@@ -39,9 +39,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
}
@@ -335,10 +335,6 @@ class GenerationTaskModel(Base):
bgm_config = Column(JSON, nullable=False, default=dict)
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
logs = Column(Text, nullable=False, default="[]", server_default="[]")
# 功能计费(smart_edit):预扣积分 / 最终积分 / 预扣流水 ID
credits_prepaid = Column(Float, nullable=False, default=0.0, server_default="0")
credits_cost = Column(Float, nullable=False, default=0.0, server_default="0")
credits_transaction_id = Column(String(36), nullable=False, default="", server_default="")
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime,
@@ -731,11 +727,6 @@ class LipsyncJobModel(Base):
# 精确句子时间戳(TTS 合成后由 silencedetect 计算,用于 B-roll 精确定位)
sentence_timings = Column(JSON, nullable=True) # list[{index,text,start_time,end_time}]
# 功能计费(lip_sync):预扣积分 / 最终积分 / 预扣流水 ID
credits_prepaid = Column(Float, nullable=False, default=0.0, server_default="0")
credits_cost = Column(Float, nullable=False, default=0.0, server_default="0")
credits_transaction_id = Column(String(36), nullable=False, default="", server_default="")
# 时间戳
submitted_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
@@ -914,11 +905,6 @@ class GpuLipsyncTaskModel(Base):
# 心跳:worker 最近一次 poll/result 的时间,用于判定 worker 失联
last_heartbeat_at = Column(DateTime, nullable=True)
# 功能计费(lip_sync):预扣积分 / 最终积分 / 预扣流水 ID
credits_prepaid = Column(Float, nullable=False, default=0.0, server_default="0")
credits_cost = Column(Float, nullable=False, default=0.0, server_default="0")
credits_transaction_id = Column(String(36), nullable=False, default="", server_default="")
class GpuWorkerModel(Base):
"""GPU Worker 注册表 — 反向轮询模式下用于心跳与监控."""
@@ -961,7 +947,6 @@ class ViralVideoJobModel(Base):
# v1.5 音频/视频参数
voice_id = Column(String(200), nullable=False, default="")
voice_source = Column(String(20), nullable=False, default="")
language = Column(String(20), nullable=False, default="zh-CN")
video_ratio = Column(String(10), nullable=False, default="9:16")
video_model = Column(String(100), nullable=False, default="")
# 结果与状态
@@ -1020,20 +1005,3 @@ class ViralVideoPromptTemplateModel(Base):
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
class SystemSettingModel(Base):
"""系统配置表 ORM 模型(#2246:后台可配置项,表已手工存在于 staging)."""
__tablename__ = "system_settings"
id = Column(String(36), primary_key=True)
setting_key = Column(String(100), nullable=False, unique=True)
setting_value = Column(Text, nullable=True)
setting_type = Column(String(20), nullable=False)
description = Column(String(255), nullable=False, default="", server_default="")
is_public = Column(Boolean, nullable=False, default=False, server_default="false")
updated_by = Column(String(36), nullable=True)
category = Column(String(50), nullable=False, default="general", server_default="general")
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
@@ -1,65 +0,0 @@
"""system_settings 表 SQLAlchemy Repository — #2246."""
from __future__ import annotations
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import SystemSettingModel
from packages.domain.system_setting import SystemSetting
class SQLAlchemySystemSettingRepository:
def __init__(self, session: Session):
self.session = session
def get_by_key(self, setting_key: str) -> SystemSetting | None:
model = self.session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == setting_key).first()
if model is None:
return None
return self._to_domain(model)
def list_all(self, category: str | None = None) -> list[SystemSetting]:
query = self.session.query(SystemSettingModel)
if category is not None:
query = query.filter(SystemSettingModel.category == category)
return [self._to_domain(m) for m in query.all()]
def upsert(self, setting: SystemSetting) -> SystemSetting:
model = (
self.session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == setting.setting_key).first()
)
if model is None:
model = SystemSettingModel(id=setting.id)
self.session.add(model)
model.setting_key = setting.setting_key
model.setting_value = setting.setting_value
model.setting_type = setting.setting_type
model.description = setting.description
model.is_public = setting.is_public
model.category = setting.category
model.updated_by = setting.updated_by
self.session.commit()
return setting
def delete_by_key(self, setting_key: str) -> bool:
model = self.session.query(SystemSettingModel).filter(SystemSettingModel.setting_key == setting_key).first()
if model is None:
return False
self.session.delete(model)
self.session.commit()
return True
@staticmethod
def _to_domain(model: SystemSettingModel) -> SystemSetting:
return SystemSetting(
id=model.id,
setting_key=model.setting_key,
setting_value=model.setting_value,
setting_type=model.setting_type,
description=model.description or "",
is_public=bool(model.is_public),
category=model.category or "general",
updated_by=model.updated_by,
created_at=model.created_at,
updated_at=model.updated_at,
)
@@ -49,13 +49,13 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
style_template_id=getattr(model, "style_template_id", "") or "",
voice_id=getattr(model, "voice_id", "") or "",
voice_source=getattr(model, "voice_source", "") or "",
language=getattr(model, "language", "zh-CN") or "zh-CN",
video_ratio=getattr(model, "video_ratio", "9:16") or "9:16",
video_model=getattr(model, "video_model", "") or "",
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
current_stage=getattr(model, "current_stage", "") or "",
phase_message=getattr(model, "phase_message", "") or "",
heartbeat_at=getattr(model, "heartbeat_at", None),
intent_result=dict(model.intent_result) if model.intent_result else None,
image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None,
storyboard=list(model.storyboard) if getattr(model, "storyboard", None) else None,
generated_copy_text=getattr(model, "generated_copy_text", "") or "",
@@ -102,13 +102,13 @@ class SQLAlchemyViralVideoJobRepository:
style_template_id=job.style_template_id,
voice_id=job.voice_id,
voice_source=job.voice_source,
language=getattr(job, "language", "zh-CN") or "zh-CN",
video_ratio=job.video_ratio,
video_model=job.video_model,
status=job.status,
current_stage=job.current_stage or "",
phase_message=job.phase_message or "",
heartbeat_at=job.heartbeat_at,
intent_result=job.intent_result,
image_analysis=job.image_analysis,
storyboard=job.storyboard,
generated_copy_text=job.generated_copy_text,
@@ -137,6 +137,7 @@ class SQLAlchemyViralVideoJobRepository:
model.current_stage = job.current_stage or ""
model.phase_message = job.phase_message or ""
model.heartbeat_at = job.heartbeat_at
model.intent_result = job.intent_result
model.image_analysis = job.image_analysis
model.storyboard = job.storyboard
model.generated_copy_text = job.generated_copy_text or ""
@@ -168,7 +169,6 @@ class SQLAlchemyViralVideoJobRepository:
model.style_template_id = job.style_template_id
model.voice_id = job.voice_id or ""
model.voice_source = job.voice_source or ""
model.language = getattr(job, "language", "zh-CN") or "zh-CN"
model.video_ratio = job.video_ratio or "9:16"
model.video_model = job.video_model or ""
model.updated_at = datetime.now(timezone.utc)
@@ -196,7 +196,9 @@ class SQLAlchemyViralVideoJobRepository:
self.session.query(ViralVideoJobModel)
.filter(
ViralVideoJobModel.user_id == user_id,
ViralVideoJobModel.status.in_(["pending", "running", "image_analyzed", "copy_generated"]),
ViralVideoJobModel.status.in_(
["pending", "running", "wait_user_confirm", "image_analyzed", "copy_generated"]
),
)
.count()
)
+3 -17
View File
@@ -3,24 +3,10 @@
使用 bcrypt 安全存储密码
"""
import hashlib
from typing import Optional
import bcrypt
# bcrypt 只对前 72 字节有效,且 bcrypt>=4.1 会对超长输入直接抛 ValueError。
# 超长密码先做一次 SHA-256(定长 hex),再交给 bcrypt,
# 既绕过长度限制又保持对超长不同密码的区分度。
_BCRYPT_MAX_BYTES = 72
def _prepare_password_bytes(password: str) -> bytes:
raw = password.encode("utf-8")
if len(raw) > _BCRYPT_MAX_BYTES:
return hashlib.sha256(raw).hexdigest().encode("utf-8")
return raw
from packages.domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort
@@ -56,8 +42,8 @@ class PasswordHasher(PasswordHasherPort):
if not password:
raise ValueError("Password cannot be empty")
# bcrypt 需要 bytes(超长密码先 SHA-256 以兼容 72 字节限制)
password_bytes = _prepare_password_bytes(password)
# bcrypt 需要 bytes
password_bytes = password.encode("utf-8")
# 生成 salt 并哈希
salt = bcrypt.gensalt(rounds=self.rounds)
@@ -81,7 +67,7 @@ class PasswordHasher(PasswordHasherPort):
return False
try:
password_bytes = _prepare_password_bytes(password)
password_bytes = password.encode("utf-8")
hashed_bytes = hashed_password.encode("utf-8")
return bcrypt.checkpw(password_bytes, hashed_bytes)
+4 -17
View File
@@ -351,25 +351,12 @@ class CosyVoiceService:
用于私有 bucket 下,将裸 URL 转为预签名 URL,
确保 CosyVoice 服务器能下载参考音频.
"""
# 优先从 ai_router 获取 DB 配置
_router_key, _router_url, _router_model = "", "", ""
try:
from packages.shared.ai_router import ai_router
tts_client = ai_router.get_tts_client("tts")
if tts_client and tts_client.is_available:
_router_key = tts_client.api_key
_router_url = tts_client.base_url
_router_model = tts_client.model
except Exception:
pass
settings = get_shared_settings()
self._api_key = api_key or _router_key or settings.cosyvoice_api_key
self._base_url = base_url or _router_url or settings.cosyvoice_base_url
self._model = model or _router_model or settings.cosyvoice_model
self._clone_model = clone_model or getattr(settings, "cosyvoice_clone_model", "")
self._api_key = api_key or settings.cosyvoice_api_key
self._base_url = base_url or settings.cosyvoice_base_url
self._model = model or settings.cosyvoice_model
self._clone_model = clone_model or getattr(settings, "cosyvoice_clone_model", "voice-enrollment")
self._audio_url_signer = audio_url_signer
# base_url 规范化:去掉末尾的路径残留(兼容旧版配置)
@@ -1,403 +0,0 @@
"""Ditto LLM 情绪分析服务 — #2076 后续:根据文案生成 emo_timeline.
职责:
1. 分句:先按 。!?; 句末标点切分;整段无句末标点且超长时按逗号等语义停顿二次切分
2. 调 DoubaoClient.chat_completion 分析每句表情(emo: 0-7 八种全部开放, intensity: 0-1)
3. 结果 LRU 缓存(文案 hash → 情绪列表)
4. LLM 失败/超时/格式错 → 返回空列表(降级中性表情,不阻塞生成)
5. TTS 完成后按字数比例或 sentence_timings 对齐成秒级 timeline
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from functools import lru_cache
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# ── 表情常量 ─────────────────────────────────────────────────────
EMO_ANGER = 0
EMO_DISGUST = 1
EMO_FEAR = 2
EMO_HAPPY = 3
EMO_NEUTRAL = 4
EMO_SAD = 5
EMO_SURPRISE = 6
EMO_CONTEMPT = 7
ALLOWED_EMOS = set(range(8)) # 8 种表情全部开放(0-7);超出范围的非法值仍会被降级为中性
# ── 分句正则 ─────────────────────────────────────────────────────
_SENT_SPLIT_RE = re.compile(r"(?<=[。!?;!?;])\s*")
# 二次切分:逗号/分号/顿号/冒号等语义停顿(lookbehind 保留标点在前句末尾)
_CLAUSE_SPLIT_RE = re.compile(r"(?<=[,,、::])")
# 整段无句末标点且超过该长度时才做逗号二次切分
_SINGLE_SENTENCE_THRESHOLD = 30
# 二次切分后过短片段(<10 字)向后并入相邻句,确保每段至少 10 字、语义相对完整
# (如"心中有爱,眼里有光,"合并为一段);避免长文案被切成十几个碎段导致
# LLM 处理的句子过多而超时,且过碎片段语义不完整、情绪分析意义不大
_MIN_CLAUSE_LEN = 10
# ── 默认 prompt 模板文件路径 ──────────────────────────────────────
_DEFAULT_PROMPT_PATH = Path(__file__).parent / "prompts" / "ditto_emotion.txt"
def _load_default_prompt() -> str:
try:
return _DEFAULT_PROMPT_PATH.read_text(encoding="utf-8").strip()
except Exception:
# 文件不存在时用极简兜底
return (
"分析文案每句话表情,输出JSON数组:"
'[{"text":"句子","emo":4,"intensity":0.2}],emo:0-7八种表情全部开放,'
"至少30%句子用非中性表情。\n【文案】\n{文案}"
)
# ── 数据结构 ─────────────────────────────────────────────────────
class EmotionSegment:
"""单句情绪结果(LLM 输出的原始结构)."""
__slots__ = ("text", "emo", "intensity")
def __init__(self, text: str, emo: int, intensity: float):
self.text = text
self.emo = emo
self.intensity = intensity
def to_dict(self) -> dict[str, Any]:
return {"text": self.text, "emo": self.emo, "intensity": self.intensity}
class EmotionTimelineEntry:
"""对齐到音频时间轴后的情绪片段(传给 Ditto)."""
__slots__ = ("start", "end", "emo", "intensity")
def __init__(self, start: float, end: float, emo: int, intensity: float):
self.start = round(start, 2)
self.end = round(end, 2)
self.emo = emo
self.intensity = round(intensity, 2)
def to_dict(self) -> dict[str, Any]:
return {
"start": self.start,
"end": self.end,
"emo": self.emo,
"intensity": self.intensity,
}
# ── 分句 ─────────────────────────────────────────────────────────
def _split_by_clauses(sentence: str) -> list[str]:
"""按逗号/分号等语义停顿二次切分;过短片段(<10 字)向后并入相邻句,避免切太碎.
每段至少 10 字、尽量控制在 10-50 字左右(50 字为软上限,无更细停顿点时保持
语义完整不硬切)。例:"心中有爱,眼里有光,"合并为一段而非各自成段。
"""
raw = [c for c in _CLAUSE_SPLIT_RE.split(sentence) if c and c.strip()]
if len(raw) <= 1:
return [sentence]
merged: list[str] = []
buf = ""
for clause in raw:
if not buf:
buf = clause
continue
if len(buf) < _MIN_CLAUSE_LEN:
buf += clause
continue
merged.append(buf)
buf = clause
if buf:
if merged and len(buf) < _MIN_CLAUSE_LEN:
merged[-1] += buf
else:
merged.append(buf)
return [m.strip() for m in merged if m.strip()]
def split_sentences(text: str) -> list[str]:
"""按中文句末标点切分,过滤空串.
Bug1 修复:若按句末标点切分后只有 1 段且文本超过 30 字(文案全是逗号、
结尾无标点),再按逗号/分号等语义停顿做二次切分,避免整段只返回 1 个
segment 导致 LLM 只能对整段打一个表情。
"""
if not text:
return []
parts = [p.strip() for p in _SENT_SPLIT_RE.split(text.strip()) if p and p.strip()]
if len(parts) <= 1 and parts and len(parts[0]) > _SINGLE_SENTENCE_THRESHOLD:
parts = _split_by_clauses(parts[0])
return parts
# ── 解析 LLM 返回的 JSON ─────────────────────────────────────────
def _parse_emotion_json(raw: str) -> list[EmotionSegment]:
"""解析 LLM 返回,容错处理:
- 去掉 markdown 代码块包裹
- 只取第一个 JSON 数组
- 逐行校验 emo/intensity 合法性,过滤无效项
"""
if not raw:
return []
text = raw.strip()
# 去掉 ```json ... ``` 包裹
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
# 找第一个 [ 到最后一个 ]
lb = text.find("[")
rb = text.rfind("]")
if lb == -1 or rb == -1 or rb <= lb:
return []
try:
data = json.loads(text[lb : rb + 1])
except (json.JSONDecodeError, ValueError):
return []
if not isinstance(data, list):
return []
results: list[EmotionSegment] = []
for item in data:
if not isinstance(item, dict):
continue
try:
emo = int(item.get("emo", EMO_NEUTRAL))
intensity = float(item.get("intensity", 0.2))
except (TypeError, ValueError):
continue
if emo not in ALLOWED_EMOS:
emo = EMO_NEUTRAL
intensity = max(0.05, min(1.0, intensity))
sent_text = str(item.get("text", "")).strip()
if not sent_text:
continue
results.append(EmotionSegment(text=sent_text, emo=emo, intensity=intensity))
return results
# ── 时间对齐(按字数比例)────────────────────────────────────────
def align_timeline_by_length(
segments: list[EmotionSegment],
audio_duration: float,
) -> list[EmotionTimelineEntry]:
"""按各句字数占总字数比例分配 audio_duration 时长."""
if not segments or audio_duration <= 0:
return []
total_chars = sum(len(s.text) for s in segments)
if total_chars <= 0:
return []
entries: list[EmotionTimelineEntry] = []
pos = 0.0
for i, seg in enumerate(segments):
if i == len(segments) - 1:
end = audio_duration # 最后一段到结尾,避免浮点误差
else:
end = pos + (len(seg.text) / total_chars) * audio_duration
if end > pos:
entries.append(
EmotionTimelineEntry(
start=pos,
end=end,
emo=seg.emo,
intensity=seg.intensity,
)
)
pos = end
return entries
def align_timeline_by_timings(
segments: list[EmotionSegment],
sentence_timings: list[dict[str, Any]],
audio_duration: float,
) -> list[EmotionTimelineEntry]:
"""使用 TTS sentence_timings 精确对齐(优先方案).
sentence_timings 格式:[{"start":0.0,"end":1.2,"text":"句子"}, ...]
按句序匹配 segments 和 timings,长度不一致时回退到按字数比例。
"""
if not sentence_timings or len(sentence_timings) != len(segments):
return align_timeline_by_length(segments, audio_duration)
entries: list[EmotionTimelineEntry] = []
for seg, timing in zip(segments, sentence_timings, strict=False):
try:
start = float(timing.get("start", 0))
end = float(timing.get("end", 0))
except (TypeError, ValueError):
return align_timeline_by_length(segments, audio_duration)
if end <= start:
continue
entries.append(
EmotionTimelineEntry(
start=start,
end=end,
emo=seg.emo,
intensity=seg.intensity,
)
)
return entries
# ── LLM 结果合理性校验(提示词遵循度排查)─────────────────────────
# 感叹号或明显情绪关键词出现时,LLM 不应返回全中性/低强度
_EMO_SIGNAL_RE = re.compile(r"[!!]|是不是|有没有|居然|竟然|只要|太坑|千万别|免费|赶紧|推荐|惊艳")
def _warn_if_suspicious_all_neutral(text: str, segments: list[EmotionSegment]) -> None:
"""文案含明显情绪信号但 LLM 返回全部 emo=4 且 intensity<=0.1 时打 warning 日志."""
if not segments:
return
all_flat = all(s.emo == EMO_NEUTRAL and s.intensity <= 0.1 for s in segments)
if all_flat and _EMO_SIGNAL_RE.search(text):
logger.warning(
"[ditto_emotion] 文案含情绪信号(感叹号/关键词)但 LLM 返回全中性/低强度,"
"疑似提示词遵循度差: text=%.80s, segments=%d",
text,
len(segments),
)
# ── LLM 情绪分析服务 ─────────────────────────────────────────────
class DittoEmotionService:
"""Ditto 情绪分析服务(带 LRU 缓存)."""
def __init__(self, settings=None):
from packages.config import get_api_settings
self.settings = settings or get_api_settings()
self._client = None
self._client_api_key: str | None = None
def _cfg(self, key: str) -> Any:
"""优先读后台 system_config,未配置则回退到 settings(env 默认)."""
try:
from packages.application.system_config_service import get_config
return get_config(key, getattr(self.settings, key, None))
except Exception:
return getattr(self.settings, key, None)
@property
def enabled(self) -> bool:
return bool(self._cfg("ditto_emotion_enabled"))
def _get_prompt_template(self) -> str:
"""优先用配置(环境变量),否则读文件."""
cfg_prompt = self._cfg("ditto_emotion_prompt") or ""
if cfg_prompt.strip():
return cfg_prompt.strip()
return _load_default_prompt()
def _cache_key(self, text: str) -> str:
return hashlib.md5(text.strip().encode("utf-8")).hexdigest()
def _get_llm_client(self):
"""创建/复用 DoubaoClient;API Key 优先读后台配置(DB),fallback 环境变量。
后台可能随时改 doubao_api_key,每次调用前对比 key,变化则重建 client。
"""
api_key = (self._cfg("doubao_api_key") or "").strip()
if self._client is None or self._client_api_key != api_key:
from packages.shared.ai_client import DoubaoClient
self._client = DoubaoClient(api_key=api_key)
self._client_api_key = api_key
return self._client
def _call_llm(self, text: str) -> list[EmotionSegment]:
"""调 LLM 分析情绪,失败返回空列表."""
template = self._get_prompt_template()
prompt = template.replace("{文案}", text)
messages = [{"role": "user", "content": prompt}]
model = self._cfg("ditto_emotion_model") or None
temperature = self._cfg("ditto_emotion_temperature")
timeout = getattr(self.settings, "ditto_emotion_timeout", 10)
max_tokens = getattr(self.settings, "ditto_emotion_max_tokens", 1024)
try:
client = self._get_llm_client()
result = client.chat_completion(
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
)
except Exception as exc:
logger.warning("[ditto_emotion] LLM 调用异常: %s", exc)
return []
if not result:
return []
segments = _parse_emotion_json(result)
if not segments:
logger.warning("[ditto_emotion] LLM 返回解析失败: %s", result[:200])
else:
_warn_if_suspicious_all_neutral(text, segments)
return segments
def analyze(self, text: str) -> list[EmotionSegment]:
"""分析文案情绪(带缓存),失败返回空列表."""
if not self.enabled or not text or not text.strip():
return []
key = self._cache_key(text)
return _cached_analyze(self, key, text)
def build_timeline(
self,
text: str,
audio_duration: float,
sentence_timings: Optional[list[dict[str, Any]]] = None,
) -> str:
"""完整流程:分句→LLM分析→时间对齐→序列化为JSON字符串.
返回: JSON 字符串(可直接传 Ditto emo_timeline 参数);空字符串表示降级中性。
"""
segments = self.analyze(text)
if not segments:
return ""
if sentence_timings:
entries = align_timeline_by_timings(segments, sentence_timings, audio_duration)
else:
entries = align_timeline_by_length(segments, audio_duration)
if not entries:
return ""
return json.dumps([e.to_dict() for e in entries], ensure_ascii=False)
# ── 模块级 LRU 缓存实例 ─────────────────────────────────────────
# 每个 service 实例共享缓存(按 cache_key 区分)
@lru_cache(maxsize=512)
def _cached_analyze(service: DittoEmotionService, cache_key: str, text: str) -> list[EmotionSegment]:
"""LRU 缓存包装:cache_key 由文案 hash 生成,maxsize 从配置读."""
# 注意:service 参数仅用于传递调用,缓存由 cache_key 驱动
segments = service._call_llm(text)
# 如果 LLM 返回空(比如分句数量不匹配),尝试直接对预分句结果分析
if not segments:
pre_splits = split_sentences(text)
if len(pre_splits) >= 1:
# 用预分句结果兜底:全中性低强度
segments = [EmotionSegment(text=s, emo=EMO_NEUTRAL, intensity=0.1) for s in pre_splits]
return segments
_singleton: Optional[DittoEmotionService] = None
def get_ditto_emotion_service() -> DittoEmotionService:
global _singleton
if _singleton is None:
_singleton = DittoEmotionService()
return _singleton
def reset_ditto_emotion_service() -> None:
"""#2246:后台配置变更后重置单例并清空 LLM 结果 LRU 缓存."""
global _singleton
_singleton = None
_cached_analyze.cache_clear()
-308
View File
@@ -1,308 +0,0 @@
"""蚂蚁 Ditto 数字人口型 API 客户端 — #2076.
封装 Ditto FastAPI(部署在 5060Ti GPU 节点,Tailscale 内网可达):
- GET /health 健康检查
- POST /generate 生成口型视频(同步返回 MP4 流)
关键特性:
- 入参:video_url(人物驱动视频 URL,用户上传优先;未传时用 default_video_url 兜底)
+ audio_url(TTS 音频 URL) + script(文案原文)
+ emo_timeline(可选,LLM 情绪时间线 JSON 字符串)
- 出参:直接返回 video/mp4 字节流(自带音频,无需二次混流)
- 429 时指数退避重试(最多 ditto_max_retries 次)
- 500/超时/网络不可达视为失败
- 输出 MP4 字节流转存到自家 OSS,返回公网 URL
注意:
- 保留 MuseTalk/GPU 路径不变;本服务作为更高优先级的第三条口型路径
- video_url 时长对齐(视频<音频时循环延长)在 Celery 任务层用 ffmpeg 预处理
- Ditto 输出自带音视频,不需要 GFPGAN 超分,不需要 ffmpeg 音视频混流
"""
from __future__ import annotations
import io
import logging
import time
from dataclasses import dataclass
from typing import Optional
import httpx
from packages.config import get_api_settings
logger = logging.getLogger(__name__)
class DittoError(Exception):
"""Ditto API 调用失败."""
def __init__(self, message: str, code: str = "DittoError", status_code: int = 0):
self.code = code
self.status_code = status_code
super().__init__(message)
@dataclass
class DittoResult:
"""Ditto 生成结果."""
video_bytes: bytes
video_url: str = "" # 转存 OSS 后填充
elapsed_seconds: float = 0.0
rtf: float = 0.0 # 实时率(响应头 X-RTF)
frames: int = 0 # 帧数(响应头 X-Frames)
class DittoClient:
"""蚂蚁 Ditto 数字人口型 API 客户端."""
def __init__(
self,
base_url: Optional[str] = None,
default_video_url: Optional[str] = None,
max_retries: Optional[int] = None,
timeout: Optional[int] = None,
):
s = get_api_settings()
self.base_url = (base_url or s.ditto_api_base_url or "").rstrip("/")
self.default_video_url = default_video_url or s.ditto_default_video_url or ""
self.max_retries = int(max_retries if max_retries is not None else s.ditto_max_retries)
self.timeout = int(timeout if timeout is not None else s.ditto_request_timeout)
try:
from packages.application.system_config_service import get_config
self.blend_frames = int(get_config("ditto_blend_frames", s.ditto_blend_frames))
except Exception:
self.blend_frames = int(s.ditto_blend_frames)
@property
def is_configured(self) -> bool:
"""配置是否完整(base_url 必填 + 默认模板视频兜底 URL 有值)。
注意:即使 is_configured=True,实际生成时优先使用用户上传的 video_url;
default_video_url 仅作为用户未上传视频时的兜底。
"""
return bool(self.base_url) and bool(self.default_video_url)
def health(self) -> bool:
"""健康检查;成功返回 True,失败返回 False(不抛异常)."""
if not self.base_url:
return False
url = f"{self.base_url}/health"
try:
with httpx.Client(timeout=5.0) as client:
resp = client.get(url)
ok = resp.status_code == 200
if ok:
logger.info("[ditto] health check OK: %s", url)
else:
logger.warning("[ditto] health check status=%d: %s", resp.status_code, url)
return ok
except Exception as exc:
logger.warning("[ditto] health check failed: %s", exc)
return False
def generate(
self,
*,
audio_url: str,
script: str,
video_url: Optional[str] = None,
emo_global: int = 4,
use_script_emo: bool = True,
blend_frames: Optional[int] = None,
emo_timeline: str = "",
) -> DittoResult:
"""调用 Ditto /generate 接口,返回 MP4 字节流结果.
Raises DittoError on failure.
"""
if not self.base_url:
raise DittoError("DITTO_API_BASE_URL 未配置", code="ConfigMissing")
driver_url = video_url or self.default_video_url
if not driver_url:
raise DittoError("Ditto 人物模板视频 URL 未配置", code="ConfigMissing")
if not audio_url:
raise DittoError("audio_url 不能为空", code="InvalidParam")
if not script:
script = " "
_blend = blend_frames if blend_frames is not None else self.blend_frames
payload = {
"video_url": driver_url,
"audio_url": audio_url,
"script": script,
"emo_global": emo_global,
"use_script_emo": use_script_emo,
"blend_frames": _blend,
}
if emo_timeline:
payload["emo_timeline"] = emo_timeline
url = f"{self.base_url}/generate"
last_exc: Optional[Exception] = None
for attempt in range(self.max_retries + 1):
try:
start = time.monotonic()
# 精细化超时:connect=10s(网络不通快速失败),read=120s(最长音频~45s按RTF=2.8推算)
_timeout = httpx.Timeout(connect=10.0, read=self.timeout, write=30.0, pool=10.0)
with httpx.Client(timeout=_timeout, follow_redirects=True) as client:
resp = client.post(url, json=payload)
elapsed = time.monotonic() - start
if resp.status_code == 429:
wait = min(2**attempt, 30)
logger.warning(
"[ditto] GPU 繁忙 (429),%ds 后重试 (%d/%d)",
wait,
attempt + 1,
self.max_retries,
)
if attempt >= self.max_retries:
raise DittoError(
f"Ditto GPU 繁忙,重试 {self.max_retries} 次仍失败",
code="BusyRetriesExhausted",
status_code=429,
)
time.sleep(wait)
continue
if resp.status_code != 200:
_text = (resp.text or "")[:300]
logger.error(
"[ditto] generate 失败 status=%d attempt=%d body=%s",
resp.status_code,
attempt + 1,
_text,
)
if resp.status_code >= 500 and attempt < self.max_retries:
time.sleep(min(2**attempt, 15))
continue
raise DittoError(
f"Ditto 返回 {resp.status_code}: {_text}",
code="DittoAPIError",
status_code=resp.status_code,
)
video_bytes = resp.content
if not video_bytes or len(video_bytes) < 1024:
raise DittoError(
f"Ditto 返回内容异常(size={len(video_bytes) if video_bytes else 0})",
code="EmptyResponse",
)
try:
rtf = float(resp.headers.get("X-RTF", "0") or 0)
except ValueError:
rtf = 0.0
try:
frames = int(resp.headers.get("X-Frames", "0") or 0)
except ValueError:
frames = 0
try:
x_time = float(resp.headers.get("X-Time", "0") or 0)
if x_time > 0:
elapsed = x_time
except ValueError:
pass
logger.info(
"[ditto] generate 成功 size=%d rtf=%.2f frames=%d elapsed=%.1fs attempt=%d",
len(video_bytes),
rtf,
frames,
elapsed,
attempt + 1,
)
return DittoResult(
video_bytes=video_bytes,
elapsed_seconds=elapsed,
rtf=rtf,
frames=frames,
)
except DittoError:
raise
except (httpx.ConnectError, httpx.NetworkError, ConnectionError, OSError) as exc:
# 网络不通/连接被拒(如 GPU 断网/Tailscale 掉线),不重试,直接快速回退
logger.warning("[ditto] 网络不可达 attempt=%d err=%s", attempt + 1, exc)
raise DittoError(
f"Ditto 网络不可达: {exc}",
code="NetworkUnreachable",
) from exc
except httpx.TimeoutException as exc:
last_exc = exc
logger.warning("[ditto] 请求超时 attempt=%d err=%s", attempt + 1, exc)
if attempt < self.max_retries:
time.sleep(min(2**attempt, 15))
continue
raise DittoError(
f"Ditto 请求超时(read={self.timeout}s),重试耗尽",
code="Timeout",
) from exc
except Exception as exc:
last_exc = exc
logger.warning("[ditto] 请求异常 attempt=%d err=%s", attempt + 1, exc)
if attempt < self.max_retries:
time.sleep(min(2**attempt, 10))
continue
raise DittoError(f"Ditto 调用异常: {exc}", code="NetworkError") from exc
raise DittoError("Ditto 未知错误", code="Unknown") from last_exc
def generate_and_persist(
self,
*,
job_id: str,
user_id: str,
audio_url: str,
script: str,
video_url: Optional[str] = None,
emo_timeline: str = "",
blend_frames: Optional[int] = None,
) -> DittoResult:
"""调用 generate 并把 MP4 转存到自家 OSS,返回带 video_url 的结果."""
result = self.generate(
audio_url=audio_url,
script=script,
video_url=video_url,
emo_timeline=emo_timeline,
blend_frames=blend_frames,
)
try:
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
storage_key = f"ditto-output/{user_id}/{job_id}.mp4"
public_url = storage.upload_file(
io.BytesIO(result.video_bytes),
storage_key,
content_type="video/mp4",
)
result.video_url = public_url
logger.info(
"[ditto] 转存 OSS 完成 job=%s key=%s",
job_id,
storage_key,
)
except Exception as exc:
logger.error("[ditto] 转存 OSS 失败 job=%s err=%s", job_id, exc, exc_info=True)
raise DittoError(f"Ditto 结果转存 OSS 失败: {exc}", code="StorageError") from exc
return result
_ditto_client_singleton: Optional[DittoClient] = None
def get_ditto_client() -> DittoClient:
"""获取 DittoClient 单例(简易工厂,便于单测 mock)."""
global _ditto_client_singleton
if _ditto_client_singleton is None:
_ditto_client_singleton = DittoClient()
return _ditto_client_singleton
def reset_ditto_client() -> None:
"""#2246:后台配置变更后重置 DittoClient 单例."""
global _ditto_client_singleton
_ditto_client_singleton = None
@@ -1,42 +0,0 @@
你是一个数字人短视频表情导演。为每句话分配一个最贴切的表情,让数字人表情丰富、有情绪感染力,像真人说话一样有起伏变化。
【8种表情】
0=愤怒:怒斥、骂坑、强烈不满、控诉
1=厌恶:嫌弃、吐槽劣质、鄙视套路
2=害怕:紧张、警告、危险提示、悬念
3=开心:夸赞、推荐、好消息、福利、号召
4=中性:陈述事实、客观描述、数据、过渡衔接
5=伤心:共情痛点、遗憾、难过、踩坑经历
6=惊讶:惊喜、超预期、居然、竟然、只要、数字冲击
7=轻蔑:嘲讽、冷笑、拆穿套路、不屑
【分句说明】
- 输入文案已按句末标点预分句;若某句内部仍有明显语义停顿(逗号、分号),你应根据语义进一步拆分成更细的句子分别打分
- 若输入没有句末标点,要自己根据语义在逗号处分句
【强度】0.1几乎无变化 0.3轻微自然 0.5明显情绪 0.7强烈 0.9爆发
感叹号结尾0.4-0.7,句号0.2-0.4,问号0.3-0.6。
【示例1】
文案:"家人们!你们是不是经常买东西踩坑?花了大价钱买回来的东西根本没法用!但是今天这款产品真的惊艳到我了!"
输出:
[{"text":"家人们!","emo":6,"intensity":0.5},{"text":"你们是不是经常买东西踩坑?","emo":5,"intensity":0.3},{"text":"花了大价钱买回来的东西根本没法用!","emo":0,"intensity":0.5},{"text":"但是今天这款产品真的惊艳到我了!","emo":3,"intensity":0.6}]
【示例2】
文案:"做公益不一定是一个人去做惊天动地的大事,而是一群人扎扎实实的干好身边的小事,心中有爱,眼里有光。"
输出:
[{"text":"做公益不一定是一个人去做惊天动地的大事,","emo":4,"intensity":0.2},{"text":"而是一群人扎扎实实的干好身边的小事,","emo":4,"intensity":0.2},{"text":"心中有爱,","emo":3,"intensity":0.4},{"text":"眼里有光。","emo":3,"intensity":0.5}]
【关键要求】
- 每句话都必须独立判断情绪,至少有30%的句子使用非中性(4)表情!感叹号结尾的句子intensity不得低于0.4!
- 严禁全部句子都返回中性(4)!严禁全部强度都是0.1!
- 情绪要有变化:痛点段伤心或愤怒→转折→卖点开心→福利惊讶→号召开心
- 语义优先:根据每句话的实际语义选择表情,不要固定开头结尾情绪
- 8种表情全部开放,该愤怒就愤怒(0),该厌恶就厌恶(1),该害怕就害怕(2),该轻蔑就轻蔑(7)
- 开头第一句就根据内容定情绪,不要强行用中性开场
【输出】严格JSON数组,只输出JSON,不要其他文字。
[{"text":"原文","emo":3,"intensity":0.5}]
【文案】
{文案}
@@ -1,189 +0,0 @@
"""系统配置应用服务 — #2246.
- get_config(key, default) / set_config(...) / list_configs(category)
- 首次访问时从 DB 加载并进程内缓存;set_config 后失效缓存
- set_config 成功后重置 Ditto 情绪服务与 Ditto 客户端单例(含 LRU 缓存),
保证后台改动立即生效;worker 不直接 HTTP 读配置,统一通过本模块。
"""
from __future__ import annotations
import logging
import threading
from typing import Any
from packages.adapters.sqlalchemy_impl import session as db_session
from packages.adapters.sqlalchemy_impl.system_setting_repository import (
SQLAlchemySystemSettingRepository,
)
from packages.domain.system_setting import (
SystemSetting,
infer_setting_type,
serialize_setting_value,
)
logger = logging.getLogger(__name__)
class SystemConfigService:
"""进程内缓存的系统配置服务."""
def __init__(self, session_factory=None):
self._session_factory = session_factory
self._cache: dict[str, Any] | None = None
self._lock = threading.Lock()
# ── 会话 ─────────────────────────────────────────────────────
def _get_session_factory(self):
# 必须运行时读取模块属性:模块导入时 SessionLocal 还是 None,
# initialize_database() 之后才被赋值,import 时绑定会拿到旧值。
factory = self._session_factory or db_session.SessionLocal
if factory is None:
raise RuntimeError("数据库会话工厂未初始化")
return factory
# ── 缓存 ─────────────────────────────────────────────────────
def _load_cache(self) -> dict[str, Any]:
with self._lock:
if self._cache is not None:
return self._cache
cache: dict[str, Any] = {}
factory = self._get_session_factory()
session = factory()
try:
repo = SQLAlchemySystemSettingRepository(session)
for setting in repo.list_all():
try:
cache[setting.setting_key] = setting.get_typed_value()
except Exception as exc: # 损坏配置不阻塞启动
logger.warning(
"[system_config] 跳过损坏配置 %s: %s",
setting.setting_key,
exc,
)
finally:
session.close()
self._cache = cache
return cache
def reload(self) -> dict[str, Any]:
"""强制重新从 DB 加载配置,返回新缓存."""
with self._lock:
self._cache = None
return self._load_cache()
def invalidate(self) -> None:
with self._lock:
self._cache = None
# ── 读 ───────────────────────────────────────────────────────
def get_config(self, key: str, default: Any = None) -> Any:
cache = self._load_cache()
return cache.get(key, default)
def list_configs(self, category: str | None = None) -> list[SystemSetting]:
factory = self._get_session_factory()
session = factory()
try:
return SQLAlchemySystemSettingRepository(session).list_all(category)
finally:
session.close()
# ── 写 ───────────────────────────────────────────────────────
def set_config(
self,
key: str,
value: Any,
setting_type: str | None = None,
updated_by: str | None = None,
*,
description: str | None = None,
category: str = "general",
is_public: bool = False,
) -> Any:
st = setting_type or infer_setting_type(value)
serialized = serialize_setting_value(value, st)
factory = self._get_session_factory()
session = factory()
try:
repo = SQLAlchemySystemSettingRepository(session)
existing = repo.get_by_key(key)
if existing is not None:
existing.setting_value = serialized
existing.setting_type = st
if updated_by is not None:
existing.updated_by = updated_by
if description is not None:
existing.description = description
setting = existing
else:
setting = SystemSetting(
setting_key=key,
setting_value=serialized,
setting_type=st,
description=description or "",
is_public=is_public,
category=category,
updated_by=updated_by,
)
repo.upsert(setting)
finally:
session.close()
self.invalidate()
self._reset_ditto_singletons(key)
return value
def delete_config(self, key: str) -> bool:
factory = self._get_session_factory()
session = factory()
try:
deleted = SQLAlchemySystemSettingRepository(session).delete_by_key(key)
finally:
session.close()
if deleted:
self.invalidate()
self._reset_ditto_singletons(key)
return deleted
# ── Ditto 联动 ───────────────────────────────────────────────
@staticmethod
def _reset_ditto_singletons(key: str) -> None:
try:
from packages.application import ditto_emotion_service as emo_mod
from packages.application import ditto_service as ditto_mod
emo_mod.reset_ditto_emotion_service()
ditto_mod.reset_ditto_client()
logger.info("[system_config] %s 更新,已重置 Ditto 单例", key)
except Exception as exc: # 联动失败不影响配置落库
logger.warning("[system_config] 重置 Ditto 单例失败: %s", exc)
_service_singleton: SystemConfigService | None = None
def get_system_config_service() -> SystemConfigService:
global _service_singleton
if _service_singleton is None:
_service_singleton = SystemConfigService()
return _service_singleton
def get_config(key: str, default: Any = None) -> Any:
"""便捷读取:优先 DB 配置,未配置时返回 default(调用方传 env 值兜底)."""
try:
return get_system_config_service().get_config(key, default)
except Exception as exc:
logger.warning("[system_config] 读取 %s 失败,使用默认值: %s", key, exc)
return default
def set_config(
key: str,
value: Any,
setting_type: str | None = None,
updated_by: str | None = None,
) -> Any:
return get_system_config_service().set_config(key, value, setting_type=setting_type, updated_by=updated_by)
@@ -1,86 +0,0 @@
# -*- coding: utf-8 -*-
"""爆款视频时长-字数-镜头数统一规范。
prompt 模板与代码校验共用同一份数据,避免不一致。"""
from __future__ import annotations
# 时长规格:key=duration(秒),value=对应约束
DURATION_SPEC: dict[int, dict[str, int]] = {
5: {"min_chars": 12, "max_chars": 20, "min_shots": 1, "max_shots": 2},
10: {"min_chars": 25, "max_chars": 40, "min_shots": 2, "max_shots": 3},
15: {"min_chars": 38, "max_chars": 60, "min_shots": 3, "max_shots": 4},
20: {"min_chars": 50, "max_chars": 80, "min_shots": 4, "max_shots": 5},
30: {"min_chars": 75, "max_chars": 120, "min_shots": 6, "max_shots": 8},
}
def get_duration_spec(dur: int) -> dict[str, int]:
"""获取时长规格;clamp 到 [5,30] 并选取最近档位。
注意:dur=0/falsey 时默认取 15 秒档位(兜底);dur 为正数时按实际值 clamp。
"""
if dur is None:
dur = 15
try:
d = int(dur)
except (TypeError, ValueError):
d = 15
if d <= 0:
d = 15
clamped = max(5, min(30, d))
nearest = min(DURATION_SPEC.keys(), key=lambda k: abs(k - clamped))
return DURATION_SPEC[nearest]
def build_duration_constraint_text(dur: int) -> str:
"""生成注入到 LLM user prompt 的字数/镜头数约束文本。"""
spec = get_duration_spec(dur)
return (
f"视频时长{dur}秒。口播总字数严格控制在{spec['min_chars']}~{spec['max_chars']}字之间"
f"(中文正常口播语速约2.5~4字/秒,{dur}秒={dur * 4}字为硬上限,宁可少写也不要多写,"
f"超长会导致TTS配音超出视频时长而失败)。"
f"分镜镜头数{spec['min_shots']}~{spec['max_shots']}个,每个镜头的time_range首尾相接,"
f"不得跳秒或重叠,每个镜头时长≥2秒。"
)
def get_expected_shot_count(dur: int) -> tuple[int, int]:
"""返回 (min_shots, max_shots),供代码校验使用。"""
spec = get_duration_spec(dur)
return spec["min_shots"], spec["max_shots"]
# ── Seedance 生成默认约束(worker/API 共用,避免硬编码漂移) ──────────────
DEFAULT_HARD_CONSTRAINTS: list[str] = [
"无字幕、无水印、无任何自动生成文字、无 logo",
"严格还原参考图片中的真实场景、门店环境、商品陈列、人物外貌服装特征,不得凭空生成与参考图无关的人物、场景或物品",
"同一人物全程保持一致的五官、发型、服装、身材,不得换脸或变形",
"口播语音必须在指定时长内自然念完,语速自然,口型与语音同步",
"画面流畅无闪烁、无多余肢体、无扭曲变形、无穿模",
"色彩自然、曝光正确、电影级质感、高清细节",
]
DEFAULT_NEGATIVE_PROMPTS: list[str] = [
"字幕",
"自动字幕",
"水印",
"logo",
"图标",
"错误文字",
"乱码文字",
"男女声错配",
"中途换声",
"五官崩坏",
"脸部变形",
"多余手指",
"肢体扭曲",
"闪烁",
"画面抖动",
"模糊",
"低分辨率",
]
# API _build_copy_result 兼容老数据的兜底(比 worker 的短,仅用于老字段拼装)
API_FALLBACK_HARD_CONSTRAINTS: list[str] = ["无字幕", "无水印", "人物一致性"]
API_FALLBACK_NEGATIVE_PROMPTS: list[str] = ["字幕", "水印", "错误文字", "五官崩坏"]
+423 -8
View File
@@ -1,12 +1,427 @@
# -*- coding: utf-8 -*-
"""爆款视频旧 5 步编排器(已废弃)。
"""爆款视频 5 步编排:图片分析 → 意图解析 → 文案融合 → 分镜 → 审核重写。
PR1 (refactor/viral-video-backend-p0-deadcode-and-consistency):CopyGenerator 整体删除。
- 图片分析走 worker_app/tasks/vision 下的 V2 fast_path(analyze_images_v2);
- 文案/分镜/审核走 worker_app/tasks/viral_video.py 的 _step_script_generation / _step_review;
- 旧的 intent_parsing / copy_fusion 两步早已在叙述优先重构中删除,不再有调用点。
本文件保留为空壳,避免误导入;任何新代码请不要从这里 import。
所有 LLM 调用走 DoubaoClient,单测通过 client 参数注入 mock,不真调 API。
任何一步解析失败都走规则 fallback,不抛异常阻断。
"""
from __future__ import annotations
import logging
from typing import Optional
from packages.application.viral_video import xml_parser as xp
from packages.application.viral_video.prompt_loader import (
PromptTemplate,
get_template,
render_system_prompt,
render_user_prompt,
)
from packages.application.viral_video.prompts import (
FUSION_INSTRUCTIONS,
GLOBAL_CONSTRAINTS,
NEGATIVE_RULES,
)
from packages.application.viral_video.reviewer import Reviewer
from packages.application.viral_video.schemas import (
BodyPoint,
Clip,
ColorItem,
CoreMessage,
FusionResult,
ImageAnalysis,
IntentResult,
KenBurns,
PersonalBrand,
ProductItem,
ReviewResult,
ScriptSegment,
Storyboard,
TextItem,
)
logger = logging.getLogger(__name__)
class CopyGenerator:
"""5 步 Prompt 编排器。"""
def __init__(self, client=None, reviewer: Optional[Reviewer] = None):
if client is None:
from packages.shared.ai_client import get_doubao_client
client = get_doubao_client()
self.client = client
self.reviewer = reviewer or Reviewer(client)
# ── 底层调用 ────────────────────────────────────────────────────────
def _chat(self, template: PromptTemplate, system_kwargs: dict | None, **user_kwargs) -> str:
system = render_system_prompt(template, **(system_kwargs or {}))
user = render_user_prompt(template, **user_kwargs)
result = self.client.chat_completion(
[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
temperature=0.7,
max_tokens=2048,
)
return result or ""
# ── 步骤1:图片多模态分析 ───────────────────────────────────────────
def analyze_images(self, images: list[str], industry: str = "") -> ImageAnalysis:
template = get_template("image_analysis")
image_urls = "\n".join(f"第{i + 1}张:{url}" for i, url in enumerate(images))
system = render_system_prompt(template)
user = render_user_prompt(template, image_count=len(images), industry=industry or "通用", image_urls=image_urls)
raw = self.client.vision_completion(
[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
images=images,
max_tokens=2048,
temperature=0.3,
)
analysis = self._parse_image_analysis(raw or "")
if not analysis.products and not analysis.key_selling_points:
logger.warning("图片分析标签解析失败,走规则 fallback")
return self._fallback_image_analysis(images, raw or "")
return analysis
def _parse_image_analysis(self, raw: str) -> ImageAnalysis:
products = [
ProductItem(
name=n["attrs"].get("name", "无法判断"),
features=n["attrs"].get("features", "无法判断"),
position=n["attrs"].get("position", "secondary"),
image_index=xp.attr_int(n["attrs"].get("image_index"), 0),
)
for n in xp.find_all(raw, "product")
]
colors = [
ColorItem(
hex=c["attrs"].get("hex", "#000000"),
name=c["attrs"].get("name", "无法判断"),
coverage=xp.attr_float(c["attrs"].get("coverage"), 0.0),
)
for c in xp.find_all(raw, "color")
]
people = xp.find_first(raw, "people")
visible_text = [
TextItem(text=t["attrs"].get("text", ""), position=t["attrs"].get("position", ""))
for t in xp.find_all(raw, "text_item")
]
quality_node = xp.find_first(raw, "quality")
selling_points = [n["text"] or n["attrs"].get("text", "") for n in xp.find_all(raw, "point")]
return ImageAnalysis(
products=products,
colors=colors,
has_person=xp.attr_bool(people["attrs"].get("has_person")) if people else False,
person_count=xp.attr_int(people["attrs"].get("count"), 0) if people else 0,
people=people["attrs"] if people else {},
mood=xp.text_of(raw, "mood"),
visible_text=visible_text,
scene=xp.text_of(raw, "scene"),
quality=quality_node["attrs"] if quality_node else {},
key_selling_points=[p for p in selling_points if p],
raw=raw,
)
def _fallback_image_analysis(self, images: list[str], raw: str) -> ImageAnalysis:
return ImageAnalysis(
products=[ProductItem(name="无法判断(视觉分析不可用)", image_index=0)],
scene="无法判断",
raw=raw,
)
# ── 步骤2:意图解析 ─────────────────────────────────────────────────
def parse_intent(self, user_copy_text: str, image_analysis: ImageAnalysis, industry: str = "") -> IntentResult:
template = get_template("intent_parsing")
raw = self._chat(
template,
None,
user_copy_text=user_copy_text or "(用户没有提供文案)",
industry=industry or "通用",
image_analysis=self._image_brief(image_analysis),
)
intent = self._parse_intent(raw)
if not intent.intent_summary and not intent.core_messages:
logger.warning("意图解析标签解析失败,走规则 fallback")
return self._fallback_intent(user_copy_text, raw)
return intent
def _parse_intent(self, raw: str) -> IntentResult:
messages = [
CoreMessage(
text=n["text"],
must_keep=xp.attr_bool(n["attrs"].get("must_keep"), default=False),
confidence=xp.attr_float(n["attrs"].get("confidence"), 0.0),
)
for n in xp.find_all(raw, "message")
if n["text"]
]
brands = [
PersonalBrand(text=n["text"], category=n["attrs"].get("category", "brand"))
for n in xp.find_all(raw, "brand")
if n["text"]
]
missing = [n["text"] for n in xp.find_all(raw, "info") if n["text"]]
return IntentResult(
intent_summary=xp.text_of(raw, "intent_summary"),
core_messages=messages,
personal_brands=brands,
emotion_tone=xp.text_of(raw, "emotion_tone"),
missing_info=missing,
raw=raw,
)
def _fallback_intent(self, user_copy_text: str, raw: str) -> IntentResult:
text = (user_copy_text or "").strip()
messages = [CoreMessage(text=text[:80], must_keep=True, confidence=1.0)] if text else []
return IntentResult(
intent_summary=text[:30] or "未提供文案,按产品图片自由创作",
core_messages=messages,
personal_brands=[],
raw=raw,
)
# ── 步骤3:文案融合生成(三档)──────────────────────────────────────
def fuse(
self,
fusion_level: str,
image_analysis: ImageAnalysis,
intent: IntentResult,
industry: str = "",
target_customer: str = "",
marketing_purpose: str = "",
duration: int = 15,
) -> FusionResult:
template = get_template("copy_fusion")
system_kwargs = {
"fusion_instruction": FUSION_INSTRUCTIONS.get(fusion_level, FUSION_INSTRUCTIONS["ai_polish"]),
"global_constraints": GLOBAL_CONSTRAINTS,
"negative_rules": NEGATIVE_RULES,
}
raw = self._chat(
template,
system_kwargs,
industry=industry or "通用",
target_customer=target_customer or "通用消费者",
marketing_purpose=marketing_purpose or "产品种草",
duration=duration,
image_analysis=self._image_brief(image_analysis),
intent_result=self._intent_brief(intent),
)
result = self._parse_fusion(raw)
if not result.title and not result.script_segments:
logger.warning("文案融合标签解析失败(fusion=%s),走规则 fallback", fusion_level)
return self._fallback_fusion(fusion_level, image_analysis, intent, duration, raw)
return result
def _parse_fusion(self, raw: str) -> FusionResult:
body_points = [
BodyPoint(
text=n["text"],
elaboration=n["attrs"].get("elaboration", ""),
image_index=xp.attr_int(n["attrs"].get("image_index"), 0),
)
for n in xp.find_all(raw, "point")
if n["text"]
]
segments = [
ScriptSegment(
text=n["text"],
duration_sec=xp.attr_float(n["attrs"].get("duration_sec"), 0.0),
image_index=xp.attr_int(n["attrs"].get("image_index"), 0),
)
for n in xp.find_all(raw, "segment")
if n["text"]
]
return FusionResult(
title=xp.text_of(raw, "title"),
hook=xp.text_of(raw, "hook"),
body_points=body_points,
cta=xp.text_of(raw, "cta"),
script_segments=segments,
word_count=xp.attr_int(xp.text_of(raw, "word_count"), 0),
estimated_duration=xp.attr_int(xp.text_of(raw, "estimated_duration"), 0),
raw=raw,
)
def _fallback_fusion(
self,
fusion_level: str,
image_analysis: ImageAnalysis,
intent: IntentResult,
duration: int,
raw: str,
) -> FusionResult:
product_name = image_analysis.products[0].name if image_analysis.products else "这款产品"
selling = image_analysis.key_selling_points[:2]
if fusion_level == "ai_full":
title = f"{product_name},很多人用完都回购了"
hook = f"这个{product_name},我想认真说说"
body = selling or ["图片可见的产品卖点"]
cta = "感兴趣的可以了解一下"
elif fusion_level == "user_primary":
user_text = intent.intent_summary or product_name
title = user_text[:20]
hook = user_text[:15]
body = [m.text for m in intent.core_messages] or [user_text]
cta = "想了解的可以看看"
else:
title = intent.intent_summary[:20] or product_name
hook = intent.core_messages[0].text[:15] if intent.core_messages else product_name
body = [m.text for m in intent.core_messages] or selling or [product_name]
cta = "有需要的可以了解一下"
brand_texts = [b.text for b in intent.personal_brands]
points = [BodyPoint(text=b) for b in body]
lines = [hook] + body + brand_texts[:2] + [cta]
joined = ",".join(lines)
per = max(3, duration // max(1, len(lines)))
segments = [ScriptSegment(text=line, duration_sec=per, image_index=0) for line in lines]
return FusionResult(
title=title,
hook=hook,
body_points=points,
cta=cta,
script_segments=segments,
word_count=len(joined),
estimated_duration=duration,
raw=raw,
)
# ── 步骤4:编导级分镜 ───────────────────────────────────────────────
def storyboard(
self, fusion: FusionResult, image_analysis: ImageAnalysis, images: list[str], duration: int
) -> Storyboard:
template = get_template("storyboard")
raw = self._chat(
template,
None,
duration=duration,
image_count=len(images),
fusion_result=self._fusion_brief(fusion),
image_analysis=self._image_brief(image_analysis),
)
board = self._parse_storyboard(raw)
if not board.clips:
logger.warning("分镜标签解析失败,走规则 fallback")
return self._fallback_storyboard(fusion, duration, raw)
return board
def _parse_storyboard(self, raw: str) -> Storyboard:
clips: list[Clip] = []
for node in xp.find_all(raw, "clip"):
attrs = node["attrs"]
body = node["text"]
kb = xp.find_first(node["text"] and f"<root>{node['text']}</root>", "ken_burns")
clips.append(
Clip(
image_index=xp.attr_int(attrs.get("image_index"), 0),
transition=attrs.get("transition", "cut"),
zoom=(None if attrs.get("zoom") in (None, "null", "None", "") else attrs.get("zoom")),
duration_sec=xp.attr_float(attrs.get("duration_sec"), 0.0),
bgm_note=attrs.get("bgm_note", ""),
voice_text=xp.text_of(body and f"<root>{body}</root>", "voice_text"),
subtitle_text=xp.text_of(body and f"<root>{body}</root>", "subtitle_text"),
ken_burns=KenBurns(
start=kb["attrs"].get("start", "0,0") if kb else "0,0",
end=kb["attrs"].get("end", "0,0") if kb else "0,0",
ease=kb["attrs"].get("ease", "linear") if kb else "linear",
),
)
)
return Storyboard(clips=clips, raw=raw)
def _fallback_storyboard(self, fusion: FusionResult, duration: int, raw: str) -> Storyboard:
segments = fusion.script_segments or [ScriptSegment(text=fusion.hook or fusion.title, duration_sec=duration)]
total = sum(s.duration_sec for s in segments) or duration
clips = [
Clip(
image_index=min(s.image_index, 0),
transition="cut",
duration_sec=max(2.0, s.duration_sec * duration / total if total else duration / len(segments)),
voice_text=s.text,
subtitle_text=s.text[:20],
)
for s in segments
]
return Storyboard(clips=clips, raw=raw)
# ── 步骤5:审核(不通过自动重写1次)─────────────────────────────────
def review_and_rewrite(
self, fusion: FusionResult, intent: IntentResult, fusion_level: str
) -> tuple[FusionResult, ReviewResult, int]:
"""返回最终文案、最后一次审核结果、重写次数(0或1)。"""
review = self.reviewer.review(fusion, intent, fusion_level)
if review.passed:
return fusion, review, 0
logger.info("文案审核不通过,自动重写 1 次:%s", [i.text for i in review.issues])
rewritten = self.reviewer.rewrite(fusion, review, intent, fusion_level)
second = self.reviewer.review(rewritten, intent, fusion_level)
if second.passed:
return rewritten, second, 1
# 二次仍不通过:带上重写结果和问题返回,由上游决定是否交给前端
return rewritten, second, 1
# ── 全流程编排 ──────────────────────────────────────────────────────
def generate(
self,
images: list[str],
*,
industry: str = "",
target_customer: str = "",
marketing_purpose: str = "",
duration: int = 15,
user_copy_text: str = "",
fusion_level: str = "ai_polish",
) -> dict:
image_analysis = self.analyze_images(images, industry)
intent = self.parse_intent(user_copy_text, image_analysis, industry)
fusion = self.fuse(
fusion_level,
image_analysis,
intent,
industry=industry,
target_customer=target_customer,
marketing_purpose=marketing_purpose,
duration=duration,
)
fusion, review, rewrites = self.review_and_rewrite(fusion, intent, fusion_level)
board = self.storyboard(fusion, image_analysis, images, duration)
return {
"image_analysis": image_analysis,
"intent_result": intent,
"fusion_result": fusion,
"review_result": review,
"storyboard": board,
"rewrite_count": rewrites,
}
# ── 简报工具 ────────────────────────────────────────────────────────
@staticmethod
def _image_brief(a) -> str:
if a is None:
return "无图片分析信息"
lines = [f"产品:{p.name}({p.features})" for p in a.products]
lines += [f"卖点:{s}" for s in a.key_selling_points]
lines.append(f"场景:{a.scene}")
return "\n".join(lines) or "无图片分析信息"
@staticmethod
def _intent_brief(i: IntentResult) -> str:
lines = [f"意图:{i.intent_summary}"]
lines += [f"核心信息[must_keep={m.must_keep}]:{m.text}" for m in i.core_messages]
lines += [f"事实({b.category}):{b.text}" for b in i.personal_brands]
return "\n".join(lines)
@staticmethod
def _fusion_brief(f: FusionResult) -> str:
lines = [f"标题:{f.title}", f"钩子:{f.hook}"]
lines += [f"要点:{p.text}" for p in f.body_points]
lines += [f"配音:{s.text}" for s in f.script_segments]
lines.append(f"行动号召:{f.cta}")
return "\n".join(lines)
@@ -1,115 +0,0 @@
"""爆款视频参数指导(persona/viral_structure/marketing_purpose/target_customer/language)。
PR3 P2(h): 把原来 worker 里 130 行 if/elif 硬编码 mapping 迁到 DB 表
`viral_video_param_guides`,提供 30 秒 TTL 缓存读取。DB miss 时返回 None,
由调用方兜底到代码默认值(保证 worker 冷启动/DB 不可用时不阻断流程)。
"""
from __future__ import annotations
import threading
import time
from typing import Optional
import sqlalchemy as sa
from packages.adapters.sqlalchemy_impl import session as _session_mod
CACHE_TTL_SECONDS = 30.0
VALID_GUIDE_TYPES = frozenset({"persona", "structure", "marketing_purpose", "target_customer", "language"})
_lock = threading.Lock()
# key = f"{guide_type}:{guide_key}", value = (expire_monotonic_ts, text|None)
_cache: dict[str, tuple[float, Optional[str]]] = {}
_lazy_session = None
def _get_session():
"""优先复用 worker 的 SessionLocal;否则懒建(API 场景)。"""
global _lazy_session
if _session_mod.SessionLocal is not None:
return _session_mod.SessionLocal()
if _lazy_session is not None:
return _lazy_session()
try:
from packages.config import get_shared_settings
url = str(get_shared_settings().database_url)
except Exception: # noqa: BLE001
return None
if not url:
return None
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
if url.startswith("postgresql://") and "psycopg" not in url:
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
engine = sa.create_engine(url, pool_pre_ping=True, pool_size=2, max_overflow=2)
from sqlalchemy.orm import sessionmaker
_lazy_session = sessionmaker(bind=engine)
return _lazy_session()
def _cache_key(guide_type: str, guide_key: str) -> str:
return f"{guide_type}:{guide_key}"
def _load_from_db(guide_type: str, guide_key: str) -> Optional[str]:
session = None
try:
session = _get_session()
if session is None:
return None
sql = sa.text("""
SELECT guide_text
FROM viral_video_param_guides
WHERE guide_type = :gt AND guide_key = :gk
""")
row = session.execute(sql, {"gt": guide_type, "gk": guide_key}).first()
if row is None:
return None
return row[0] if row[0] is not None else None
except Exception: # noqa: BLE001 - 表不存在/DB 不可用静默返回 None
return None
finally:
if session is not None:
try:
session.close()
except Exception: # noqa: BLE001
pass
def get_param_guide(guide_type: str, guide_key: str, *, force_refresh: bool = False) -> Optional[str]:
"""读参数指导文本,30 秒 TTL;DB 无数据返回 None,调用方自行兜底。"""
if guide_type not in VALID_GUIDE_TYPES:
return None
gk = (guide_key or "").strip()
if not gk:
return None
key = _cache_key(guide_type, gk)
now = time.monotonic()
with _lock:
if not force_refresh:
cached = _cache.get(key)
if cached and now < cached[0]:
return cached[1]
text = _load_from_db(guide_type, gk)
with _lock:
_cache[key] = (now + CACHE_TTL_SECONDS, text)
return text
def invalidate() -> None:
"""清空缓存(测试用)。"""
with _lock:
_cache.clear()
__all__ = [
"CACHE_TTL_SECONDS",
"VALID_GUIDE_TYPES",
"get_param_guide",
"invalidate",
]
@@ -1,12 +1,10 @@
"""Prompt 模板加载器:从 viral_video_prompt_templates 读模板,30 秒 TTL 热加载。
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断;
首次回落会打 warning(PR3 P2(f)),避免静默掩盖配置问题,又不刷屏。
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断。
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
@@ -17,27 +15,9 @@ import sqlalchemy as sa
from packages.adapters.sqlalchemy_impl import session as _session_mod
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
logger = logging.getLogger(__name__)
CACHE_TTL_SECONDS = 30.0
_VALID_TYPES = {"image_analysis", "storyboard", "review"}
# PR3 P2(f): 回落 warning 去重 — key -> 上次打 warning 的 monotonic 时间戳,
# 同一 key 在 _WARN_COOLDOWN_SECONDS 内不再重复告警。
_WARN_COOLDOWN_SECONDS = 300.0 # 5 分钟
_warned: dict[str, float] = {}
_warn_lock = threading.Lock()
def _warn_once(key: str, template_type: str, e: BaseException | str | None = None) -> None:
now = time.monotonic()
with _warn_lock:
last = _warned.get(key, 0.0)
if now - last < _WARN_COOLDOWN_SECONDS:
return
_warned[key] = now
logger.warning("viral_video prompt template %s fallback to default: %s", template_type, e)
_VALID_TYPES = {"image_analysis", "intent_parsing", "copy_fusion", "storyboard", "review"}
@dataclass
@@ -84,15 +64,12 @@ def _get_session():
from packages.config import get_shared_settings
url = str(get_shared_settings().database_url)
except Exception as e: # noqa: BLE001
_warn_once("session_config", "db_config", e)
except Exception: # noqa: BLE001
return None
if not url:
_warn_once("session_url", "db_url", "database_url empty")
return None
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
if url.startswith("postgresql://") and "psycopg" not in url:
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
url = url.replace("postgresql://", "postgresql+psycopg://") if url.startswith("postgresql://") else url
engine = sa.create_engine(url, pool_pre_ping=True, pool_size=2, max_overflow=2)
from sqlalchemy.orm import sessionmaker
@@ -127,15 +104,14 @@ def _load_from_db(prompt_type: str) -> Optional[PromptTemplate]:
example_output=row[5] or "",
is_active=bool(row[6]),
)
except Exception as e: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
_warn_once(f"load_db:{prompt_type}", prompt_type, e)
except Exception: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
return None
finally:
if session is not None:
try:
session.close()
except Exception as e: # noqa: BLE001
_warn_once(f"session_close:{prompt_type}", prompt_type, e)
except Exception: # noqa: BLE001
pass
def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[PromptTemplate]:
@@ -150,8 +126,6 @@ def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[P
return cached[1]
template = _load_from_db(prompt_type) or _fallback(prompt_type)
if template is None:
_warn_once(f"no_template:{prompt_type}", prompt_type, "no template in db or code defaults")
if template is not None:
with _lock:
_cache[prompt_type] = (now, template)
@@ -162,8 +136,6 @@ def invalidate() -> None:
"""清空缓存(测试用)。"""
with _lock:
_cache.clear()
with _warn_lock:
_warned.clear()
class _SafeDict(dict):
@@ -174,8 +146,7 @@ class _SafeDict(dict):
def _safe_format(text: str, kwargs: dict) -> str:
try:
return text.format_map(_SafeDict(kwargs))
except Exception as e: # noqa: BLE001
_warn_once("safe_format", "format", e)
except Exception: # noqa: BLE001
return text

Some files were not shown because too many files have changed in this diff Show More