Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 617c40e1d4 | |||
| 2597962528 | |||
| 8c56694599 | |||
| cd618c3f29 | |||
| 015fd2c381 | |||
| 8caf3ac8c3 | |||
| 3577108e29 | |||
| 0bf4f359a7 | |||
| 35e7789c81 | |||
| 70c526ffc3 | |||
| e2de75c9f6 | |||
| d949e90051 | |||
| 7adbb7d331 | |||
| 8624896379 | |||
| 65343473d8 | |||
| d7fa9d9e8d | |||
| 90004cced4 | |||
| e672c17eb2 | |||
| fb0e4989cd | |||
| 702f09e6b7 | |||
| 12b0d15473 | |||
| 9344314eac | |||
| 3f49867384 | |||
| dd420c556f | |||
| 2d823a9255 | |||
| ed24c7cd68 | |||
| 69f88434bd | |||
| 599388d9e0 | |||
| 9ffe909dc0 | |||
| 6243196408 | |||
| 3d8f2c2ce2 | |||
| 3eef497dfe | |||
| 61c15eb987 | |||
| 0012ecad30 | |||
| e1994ada0a | |||
| f9f6c53ef4 | |||
| 9699a1fcde | |||
| 305e2bd9d5 | |||
| 30cc58441f | |||
| b3b5dbd459 | |||
| 9f5948dd2b | |||
| fbc1df36e0 | |||
| d36cc09be5 | |||
| fa4dbc6761 | |||
| 77133eb9a0 | |||
| 35207ae040 | |||
| 5af16f1aea | |||
| c232721fff | |||
| fc27d4e81e | |||
| 3c782f89d1 | |||
| 2b4f11036f | |||
| 2881da65cf | |||
| 1037e218bb | |||
| f0514d7487 | |||
| bf8f62ec5b | |||
| cb911f5eba | |||
| f6fa3ea653 | |||
| 6a7709ba43 | |||
| 2c70dd4c29 | |||
| 5548e78eee | |||
| 44bd96b148 | |||
| dda67cd10f | |||
| 233d0272a9 | |||
| 631dd643c0 | |||
| 50489b05d6 | |||
| 252ea71d50 | |||
| d5f6e9499d | |||
| afd6b9c0b8 | |||
| 8a6ebb49be | |||
| 9e024caff5 | |||
| c7e9878f0d | |||
| b6243c8ab8 | |||
| 67e4b3fc6f | |||
| df654bce19 | |||
| bd99c161dd | |||
| 7fee203693 | |||
| 562ffc53cc | |||
| 9a67f727b3 | |||
| 5bb714f25b | |||
| 0f592add29 | |||
| 42dd5fabc6 | |||
| c7aed2c152 | |||
| 3428f4ef73 | |||
| 13c57771b9 | |||
| 5eedf190c1 | |||
| 7a9a97b31a | |||
| a6f89067e7 | |||
| 718deb32b9 | |||
| 054e81c3e7 | |||
| 07cf055a97 | |||
| 0e78f175fe | |||
| 371d1b9daf | |||
| 1a459d1ad2 | |||
| f097e16bc1 | |||
| 69eea54cd2 | |||
| b5cbc3b482 | |||
| 9106b4de2e | |||
| 7d639d5f9a | |||
| db6c237d68 | |||
| 7b68b94df6 | |||
| 8e19f24984 | |||
| 866d71a431 | |||
| 042512a527 | |||
| 3bb9c5dd4e |
+13
-4
@@ -211,15 +211,24 @@ COSYVOICE_CLONE_MODEL=voice-enrollment
|
||||
# 用于 AI 文案生成、智能剪辑等需要大模型能力的场景
|
||||
|
||||
DOUBAO_API_KEY=your-doubao-api-key
|
||||
DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
DOUBAO_FAST_MODEL=doubao-1-5-pro-32k-250115
|
||||
DOUBAO_MODEL=doubao-seed-2-1-pro-260915
|
||||
DOUBAO_FAST_MODEL=doubao-seed-2-1-lite-260915
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
# 视觉模型:pro 精度高,lite 速度快(viral-video 商品识别默认用 lite 提速)
|
||||
DOUBAO_VISION_MODEL=doubao-1-5-vision-pro-250328
|
||||
DOUBAO_VISION_LITE_MODEL=doubao-1-5-vision-lite-250315
|
||||
DOUBAO_VISION_MODEL=doubao-seed-2-1-pro-260915
|
||||
DOUBAO_VISION_LITE_MODEL=doubao-seed-2-1-lite-260915
|
||||
DOUBAO_VISION_USE_LITE=true
|
||||
# Embedding 向量化模型
|
||||
DOUBAO_EMBEDDING_MODEL=doubao-embedding-vision-251215
|
||||
# 视频模型(Seedance 2.5,统一走方舟;真人参考图通过信任链自动 AI 化)
|
||||
DOUBAO_VIDEO_MODEL=doubao-seedance-2-5-260628
|
||||
DOUBAO_VIDEO_TIMEOUT=480
|
||||
DOUBAO_VIDEO_POLL_INTERVAL=10
|
||||
# 图片模型(Seedream 5.0 Pro,用于信任链真人 AI 化 + 文生图)
|
||||
DOUBAO_IMAGE_MODEL=doubao-seedream-5-0-pro-260628
|
||||
DOUBAO_IMAGE_TIMEOUT=120
|
||||
|
||||
# ==================== 积分/会员系统 (#1895) ====================
|
||||
# 积分系统总开关:默认 false(暂停积分系统)。
|
||||
|
||||
@@ -1187,6 +1187,14 @@ jobs:
|
||||
DOUBAO_MODEL: "${{ secrets.DOUBAO_MODEL }}"
|
||||
DOUBAO_BASE_URL: "${{ secrets.DOUBAO_BASE_URL }}"
|
||||
DOUBAO_VISION_MODEL: "${{ secrets.DOUBAO_VISION_MODEL }}"
|
||||
DOUBAO_VISION_LITE_MODEL: "${{ secrets.DOUBAO_VISION_LITE_MODEL }}"
|
||||
DOUBAO_VISION_USE_LITE: "${{ secrets.DOUBAO_VISION_USE_LITE }}"
|
||||
DOUBAO_IMAGE_MODEL: "${{ secrets.DOUBAO_IMAGE_MODEL }}"
|
||||
DOUBAO_IMAGE_SIZE: "${{ secrets.DOUBAO_IMAGE_SIZE }}"
|
||||
DOUBAO_IMAGE_TIMEOUT: "${{ secrets.DOUBAO_IMAGE_TIMEOUT }}"
|
||||
DOUBAO_FAST_MODEL: "${{ secrets.DOUBAO_FAST_MODEL }}"
|
||||
DOUBAO_TIMEOUT: "${{ secrets.DOUBAO_TIMEOUT }}"
|
||||
DOUBAO_MAX_RETRIES: "${{ secrets.DOUBAO_MAX_RETRIES }}"
|
||||
WECHAT_APP_ID: "${{ secrets.WECHAT_APP_ID }}"
|
||||
WECHAT_APP_SECRET: "${{ secrets.WECHAT_APP_SECRET }}"
|
||||
TIKHUB_API_KEY: "${{ secrets.TIKHUB_API_KEY }}"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Mon Oct 5 04:09:11 PM CST 2026
|
||||
2198 lite/pro并行竞速 (commit 9699a1f) — CI rebuild trigger Mon Oct 5 08:09:11 AM UTC 2026
|
||||
@@ -0,0 +1,31 @@
|
||||
"""viral_video_jobs 增加 pre_trusted_images 列(信任链Seedream预热结果)
|
||||
|
||||
Revision ID: 094_viral_video_pre_trusted
|
||||
Revises: 093_viral_video_pricing_points_float
|
||||
Create Date: 2026-10-04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "094_viral_video_pre_trusted"
|
||||
down_revision = "093"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "pre_trusted_images" not in cols:
|
||||
op.add_column("viral_video_jobs", sa.Column("pre_trusted_images", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "pre_trusted_images" in cols:
|
||||
op.drop_column("viral_video_jobs", "pre_trusted_images")
|
||||
@@ -0,0 +1,102 @@
|
||||
"""爆款视频 Prompt 模板配置表(#2040)。
|
||||
|
||||
086 曾预留同名旧表(id varchar / content / variables json),从未被业务使用;
|
||||
本迁移将其替换为 #2040 新结构。
|
||||
|
||||
Revision ID: 095_viral_video_prompt_templates
|
||||
Revises: 094_viral_video_pre_trusted
|
||||
Create Date: 2026-10-04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "095_viral_video_prompt_templates"
|
||||
down_revision = "094_viral_video_pre_trusted"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(conn, name: str) -> bool:
|
||||
return name in sa.inspect(conn).get_table_names()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
# 086 预留的旧结构表:先删除(无业务数据、无任何引用)
|
||||
if _table_exists(conn, "viral_video_prompt_templates"):
|
||||
op.drop_table("viral_video_prompt_templates")
|
||||
|
||||
op.create_table(
|
||||
"viral_video_prompt_templates",
|
||||
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(128), nullable=False),
|
||||
sa.Column("prompt_type", sa.String(32), nullable=False),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("system_prompt", sa.Text, nullable=False),
|
||||
sa.Column("user_prompt_template", sa.Text, nullable=False),
|
||||
sa.Column("example_output", sa.Text, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_vvpt_type_active",
|
||||
"viral_video_prompt_templates",
|
||||
["prompt_type", "is_active"],
|
||||
)
|
||||
op.create_index(
|
||||
"uq_vvpt_type_version",
|
||||
"viral_video_prompt_templates",
|
||||
["prompt_type", "version"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if _table_exists(conn, "viral_video_prompt_templates"):
|
||||
op.drop_index("uq_vvpt_type_version", table_name="viral_video_prompt_templates")
|
||||
op.drop_index("ix_vvpt_type_active", table_name="viral_video_prompt_templates")
|
||||
op.drop_table("viral_video_prompt_templates")
|
||||
|
||||
# 恢复 086 的旧预留结构
|
||||
op.create_table(
|
||||
"viral_video_prompt_templates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("prompt_type", sa.String(50), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("variables", sa.JSON, nullable=False, server_default="[]"),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text("true"),
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
"""功能计费积分字段(爆款/对口型/智能剪辑 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
@@ -44,6 +44,7 @@ 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 维度)
|
||||
@@ -163,7 +164,6 @@ def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
|
||||
return matched or None
|
||||
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -700,6 +700,17 @@ 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% → 短素材禁复用);
|
||||
@@ -930,6 +941,42 @@ 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。
|
||||
|
||||
@@ -145,19 +145,22 @@ def get_rules(
|
||||
def get_packages(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询可购买的积分包列表。"""
|
||||
packages = []
|
||||
for code, pkg in POINTS_PACKAGES.items():
|
||||
unit_price = f"¥{pkg['price_cents'] / 100 / pkg['points']:.3f}/积分"
|
||||
packages.append(
|
||||
PointsPackageItem(
|
||||
code=code,
|
||||
name=pkg["name"],
|
||||
points=pkg["points"],
|
||||
price_cents=pkg["price_cents"],
|
||||
unit_price=unit_price,
|
||||
)
|
||||
"""查询可购买的积分包列表(读管理后台 credit_packages 表真实数据)。
|
||||
|
||||
仅返回 is_active=true;后台改价/启停后最多 30 秒生效。
|
||||
"""
|
||||
from packages.application.catalog.admin_catalog import get_points_packages
|
||||
|
||||
packages = [
|
||||
PointsPackageItem(
|
||||
code=row["code"],
|
||||
name=row["name"],
|
||||
points=row["points"],
|
||||
price_cents=row["price_cents"],
|
||||
unit_price=row["unit_price"],
|
||||
)
|
||||
for row in get_points_packages()
|
||||
]
|
||||
mt = _member_type(current_user)
|
||||
discount = MEMBER_DISCOUNT.get(mt) if mt else None
|
||||
return PointsPackagesResponse(packages=packages, user_discount=discount)
|
||||
|
||||
@@ -86,33 +86,13 @@ async def get_current_subscription(
|
||||
def list_membership_plans(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""查询所有会员档位(供前端会员购买页展示)。
|
||||
"""查询可购买的会员套餐(读管理后台 plans 表真实数据)。
|
||||
|
||||
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
|
||||
仅返回 is_enabled=true 的套餐;后台启停/改价后最多 30 秒生效。
|
||||
"""
|
||||
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
|
||||
from packages.application.catalog.admin_catalog import get_membership_plans
|
||||
|
||||
plans: list[dict[str, Any]] = []
|
||||
for plan_id, info in MEMBERSHIP_PRICES.items():
|
||||
days = info["duration_days"]
|
||||
monthly_cents = round(info["price_cents"] * 30 / days)
|
||||
features: dict[str, Any] = {"max_resolution": "1080p"}
|
||||
if plan_id == MembershipType.MONTHLY:
|
||||
features.update({"free_clips_daily": 2})
|
||||
elif plan_id == MembershipType.QUARTERLY:
|
||||
features.update({"free_clips_daily": 5})
|
||||
elif plan_id == MembershipType.YEARLY:
|
||||
features.update({"free_clips_daily": "unlimited"})
|
||||
plans.append({
|
||||
"plan_id": plan_id,
|
||||
"name": info["name"],
|
||||
"price_cents": info["price_cents"],
|
||||
"monthly_price_cents": monthly_cents,
|
||||
"duration_days": days,
|
||||
"points_discount": MEMBER_DISCOUNT.get(plan_id, 1.0),
|
||||
"features": features,
|
||||
})
|
||||
return {"plans": plans}
|
||||
return {"plans": get_membership_plans()}
|
||||
|
||||
|
||||
@router.get("/billing-records", response_model=list[BillingRecord])
|
||||
|
||||
@@ -146,6 +146,7 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
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",
|
||||
credits_prepaid=float(getattr(job, "credits_prepaid", 0) or 0),
|
||||
credits_cost=float(getattr(job, "credits_cost", 0) or 0),
|
||||
|
||||
@@ -222,6 +222,7 @@ class ViralVideoJobResponse(BaseModel):
|
||||
video_model: str = ""
|
||||
intent_result: dict | None = None
|
||||
result_video_url: str = ""
|
||||
pre_trusted_images: list[str] | None = None
|
||||
video_resolution: str = "720p"
|
||||
credits_prepaid: float = 0.0
|
||||
credits_cost: float = 0.0
|
||||
|
||||
@@ -228,6 +228,8 @@ 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)
|
||||
@@ -240,6 +242,8 @@ 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,
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ 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,
|
||||
@@ -368,6 +369,8 @@ 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",
|
||||
@@ -415,6 +418,121 @@ 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(
|
||||
@@ -466,6 +584,35 @@ 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(
|
||||
@@ -482,6 +629,8 @@ 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()
|
||||
@@ -677,6 +826,8 @@ class LipsyncService:
|
||||
job.completed_at = _now
|
||||
job.updated_at = _now
|
||||
self.db.commit()
|
||||
# lip_sync 超时全额退款
|
||||
self._refund_lip_sync(job)
|
||||
return job
|
||||
|
||||
# 未提交的任务不轮询
|
||||
@@ -702,6 +853,8 @@ 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
|
||||
@@ -719,6 +872,8 @@ 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:
|
||||
@@ -812,6 +967,8 @@ 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
|
||||
|
||||
@@ -104,6 +104,7 @@ 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":
|
||||
@@ -141,6 +142,7 @@ 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:
|
||||
@@ -157,6 +159,33 @@ 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:
|
||||
|
||||
@@ -176,7 +176,7 @@ export interface ViralVideoJob {
|
||||
fusion_level?: FusionLevel
|
||||
voice_id?: string
|
||||
voice_mode?: "global" | "per_video"
|
||||
voice_source?: "preset" | "library" | "clone" | "upload"
|
||||
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
|
||||
bgm_preference?: string
|
||||
intent_result?: IntentResult
|
||||
intent_text?: string
|
||||
@@ -212,7 +212,7 @@ export interface GenerateViralVideoRequest {
|
||||
user_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
voice_id?: string
|
||||
voice_source?: "preset" | "library" | "clone" | "upload"
|
||||
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
|
||||
bgm_preference?: string
|
||||
industry?: string
|
||||
target_customer?: string
|
||||
@@ -244,7 +244,7 @@ export interface AnalyzeImagesRequest {
|
||||
/** TTS 音色 ID(STEP1 已选音色时传) */
|
||||
voice_id?: string
|
||||
/** 音色来源:preset | library | clone | upload */
|
||||
voice_source?: "preset" | "library" | "clone" | "upload"
|
||||
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
|
||||
/** Seedance 视频比例:9:16 | 16:9 | 1:1 */
|
||||
video_ratio?: string
|
||||
/** Seedance 模型 ID(空则使用服务端默认) */
|
||||
@@ -273,7 +273,7 @@ export interface GenerateCopyRequest {
|
||||
/** TTS 音色 ID(优先级高于 persona_id) */
|
||||
voice_id?: string
|
||||
/** 音色来源:preset | library | clone | upload */
|
||||
voice_source?: "preset" | "library" | "clone" | "upload"
|
||||
voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice"
|
||||
/** Seedance 视频比例(9:16/16:9/1:1 等) */
|
||||
video_ratio?: string
|
||||
/** Seedance 模型 ID(空则使用服务端默认) */
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface VoiceClone {
|
||||
language: string
|
||||
gender: string
|
||||
error_message: string | null
|
||||
/** CosyVoice 实际使用的音色 ID(status=ready 时由后端填充,用于 TTS 调用) */
|
||||
voice_id?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
|
||||
language: profile.language || "",
|
||||
gender: profile.gender || "",
|
||||
error_message: profile.error_message || null,
|
||||
voice_id: profile.voice_id,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/* DurationWheelPicker —— 弹层式滚轮选择器(样式与表单一致) */
|
||||
|
||||
/* 触发按钮:外观复用 .vv-select 风格 */
|
||||
.dw-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e8;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.dw-trigger:hover {
|
||||
border-color: #c0c0d0;
|
||||
}
|
||||
.dw-trigger-open,
|
||||
.dw-trigger:focus-within {
|
||||
border-color: #7c3aed !important;
|
||||
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
.dw-trigger-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.dw-trigger-val {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dw-trigger-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dw-trigger-arrow {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
margin-left: 8px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.dw-trigger-arrow-up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 弹层容器 */
|
||||
.dw-popup {
|
||||
padding: 8px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* 滚轮 */
|
||||
.dw-picker {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #fafafe;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.dw-picker-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.dw-picker-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.dw-picker-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
scroll-snap-align: center;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
transition:
|
||||
color 0.15s,
|
||||
transform 0.15s,
|
||||
font-weight 0.15s;
|
||||
}
|
||||
.dw-picker-item-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dw-picker-item-unit {
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
.dw-picker-item-active {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dw-picker-item-active .dw-picker-item-val {
|
||||
font-size: 18px;
|
||||
}
|
||||
.dw-picker-item-active .dw-picker-item-unit {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 中心选中条 */
|
||||
.dw-picker-mask {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
pointer-events: none;
|
||||
background: #f5f0ff;
|
||||
border-radius: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
.dw-picker-mask::before,
|
||||
.dw-picker-mask::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #d8c4ff;
|
||||
}
|
||||
.dw-picker-mask::before {
|
||||
top: 0;
|
||||
}
|
||||
.dw-picker-mask::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* 上下渐变 */
|
||||
.dw-picker-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.dw-picker-fade-top {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, #fafafe 25%, rgba(250, 250, 254, 0));
|
||||
}
|
||||
.dw-picker-fade-bottom {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, #fafafe 25%, rgba(250, 250, 254, 0));
|
||||
}
|
||||
|
||||
/* 弹层按钮区 */
|
||||
.dw-popup-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.dw-popup-actions .ant-btn {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dw-popup-actions .ant-btn-primary {
|
||||
background: #7c3aed;
|
||||
}
|
||||
.dw-popup-actions .ant-btn-primary:hover {
|
||||
background: #6d28d9 !important;
|
||||
}
|
||||
|
||||
/* 覆盖 antd Popover 默认内边距 */
|
||||
.dw-popover .ant-popover-inner {
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dw-popover .ant-popover-arrow {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* DurationWheelPicker —— 竖屏滚轮式时长选择器(弹层版)
|
||||
*
|
||||
* 设计:
|
||||
* - 外观是和其他表单 Select 一致的输入框(白色底+1px灰边+紫色focus ring)
|
||||
* - 点击输入框弹出 Popover,内部是滚轮 picker(原生 scroll-snap,零依赖)
|
||||
* - 滚轮样式:白底容器,选中行 #7c3aed 紫字加粗+浅紫背景条
|
||||
* - 支持触摸/鼠标滚轮/点击;松手吸附;底部"确认/取消"按钮
|
||||
* - 默认范围 15–30 秒,步长 1 秒
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"
|
||||
import { Popover, Button } from "antd"
|
||||
import { DownOutlined } from "@ant-design/icons"
|
||||
import "./DurationWheelPicker.css"
|
||||
|
||||
export interface DurationWheelPickerProps {
|
||||
value?: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
unit?: string
|
||||
onChange?: (value: number) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** 弹层宽度,默认 160px */
|
||||
popupWidth?: number
|
||||
/** 弹层内滚轮高度,默认 180px */
|
||||
wheelHeight?: number
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 36
|
||||
|
||||
const DurationWheelPicker: React.FC<DurationWheelPickerProps> = ({
|
||||
value = 20,
|
||||
min = 15,
|
||||
max = 30,
|
||||
step = 1,
|
||||
unit = "秒",
|
||||
onChange,
|
||||
placeholder = "请选择时长",
|
||||
disabled = false,
|
||||
popupWidth = 160,
|
||||
wheelHeight = 180,
|
||||
}) => {
|
||||
const options = useMemo(() => {
|
||||
const arr: number[] = []
|
||||
for (let v = min; v <= max; v += step) arr.push(v)
|
||||
return arr
|
||||
}, [min, max, step])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
// 弹层内暂存值,点确认才提交
|
||||
const [draft, setDraft] = useState<number>(value)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(value)
|
||||
// 下一帧滚到当前值
|
||||
requestAnimationFrame(() => scrollToValue(value, false))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const scrollToValue = useCallback(
|
||||
(v: number, smooth = true) => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const idx = options.indexOf(v)
|
||||
if (idx < 0) return
|
||||
list.scrollTo({ top: idx * ITEM_HEIGHT, behavior: smooth ? "smooth" : "auto" })
|
||||
},
|
||||
[options],
|
||||
)
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const idx = Math.round(list.scrollTop / ITEM_HEIGHT)
|
||||
const clamped = Math.max(0, Math.min(options.length - 1, idx))
|
||||
const targetTop = clamped * ITEM_HEIGHT
|
||||
if (Math.abs(list.scrollTop - targetTop) > 1) {
|
||||
list.scrollTo({ top: targetTop, behavior: "smooth" })
|
||||
}
|
||||
setDraft(options[clamped])
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onChange?.(draft)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleItemClick = (v: number) => {
|
||||
setDraft(v)
|
||||
scrollToValue(v, true)
|
||||
}
|
||||
|
||||
const maskTop = wheelHeight / 2 - ITEM_HEIGHT / 2
|
||||
|
||||
const wheel = (
|
||||
<div className="dw-popup">
|
||||
<div
|
||||
className="dw-picker"
|
||||
style={{ height: wheelHeight, width: popupWidth - 24 /* padding */ }}
|
||||
>
|
||||
<div className="dw-picker-mask" style={{ top: maskTop, height: ITEM_HEIGHT }} aria-hidden />
|
||||
<div className="dw-picker-fade dw-picker-fade-top" aria-hidden />
|
||||
<div className="dw-picker-fade dw-picker-fade-bottom" aria-hidden />
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="dw-picker-list"
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
paddingTop: wheelHeight / 2 - ITEM_HEIGHT / 2,
|
||||
paddingBottom: wheelHeight / 2 - ITEM_HEIGHT / 2,
|
||||
}}
|
||||
>
|
||||
{options.map((v) => {
|
||||
const isActive = v === draft
|
||||
return (
|
||||
<li
|
||||
key={v}
|
||||
className={`dw-picker-item${isActive ? " dw-picker-item-active" : ""}`}
|
||||
style={{ height: ITEM_HEIGHT, lineHeight: `${ITEM_HEIGHT}px` }}
|
||||
onClick={() => handleItemClick(v)}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
>
|
||||
<span className="dw-picker-item-val">{v}</span>
|
||||
<span className="dw-picker-item-unit">{unit}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="dw-popup-actions">
|
||||
<Button size="small" onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={handleConfirm}>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={!disabled && open}
|
||||
onOpenChange={(v) => setOpen(v)}
|
||||
content={wheel}
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
overlayClassName="dw-popover"
|
||||
overlayStyle={{ padding: 0 }}
|
||||
overlayInnerStyle={{ padding: 0, borderRadius: 10 }}
|
||||
destroyTooltipOnHide
|
||||
>
|
||||
<div
|
||||
className={`dw-trigger${disabled ? " dw-trigger-disabled" : ""}${open ? " dw-trigger-open" : ""}`}
|
||||
style={{ height: 36 }}
|
||||
>
|
||||
<span className={`dw-trigger-val${value != null ? "" : " dw-trigger-placeholder"}`}>
|
||||
{value != null ? `${value}${unit}` : placeholder}
|
||||
</span>
|
||||
<DownOutlined className={`dw-trigger-arrow${open ? " dw-trigger-arrow-up" : ""}`} />
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default DurationWheelPicker
|
||||
@@ -10,4 +10,4 @@
|
||||
* 功能流程不做积分预校验,直接走生成。
|
||||
* - true:展示完整积分系统 UI。
|
||||
*/
|
||||
export const ENABLE_CREDIT_SYSTEM = false
|
||||
export const ENABLE_CREDIT_SYSTEM = true
|
||||
|
||||
@@ -1834,3 +1834,117 @@
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── v1.6 我的音色(默认主路径) ── */
|
||||
.vv-voice-section {
|
||||
margin-top: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 10px;
|
||||
}
|
||||
.vv-voice-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.vv-voice-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.vv-voice-section-title .anticon {
|
||||
color: #7c3aed;
|
||||
}
|
||||
.vv-link-btn-sm {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.vv-my-voice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vv-my-voice-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.vv-my-voice-item:hover {
|
||||
background: #f5f0ff;
|
||||
}
|
||||
.vv-my-voice-item.selected {
|
||||
background: #f5f0ff;
|
||||
}
|
||||
.vv-my-voice-item.selected .vv-voice-radio {
|
||||
border-color: #7c3aed;
|
||||
background: #7c3aed;
|
||||
box-shadow: inset 0 0 0 2px #fff;
|
||||
}
|
||||
.vv-voice-empty {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.vv-voice-empty-ic {
|
||||
font-size: 28px;
|
||||
color: #d1d5db;
|
||||
}
|
||||
.vv-voice-empty-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
.vv-voice-alt-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.vv-voice-alt-btn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #fafafe;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vv-voice-alt-btn:hover {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
background: #f5f0ff;
|
||||
}
|
||||
.vv-voice-alt-btn.selected {
|
||||
background: #f5f0ff;
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.vv-voice-panel-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px dashed #e5e7eb;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,13 @@ import {
|
||||
CheckCircleFilled,
|
||||
EditOutlined,
|
||||
HistoryOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Select, Input, message } from "antd"
|
||||
import { uploadAssetDirect, getAssetLibraries, getAssetsByKind, type AssetItem } from "@/api/assets"
|
||||
import { fetchPresetVoices } from "@/api/voices"
|
||||
import { getVoiceClones, getVoiceClonePreview } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import {
|
||||
FUSION_LEVELS,
|
||||
STYLE_STRENGTHS,
|
||||
@@ -94,13 +97,15 @@ type RefVideo = {
|
||||
} | null
|
||||
|
||||
type RefAudio = {
|
||||
source: "preset" | "library" | "upload" | "clone"
|
||||
source: "preset" | "library" | "upload" | "clone" | "my_voice"
|
||||
id?: string
|
||||
name: string
|
||||
url?: string
|
||||
/** 克隆音色的 CosyVoice voice_id(source=my_voice 时使用) */
|
||||
ttsVoiceId?: string
|
||||
} | null
|
||||
|
||||
type VoicePanel = "preset" | "upload" | "library" | "record" | "douyin" | null
|
||||
type VoicePanel = "my_voices" | "preset" | "upload" | "library" | "record" | "douyin" | null
|
||||
|
||||
type TabTask = {
|
||||
id: string
|
||||
@@ -213,12 +218,12 @@ const PURPOSES = [
|
||||
"悬念短剧",
|
||||
"情绪短片",
|
||||
]
|
||||
const DURATIONS = [15, 20, 30, 45, 60]
|
||||
const RATIOS = [
|
||||
{ v: "9:16", label: "9:16 竖屏(抖音/视频号)" },
|
||||
{ v: "16:9", label: "16:9 横屏(B站/YouTube)" },
|
||||
{ v: "1:1", label: "1:1 方形(小红书)" },
|
||||
]
|
||||
const DURATIONS = Array.from({ length: 16 }, (_, i) => 15 + i)
|
||||
/** 兜底模型列表(接口未返回时使用,字段与 ViralVideoModel 对齐;后端返回后自动覆盖) */
|
||||
const FALLBACK_VIDEO_MODELS: ViralVideoModel[] = [
|
||||
{
|
||||
@@ -432,7 +437,7 @@ const emptyTask = (id: string, title: string): TabTask => ({
|
||||
language: "中文(普通话)",
|
||||
viralStructure: STRUCTURES[0],
|
||||
marketingPurpose: "",
|
||||
duration: 15,
|
||||
duration: 20,
|
||||
persona: "",
|
||||
videoRatio: "9:16",
|
||||
videoModel: "seedance-2.5",
|
||||
@@ -485,6 +490,9 @@ const ViralVideoPage: React.FC = () => {
|
||||
{ id: string; name: string; url: string; desc?: string }[]
|
||||
>([])
|
||||
const [presetVoices, setPresetVoices] = useState<PresetVoice[]>([])
|
||||
/** 我的克隆音色(GET /voice-clones?status=ready) */
|
||||
const [myVoices, setMyVoices] = useState<VoiceClone[]>([])
|
||||
const [myVoicesLoading, setMyVoicesLoading] = useState(false)
|
||||
const [voicePickerOpen, setVoicePickerOpen] = useState(false)
|
||||
const [videoModels, setVideoModels] = useState<ViralVideoModel[]>(FALLBACK_VIDEO_MODELS)
|
||||
const [assetPicker, setAssetPicker] = useState<{
|
||||
@@ -514,8 +522,18 @@ const ViralVideoPage: React.FC = () => {
|
||||
[activeId],
|
||||
)
|
||||
|
||||
/* ── 加载音色 ── */
|
||||
/* ── 加载音色(我的克隆音色 + 上传的配音素材 + 预设音色) ── */
|
||||
const reloadMyVoices = useCallback(() => {
|
||||
setMyVoicesLoading(true)
|
||||
getVoiceClones({ status: "ready", limit: 50 })
|
||||
.then((list) => {
|
||||
setMyVoices(list.filter((v) => v.status === "ready"))
|
||||
})
|
||||
.catch(() => setMyVoices([]))
|
||||
.finally(() => setMyVoicesLoading(false))
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
reloadMyVoices()
|
||||
getAssetsByKind("voice", { limit: 50 })
|
||||
.then((list) =>
|
||||
setVoiceAssets(
|
||||
@@ -523,7 +541,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
url: a.file_url || "",
|
||||
desc: a.duration ? `${Math.round(a.duration)}s` : "我的音色",
|
||||
desc: a.duration ? `${Math.round(a.duration)}s` : "配音文件",
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -544,7 +562,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
// 接口失败兜底:弹窗内 MOCK_VOICES 会生效(voice_id 与后端 CosyVoice 一致)
|
||||
setPresetVoices([])
|
||||
})
|
||||
}, [])
|
||||
}, [reloadMyVoices])
|
||||
|
||||
/* ── 加载视频模型列表 ── */
|
||||
useEffect(() => {
|
||||
@@ -914,21 +932,36 @@ const ViralVideoPage: React.FC = () => {
|
||||
[setTask, uploadFile],
|
||||
)
|
||||
|
||||
const onCloneSuccess = (voice: { id?: string; name?: string }) => {
|
||||
const onCloneSuccess = (
|
||||
voice: VoiceClone | { id?: string; name?: string; voice_id?: string | null },
|
||||
) => {
|
||||
setCloneModalOpen(false)
|
||||
// 克隆成功后刷新我的音色列表
|
||||
reloadMyVoices()
|
||||
const vid = "voice_id" in voice ? voice.voice_id : undefined
|
||||
if (voice?.id) {
|
||||
setTask({
|
||||
refAudio: { source: "clone", id: voice.id, name: voice.name || "我录制的音色" },
|
||||
refAudio: {
|
||||
source: "my_voice",
|
||||
id: voice.id,
|
||||
name: voice.name || "我录制的音色",
|
||||
ttsVoiceId: vid || undefined,
|
||||
},
|
||||
voicePanel: null,
|
||||
})
|
||||
getAssetsByKind("voice", { limit: 50 })
|
||||
.then((list) =>
|
||||
setVoiceAssets(
|
||||
list.map((a) => ({ id: a.id, name: a.name, url: a.file_url || "", desc: "我的音色" })),
|
||||
list.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
url: a.file_url || "",
|
||||
desc: a.duration ? `${Math.round(a.duration)}s` : "配音文件",
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
message.success("录音已完成,已自动选中")
|
||||
message.success("音色克隆完成,已自动选中")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,7 +1047,12 @@ const ViralVideoPage: React.FC = () => {
|
||||
: undefined,
|
||||
fusion_level: task.fusionLevel,
|
||||
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
|
||||
voice_id: task.refAudio?.source === "preset" ? task.refAudio.id : 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,
|
||||
@@ -1058,7 +1096,12 @@ const ViralVideoPage: React.FC = () => {
|
||||
style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined,
|
||||
user_copy_text: task.storyboard?.voiceover_script || task.userCopy || undefined,
|
||||
fusion_level: task.fusionLevel,
|
||||
voice_id: task.refAudio?.id || 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,
|
||||
industry: task.industry || undefined,
|
||||
target_customer: task.targetCustomer || undefined,
|
||||
@@ -2033,43 +2076,119 @@ const ViralVideoPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 参考音频 2x2 */}
|
||||
{/* 配音选择(默认"我的音色") */}
|
||||
<div className="vv-subblock">
|
||||
<div className="vv-subblock-head">
|
||||
<span className="vv-subblock-title">参考音频</span>
|
||||
<span className="vv-subblock-title">AI 配音</span>
|
||||
<span className="vv-opt-tag">选填</span>
|
||||
<span className="vv-subblock-hint">最大 10MB · 最长 30 秒</span>
|
||||
<span className="vv-subblock-hint">
|
||||
默认使用我的克隆音色,也可选择内置音色或上传配音
|
||||
</span>
|
||||
</div>
|
||||
<div className="vv-audio-grid">
|
||||
|
||||
{/* 我的音色(主路径) */}
|
||||
<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={() => setCloneModalOpen(true)}
|
||||
>
|
||||
<PlusOutlined /> 录制新音色
|
||||
</button>
|
||||
</div>
|
||||
{myVoicesLoading ? (
|
||||
<div className="vv-muted vv-voice-empty">加载中…</div>
|
||||
) : 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={() => setCloneModalOpen(true)}
|
||||
>
|
||||
去录制一个
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vv-my-voice-list">
|
||||
{myVoices.map((v) => {
|
||||
const selected =
|
||||
task.refAudio?.source === "my_voice" && task.refAudio.id === v.id
|
||||
const playing = task.playingVoiceId === `my-${v.id}`
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`vv-my-voice-item ${selected ? "selected" : ""}`}
|
||||
onClick={() =>
|
||||
setTask({
|
||||
refAudio: {
|
||||
source: "my_voice",
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
ttsVoiceId: v.voice_id || undefined,
|
||||
},
|
||||
voicePanel: null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<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()
|
||||
// 克隆音色试听:优先 sample_url,否则调用 preview 接口
|
||||
if (v.sample_url) {
|
||||
toggleVoice({ id: `my-${v.id}`, url: v.sample_url })
|
||||
} else {
|
||||
message.info("正在获取试听音频…")
|
||||
getVoiceClonePreview(v.id)
|
||||
.then((r) => {
|
||||
if (r.audio_url)
|
||||
toggleVoice({ 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-audio-cell ${task.refAudio?.source === "preset" ? "selected" : ""}`}
|
||||
className={`vv-voice-alt-btn ${task.refAudio?.source === "preset" ? "selected" : ""}`}
|
||||
onClick={() => setVoicePickerOpen(true)}
|
||||
>
|
||||
<FileTextOutlined className="vv-audio-ic" />
|
||||
<span>选择内置音色</span>
|
||||
<FileTextOutlined /> 内置音色
|
||||
</button>
|
||||
<button
|
||||
className={`vv-audio-cell ${task.voicePanel === "upload" ? "active" : ""} ${task.refAudio?.source === "upload" ? "selected" : ""}`}
|
||||
onClick={() => voiceInputRef.current?.click()}
|
||||
className={`vv-voice-alt-btn ${task.refAudio?.source === "upload" || task.refAudio?.source === "library" ? "selected" : ""}`}
|
||||
onClick={() =>
|
||||
setTask({ voicePanel: task.voicePanel === "library" ? null : "library" })
|
||||
}
|
||||
>
|
||||
<UploadOutlined className="vv-audio-ic" />
|
||||
<span>本地上传</span>
|
||||
</button>
|
||||
<button
|
||||
className={`vv-audio-cell ${task.voicePanel === "library" ? "active" : ""} ${task.refAudio?.source === "library" ? "selected" : ""}`}
|
||||
onClick={() => setAssetPicker({ open: true, kind: "voice", multiple: false })}
|
||||
>
|
||||
<FolderOpenOutlined className="vv-audio-ic" />
|
||||
<span>从素材库选择</span>
|
||||
</button>
|
||||
<button
|
||||
className={`vv-audio-cell ${task.voicePanel === "record" ? "active" : ""} ${task.refAudio?.source === "clone" ? "selected" : ""}`}
|
||||
onClick={() => setCloneModalOpen(true)}
|
||||
>
|
||||
<AudioMutedOutlined className="vv-audio-ic" />
|
||||
<span>直接录音</span>
|
||||
<FolderOpenOutlined /> 上传配音
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={voiceInputRef}
|
||||
type="file"
|
||||
@@ -2083,7 +2202,14 @@ const ViralVideoPage: React.FC = () => {
|
||||
{task.refAudio && (
|
||||
<div className="vv-selected-audio">
|
||||
<SoundOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
|
||||
<span style={{ flex: 1 }}>已选:{task.refAudio.name}</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
已选:
|
||||
{task.refAudio.source === "my_voice"
|
||||
? `我的音色 · ${task.refAudio.name}`
|
||||
: task.refAudio.source === "preset"
|
||||
? `内置 · ${task.refAudio.name}`
|
||||
: task.refAudio.name}
|
||||
</span>
|
||||
<button className="vv-link-btn" onClick={() => setTask({ refAudio: null })}>
|
||||
移除
|
||||
</button>
|
||||
@@ -2091,16 +2217,32 @@ const ViralVideoPage: React.FC = () => {
|
||||
)}
|
||||
{task.voicePanel === "library" && (
|
||||
<div className="vv-voice-panel">
|
||||
<div className="vv-voice-panel-actions">
|
||||
<button
|
||||
className="vv-link-btn vv-link-btn-sm"
|
||||
onClick={() => voiceInputRef.current?.click()}
|
||||
>
|
||||
<UploadOutlined /> 本地上传音频文件
|
||||
</button>
|
||||
<button
|
||||
className="vv-link-btn vv-link-btn-sm"
|
||||
onClick={() =>
|
||||
setAssetPicker({ open: true, kind: "voice", multiple: false })
|
||||
}
|
||||
>
|
||||
<FolderOpenOutlined /> 从素材库选择
|
||||
</button>
|
||||
</div>
|
||||
{voiceAssets.length === 0 ? (
|
||||
<div className="vv-muted" style={{ padding: 12, textAlign: "center" }}>
|
||||
暂无已上传的音频
|
||||
暂无已上传的配音文件
|
||||
</div>
|
||||
) : (
|
||||
<div className="vv-voice-list">
|
||||
{voiceAssets.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`vv-voice-item ${task.refAudio?.id === v.id && task.refAudio.source === "library" ? "selected" : ""}`}
|
||||
className={`vv-voice-item ${task.refAudio?.id === v.id && (task.refAudio.source === "library" || task.refAudio.source === "upload") ? "selected" : ""}`}
|
||||
onClick={() =>
|
||||
pickVoiceAsset({
|
||||
id: v.id,
|
||||
@@ -2289,7 +2431,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
options={PURPOSES.map((i) => ({ value: i, label: i }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="vv-form-row" style={{ gridColumn: "1 / -1" }}>
|
||||
<div className="vv-form-row">
|
||||
<label className="vv-label">文案视频时长</label>
|
||||
<Select
|
||||
className="vv-select"
|
||||
|
||||
@@ -387,6 +387,41 @@ 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 重渲。
|
||||
|
||||
@@ -1167,6 +1202,10 @@ 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,
|
||||
@@ -1205,6 +1244,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
)
|
||||
|
||||
# ── 自动重试逻辑 ──────────────────────────────────────────────────
|
||||
will_retry = False
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
@@ -1217,6 +1257,7 @@ 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,
|
||||
@@ -1250,6 +1291,13 @@ 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
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 图片分析:火山OCR专用API + doubao-lite强约束JSON并行,单次pro VLM兜底。"""
|
||||
|
||||
from .fast_path import analyze_image_v2, analyze_images_v2 # noqa: F401
|
||||
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 prompt 解析:优先读后台 viral_video_prompt_templates 表(prompt_type='image_analysis'
|
||||
且 is_active=true),30s TTL 热加载;DB 无有效记录/异常时,fallback 到纯硬编码 JSON schema prompt。
|
||||
|
||||
规则(简单直接,不做字符串匹配判断):
|
||||
- DB 有 is_active=true 的 image_analysis 记录(含种子版本和用户修改后的版本):
|
||||
* system = DB.system_prompt(DB prompt 自带完整输出格式,不追加硬编码 schema,
|
||||
避免 DB 写 XML、调用强制 json_object 造成的格式冲突)
|
||||
* user = DB.user_prompt_template 渲染后使用;渲染后为空则用硬编码默认
|
||||
- DB 无记录/连接异常/返回空:system/user 全部用纯硬编码 JSON schema prompt
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---- 纯硬编码 JSON schema(DB 无有效配置时全量使用) ----
|
||||
|
||||
_FAST_JSON_SCHEMA = (
|
||||
"你是图片结构化识别器。严格按下方 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'
|
||||
"}\n\n"
|
||||
"你必须只返回一个合法的JSON对象,不要输出任何其他文字、解释、XML标签或markdown。"
|
||||
)
|
||||
DEFAULT_FAST_USER = "识别这张图片的人物穿搭与主体信息,只返回JSON对象。"
|
||||
|
||||
_PRO_JSON_SCHEMA = (
|
||||
"你是图片分析专家。严格按下方 JSON schema 返回一个对象,不要解释、不要markdown、不要代码块、不要XML标签。\n"
|
||||
"{\n"
|
||||
' "has_person": true/false,\n'
|
||||
' "gender": "男"/"女"/null,\n'
|
||||
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
|
||||
' "outfit": "整体穿着描述(含颜色款式)",\n'
|
||||
' "hair": "发型发色",\n'
|
||||
' "pose": "姿势",\n'
|
||||
' "expression": "表情",\n'
|
||||
' "scene": "场景",\n'
|
||||
' "mood": "氛围",\n'
|
||||
' "has_product": true/false,\n'
|
||||
' "category": "类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n'
|
||||
' "product_name": "产品名,非产品图填null",\n'
|
||||
' "brand": "品牌,无则null",\n'
|
||||
' "key_features": ["核心特征数组,3-6个短语"]\n'
|
||||
"}\n\n"
|
||||
"你必须只返回一个合法的JSON对象,不要输出任何其他文字、解释、XML标签或markdown。"
|
||||
)
|
||||
DEFAULT_PRO_USER = "分析这张图片,返回符合schema的JSON。"
|
||||
|
||||
# 保留旧 JSON schema 追加文本作为常量(DB prompt 完全控制输出格式后不再使用,
|
||||
# 保留以便排查历史行为)。
|
||||
_FAST_JSON_APPEND = (
|
||||
"\n\n【输出格式要求】无论上文如何要求,最终你必须只返回一个合法的JSON对象,"
|
||||
"严格包含以下字段(字段值不确定时填null或空数组):\n"
|
||||
"{\n"
|
||||
' "has_person": true/false,\n'
|
||||
' "gender": "男"/"女"/null,\n'
|
||||
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
|
||||
' "upper_wear": "上装款式字符串",\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": "图案",\n'
|
||||
' "colors": ["主色数组"],\n'
|
||||
' "mood": "整体氛围"\n'
|
||||
"}\n"
|
||||
"不要输出任何其他文字、解释、XML标签或markdown。"
|
||||
)
|
||||
|
||||
_PRO_JSON_APPEND = (
|
||||
"\n\n【输出格式要求】无论上文如何要求,最终你必须只返回一个合法的JSON对象,"
|
||||
"严格包含以下字段(字段值不确定时填null或空数组):\n"
|
||||
"{\n"
|
||||
' "has_person": true/false,\n'
|
||||
' "gender": "男"/"女"/null,\n'
|
||||
' "age_range": "儿童"/"青少年"/"青年"/"中年"/"老年"/null,\n'
|
||||
' "outfit": "整体穿着描述(含颜色款式)",\n'
|
||||
' "hair": "发型发色",\n'
|
||||
' "pose": "姿势",\n'
|
||||
' "expression": "表情",\n'
|
||||
' "scene": "场景",\n'
|
||||
' "mood": "氛围",\n'
|
||||
' "has_product": true/false,\n'
|
||||
' "category": "类目:服饰/鞋包/美妆/数码/食品/家居/配饰/母婴/非产品图",\n'
|
||||
' "product_name": "产品名,非产品图填null",\n'
|
||||
' "brand": "品牌,无则null",\n'
|
||||
' "key_features": ["核心特征3-6个短语"]\n'
|
||||
"}\n"
|
||||
"不要输出任何其他文字、解释、XML标签或markdown。"
|
||||
)
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[float, Any]] = {}
|
||||
_CACHE_TTL = 30.0
|
||||
|
||||
|
||||
def _load_db_template() -> Any | None:
|
||||
"""直接查DB viral_video_prompt_templates 中 is_active=true 的 image_analysis 记录;
|
||||
DB不可达/无记录/异常返回None。
|
||||
复用 prompt_loader._load_from_db,它只查DB不做DEFAULT_TEMPLATES fallback,
|
||||
返回None表示DB无记录或异常。"""
|
||||
try:
|
||||
from packages.application.viral_video.prompt_loader import _load_from_db
|
||||
|
||||
return _load_from_db("image_analysis")
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] 查询DB prompt配置失败: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _render_user(tpl: Any | None, default_user: str) -> str:
|
||||
if not tpl:
|
||||
return default_user
|
||||
tpl_str = getattr(tpl, "user_prompt_template", "") or ""
|
||||
if not tpl_str.strip():
|
||||
return default_user
|
||||
rendered = tpl_str.replace("{image_count}", "1").replace("{industry}", "通用").replace("{image_urls}", "").strip()
|
||||
return rendered or default_user
|
||||
|
||||
|
||||
def resolve_fast_prompt() -> tuple[str, str]:
|
||||
return _resolve("fast")
|
||||
|
||||
|
||||
def resolve_pro_prompt() -> tuple[str, str]:
|
||||
return _resolve("pro")
|
||||
|
||||
|
||||
def _resolve(kind: 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:
|
||||
return hit[1]
|
||||
|
||||
default_sys = _FAST_JSON_SCHEMA if kind == "fast" else _PRO_JSON_SCHEMA
|
||||
default_user = DEFAULT_FAST_USER if kind == "fast" else DEFAULT_PRO_USER
|
||||
|
||||
sys_prompt = default_sys
|
||||
usr_prompt = default_user
|
||||
try:
|
||||
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 prompt自带完整输出格式,不追加硬编码schema避免冲突
|
||||
usr_prompt = _render_user(tpl, default_user)
|
||||
logger.info(
|
||||
"[vision.v2] 使用DB image_analysis prompt (kind=%s version=%s sys_len=%d)",
|
||||
kind,
|
||||
getattr(tpl, "version", "?"),
|
||||
len(db_sys),
|
||||
)
|
||||
else:
|
||||
logger.debug("[vision.v2] DB image_analysis system_prompt为空,使用默认JSON (kind=%s)", kind)
|
||||
else:
|
||||
logger.debug("[vision.v2] DB无image_analysis记录/不可达,使用默认JSON prompt (kind=%s)", kind)
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] 解析DB prompt异常,使用默认: %s", e)
|
||||
|
||||
with _cache_lock:
|
||||
_cache[cache_key] = (now, (sys_prompt, usr_prompt))
|
||||
return sys_prompt, usr_prompt
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
with _cache_lock:
|
||||
_cache.clear()
|
||||
@@ -0,0 +1,646 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 fast_json VLM 输出 + OCR 文本组装为下游兼容的 product dict。
|
||||
|
||||
v4 schema: DB prompt完全控制输出格式,可能是v4嵌套schema(type/products/people/store_info)
|
||||
或旧扁平schema(has_person/upper_wear/product_name/brand等)。assembler兼容两种格式。
|
||||
|
||||
目标:下游(信任链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
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _join_parts(*parts: str | None) -> str:
|
||||
return "".join(p for p in parts if p)
|
||||
|
||||
|
||||
_AGE_PREFIX = {"青年": "年轻", "中年": "中年", "老年": "老年"}
|
||||
_GENDER_WORD = {"男": "男性", "女": "女性"}
|
||||
|
||||
|
||||
def _person_subject(gender: str, age: str) -> str:
|
||||
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 "人物"
|
||||
|
||||
|
||||
def _build_wear_from_v4(p: dict) -> str:
|
||||
"""v4 person schema: upper_wear/upper_color/lower_wear/lower_color/dress_color"""
|
||||
upper = p.get("upper_wear") or ""
|
||||
upper_color = p.get("upper_color") or ""
|
||||
lower = p.get("lower_wear") or ""
|
||||
lower_color = p.get("lower_color") or ""
|
||||
dress_color = p.get("dress_color") or ""
|
||||
is_dress = ("连衣裙" in upper) or ("裙" in upper and not lower)
|
||||
if is_dress:
|
||||
c = dress_color or upper_color
|
||||
return f"身穿{c}{upper}" if c else f"身穿{upper}"
|
||||
parts = []
|
||||
if upper:
|
||||
up = f"{upper_color}{upper}" if upper_color else upper
|
||||
parts.append(f"上身{up}")
|
||||
if lower:
|
||||
lo = f"{lower_color}{lower}" if lower_color else lower
|
||||
parts.append(f"下身{lo}")
|
||||
return ",".join(parts)
|
||||
|
||||
|
||||
def _build_portrait_prompt_from_v4(p: dict) -> str:
|
||||
"""v4 person: 直接用portrait_prompt字段;没有就拼"""
|
||||
direct = p.get("portrait_prompt")
|
||||
if direct and len(direct) >= 10:
|
||||
return direct
|
||||
subject = _person_subject(p.get("gender", ""), p.get("age_range", ""))
|
||||
wear = _build_wear_from_v4(p)
|
||||
acc = p.get("accessories") or []
|
||||
if isinstance(acc, str):
|
||||
acc = [acc]
|
||||
acc_str = ",佩戴" + "、".join(str(a) for a in acc if a) if acc else ""
|
||||
hair = p.get("hairstyle") or ""
|
||||
expr = p.get("expression") or ""
|
||||
pose = p.get("pose") or ""
|
||||
style = p.get("outfit_style") or p.get("style") or ""
|
||||
scene = p.get("scene") or ""
|
||||
mood = p.get("mood") or ""
|
||||
details = []
|
||||
if hair:
|
||||
details.append(hair)
|
||||
if expr and expr not in ("自然", "平静"):
|
||||
details.append(f"神情{expr}")
|
||||
if pose and pose not in ("站立",):
|
||||
details.append(pose)
|
||||
style_parts = []
|
||||
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 details:
|
||||
pieces.append(",".join(details))
|
||||
pieces.append(("".join(style_parts) + "风格") if style_parts else "人像写真")
|
||||
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 _build_product_prompt_from_v4(prod: dict, top: dict) -> str:
|
||||
"""v4 product: 拼商品视觉描述prompt(用于AI生图参考)"""
|
||||
name = prod.get("product_name") or "商品"
|
||||
brand = prod.get("brand") or ""
|
||||
lead = f"{brand} {name}" if brand and brand not in name else name
|
||||
pkg_color = prod.get("package_color") or ""
|
||||
pkg_type = prod.get("package_type") or ""
|
||||
cap = prod.get("cap_type") or ""
|
||||
body = prod.get("body_shape") or ""
|
||||
features = prod.get("product_features") or []
|
||||
sell = prod.get("key_selling_points") or []
|
||||
colors = top.get("colors") or []
|
||||
style = top.get("style") or ""
|
||||
scene = top.get("scene") or ""
|
||||
mood = top.get("mood") or ""
|
||||
|
||||
parts = [lead]
|
||||
desc = []
|
||||
if pkg_color:
|
||||
desc.append(pkg_color)
|
||||
if pkg_type:
|
||||
desc.append(pkg_type)
|
||||
if cap and len(desc) < 3:
|
||||
desc.append(f"配{cap}")
|
||||
if body and len(desc) < 3:
|
||||
desc.append(body)
|
||||
if desc:
|
||||
parts.append(",".join(desc))
|
||||
if features:
|
||||
core = [str(f) for f in features[:3] if f and len(str(f)) <= 25]
|
||||
if core:
|
||||
parts.append(";".join(core))
|
||||
if sell:
|
||||
s = [str(x) for x in sell[:2] if x]
|
||||
if s:
|
||||
parts.append("突出" + "、".join(s))
|
||||
cnames = []
|
||||
for cc in colors:
|
||||
if isinstance(cc, dict) and cc.get("name"):
|
||||
cnames.append(cc["name"])
|
||||
elif isinstance(cc, str):
|
||||
cnames.append(cc)
|
||||
cnames = cnames[:3]
|
||||
if cnames:
|
||||
parts.append("、".join(cnames) + "主色")
|
||||
if style:
|
||||
parts.append(style)
|
||||
if mood:
|
||||
parts.append(mood)
|
||||
if scene and not any(k in scene for k in ("白色背景", "纯色", "通用")):
|
||||
parts.append(scene)
|
||||
parts.append("产品特写,画面清晰")
|
||||
prompt = ",".join(p for p in parts if p)
|
||||
return prompt if len(prompt) >= 10 else "产品展示图,特写镜头"
|
||||
|
||||
|
||||
def _is_v4_schema(fj: dict) -> bool:
|
||||
"""判断是v4嵌套schema还是旧扁平schema"""
|
||||
return (
|
||||
isinstance(fj.get("products"), list)
|
||||
or fj.get("type") in ("product", "store", "person", "other")
|
||||
or isinstance(fj.get("people"), dict)
|
||||
)
|
||||
|
||||
|
||||
# ---------- 旧扁平schema兼容(保留原逻辑) ----------
|
||||
|
||||
|
||||
def _person_subject_old(fj: dict) -> str:
|
||||
return _person_subject(fj.get("gender", ""), fj.get("age_range", ""))
|
||||
|
||||
|
||||
def _build_wear_sentence_old(fj: dict) -> 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 ""
|
||||
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}"
|
||||
parts = []
|
||||
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 lower:
|
||||
lo = f"{lower_color}{lower}" if lower_color else lower
|
||||
parts.append(f"下身{lo}")
|
||||
return ",".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _build_portrait_prompt_old(fj: dict) -> str:
|
||||
if not fj.get("has_person"):
|
||||
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:
|
||||
cnames = []
|
||||
for c in colors:
|
||||
if isinstance(c, dict):
|
||||
cnames.append(c.get("name", ""))
|
||||
elif isinstance(c, str):
|
||||
cnames.append(c)
|
||||
cnames = [c for c in cnames if c][:3]
|
||||
if cnames:
|
||||
pieces.append("、".join(cnames) + "配色")
|
||||
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_old(fj)
|
||||
wear = _build_wear_sentence_old(fj)
|
||||
accessories = fj.get("accessories") or []
|
||||
if isinstance(accessories, str):
|
||||
accessories = [accessories]
|
||||
acc_str = ",佩戴" + "、".join(str(a) for a in accessories if a) if accessories else ""
|
||||
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 = []
|
||||
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 = []
|
||||
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))
|
||||
pieces.append("".join(style_parts) + "风格" if style_parts else "人像写真")
|
||||
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_old(fj: dict, ocr_texts: list[str]) -> str:
|
||||
pname = fj.get("product_name")
|
||||
if pname and pname != "未识别":
|
||||
return str(pname)
|
||||
if fj.get("has_person"):
|
||||
up = fj.get("upper_wear") or ""
|
||||
if "连衣裙" in up:
|
||||
return up
|
||||
return up or "人物穿搭"
|
||||
if ocr_texts:
|
||||
return max(ocr_texts, key=len)
|
||||
return "未识别"
|
||||
|
||||
|
||||
def _infer_brand_old(fj: dict, ocr_texts: list[str]) -> str:
|
||||
brand = fj.get("brand")
|
||||
if brand:
|
||||
return str(brand)
|
||||
for t in ocr_texts:
|
||||
if 1 < len(t) <= 12:
|
||||
return t
|
||||
return "无法判断"
|
||||
|
||||
|
||||
def _infer_category_old(fj: dict) -> str:
|
||||
cat = fj.get("category")
|
||||
if cat:
|
||||
return str(cat)
|
||||
if fj.get("has_person"):
|
||||
return "服饰"
|
||||
return "非产品图"
|
||||
|
||||
|
||||
def _build_appearance_old(fj: dict) -> str:
|
||||
parts = []
|
||||
for key in ("upper_color", "upper_wear", "material", "pattern"):
|
||||
v = fj.get(key)
|
||||
if v and v not in ("无法判断", "未知", "纯色"):
|
||||
parts.append(str(v))
|
||||
if not parts:
|
||||
return "人像穿搭整体造型" if fj.get("has_person") else "无法判断"
|
||||
return "、".join(parts)
|
||||
|
||||
|
||||
def _build_key_features_old(fj: dict, ocr_texts: list[str]) -> list[str]:
|
||||
feats = []
|
||||
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, seen = [], 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 _flatten_colors(c) -> list[str]:
|
||||
"""colors可能是字符串数组或[{hex,name,coverage}],统一返回名字数组"""
|
||||
if not c:
|
||||
return []
|
||||
out = []
|
||||
for item in c:
|
||||
if isinstance(item, dict):
|
||||
n = item.get("name")
|
||||
if n:
|
||||
out.append(n)
|
||||
elif isinstance(item, str):
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def assemble_result(idx: int, fast_json: dict | None, ocr_texts: list[str]) -> dict[str, Any]:
|
||||
fj = fast_json or {}
|
||||
ocr_texts = ocr_texts or []
|
||||
|
||||
if _is_v4_schema(fj):
|
||||
return _assemble_v4(idx, fj, ocr_texts)
|
||||
else:
|
||||
return _assemble_old(idx, fj, ocr_texts)
|
||||
|
||||
|
||||
def _assemble_v4(idx: int, fj: dict, ocr_texts: list[str]) -> dict[str, Any]:
|
||||
"""v4嵌套schema → 下游product dict"""
|
||||
vtype = fj.get("type") or "other"
|
||||
products = fj.get("products") or []
|
||||
scene = fj.get("scene") or "通用"
|
||||
mood = fj.get("mood") or ""
|
||||
colors = fj.get("colors") or []
|
||||
visible_text = fj.get("visible_text") or []
|
||||
color_names = _flatten_colors(colors)
|
||||
|
||||
# 合并OCR文字和visible_text
|
||||
pkg_texts = []
|
||||
for vt in visible_text:
|
||||
if isinstance(vt, dict):
|
||||
t = vt.get("text")
|
||||
if t:
|
||||
pkg_texts.append(str(t))
|
||||
elif isinstance(vt, str):
|
||||
pkg_texts.append(vt)
|
||||
pkg_texts.extend(ocr_texts[:5])
|
||||
# 去重
|
||||
seen_t = set()
|
||||
text_on_package = []
|
||||
for t in pkg_texts:
|
||||
t = str(t).strip()
|
||||
if t and t not in seen_t and len(t) <= 50:
|
||||
seen_t.add(t)
|
||||
text_on_package.append(t)
|
||||
text_on_package = text_on_package[:8]
|
||||
|
||||
has_person = fj.get("has_person", False)
|
||||
|
||||
# 人物类
|
||||
if vtype == "person" or has_person:
|
||||
# 取第一个人物信息(v4 schema人物信息在顶层)
|
||||
person_info = fj
|
||||
# 兼容people嵌套
|
||||
ppl = fj.get("people")
|
||||
if isinstance(ppl, dict) and ppl.get("has_person"):
|
||||
person_info = {**fj, **ppl}
|
||||
has_person = True
|
||||
|
||||
portrait_prompt = _build_portrait_prompt_from_v4(person_info)
|
||||
name = person_info.get("upper_wear") or "人物穿搭"
|
||||
if "连衣裙" in name:
|
||||
pass
|
||||
else:
|
||||
lower = person_info.get("lower_wear") or ""
|
||||
if lower:
|
||||
name = f"{name}+{lower}"
|
||||
brand = "无法判断"
|
||||
category = "服饰"
|
||||
outfit_parts = []
|
||||
for k in ("upper_wear", "lower_wear", "dress_color", "upper_color", "lower_color", "outfit_style"):
|
||||
v = person_info.get(k)
|
||||
if v and v not in ("null", None):
|
||||
outfit_parts.append(str(v))
|
||||
appearance = "、".join(outfit_parts) if outfit_parts else "人像穿搭整体造型"
|
||||
# key_features: 穿搭特征+配饰
|
||||
kf = []
|
||||
for k in (
|
||||
"upper_wear",
|
||||
"lower_wear",
|
||||
"upper_color",
|
||||
"lower_color",
|
||||
"hairstyle",
|
||||
"expression",
|
||||
"pose",
|
||||
"outfit_style",
|
||||
):
|
||||
v = person_info.get(k)
|
||||
if v and v not in ("null", None, "无法判断"):
|
||||
kf.append(str(v))
|
||||
acc = person_info.get("accessories") or []
|
||||
if isinstance(acc, list):
|
||||
kf.extend(str(a) for a in acc if a)
|
||||
if text_on_package:
|
||||
kf.append(f"画面文字: {'/'.join(text_on_package[:3])}")
|
||||
kf = kf[:6] or ["无法判断"]
|
||||
summary = (person_info.get("outfit_style") or "") + (person_info.get("upper_wear") or "穿搭")
|
||||
if not summary or summary == "穿搭":
|
||||
summary = "人物穿搭"
|
||||
return {
|
||||
"name": name[:30],
|
||||
"brand": brand,
|
||||
"category": category,
|
||||
"appearance": appearance,
|
||||
"packaging": "人物形象无包装",
|
||||
"text_on_package": text_on_package,
|
||||
"key_features": kf,
|
||||
"scene": scene,
|
||||
"mood": mood,
|
||||
"portrait_prompt": portrait_prompt,
|
||||
"summary": summary[:40],
|
||||
"_source": "v2_fast_json_v4",
|
||||
}
|
||||
|
||||
# 商品类
|
||||
if vtype == "product" and products:
|
||||
# 主商品(第一个position=main或第一个)
|
||||
main = products[0]
|
||||
for p in products:
|
||||
if p.get("position") == "main":
|
||||
main = p
|
||||
break
|
||||
name = main.get("product_name") or "未识别"
|
||||
brand = main.get("brand") or "无法判断"
|
||||
category = main.get("category") or "非产品图"
|
||||
# appearance: 包装外观
|
||||
app_parts = []
|
||||
for k in ("package_color", "package_type", "cap_type", "body_shape", "label_design"):
|
||||
v = main.get(k)
|
||||
if v and v not in ("null", None):
|
||||
app_parts.append(str(v))
|
||||
appearance = ";".join(app_parts) if app_parts else "无法判断"
|
||||
# packaging: 包装信息(直接用package_type+package_color)
|
||||
pkg_parts = []
|
||||
if main.get("package_type"):
|
||||
pkg_parts.append(str(main["package_type"]))
|
||||
if main.get("package_color"):
|
||||
pkg_parts.append(str(main["package_color"]))
|
||||
if main.get("cap_type"):
|
||||
pkg_parts.append(f"配{main['cap_type']}")
|
||||
packaging = ",".join(pkg_parts) if pkg_parts else "无法判断"
|
||||
# key_features: product_features字段
|
||||
feats = main.get("product_features") or []
|
||||
if not isinstance(feats, list):
|
||||
feats = [str(feats)]
|
||||
kf = [str(f) for f in feats if f and len(str(f)) <= 40][:6]
|
||||
# 补充卖点
|
||||
sell = main.get("key_selling_points") or []
|
||||
if isinstance(sell, list):
|
||||
for s in sell[:2]:
|
||||
if s and len(str(s)) <= 30 and str(s) not in kf:
|
||||
kf.append(f"卖点:{s}")
|
||||
if text_on_package:
|
||||
kf.append(f"文字: {'/'.join(text_on_package[:3])}")
|
||||
kf = kf[:6] or ["无法判断"]
|
||||
portrait_prompt = _build_product_prompt_from_v4(main, fj)
|
||||
if brand != "无法判断" and brand not in name:
|
||||
summary = f"{brand} {name}"
|
||||
else:
|
||||
summary = name
|
||||
return {
|
||||
"name": str(name)[:50],
|
||||
"brand": str(brand)[:30],
|
||||
"category": str(category)[:20],
|
||||
"appearance": appearance[:200],
|
||||
"packaging": packaging[:100],
|
||||
"text_on_package": text_on_package,
|
||||
"key_features": kf,
|
||||
"scene": scene,
|
||||
"mood": mood,
|
||||
"portrait_prompt": portrait_prompt[:200],
|
||||
"summary": str(summary)[:60],
|
||||
"_source": "v2_fast_json_v4",
|
||||
}
|
||||
|
||||
# 门店类或其他
|
||||
if vtype == "store":
|
||||
store_type = fj.get("store_type") or "店铺"
|
||||
name = store_type
|
||||
brand = fj.get("brand_signage") or "无法判断"
|
||||
category = "门店场景"
|
||||
visual = fj.get("visual_elements") or []
|
||||
if isinstance(visual, str):
|
||||
visual = [visual]
|
||||
atmosphere = fj.get("atmosphere") or mood
|
||||
appearance_parts = []
|
||||
if fj.get("store_layout"):
|
||||
appearance_parts.append(str(fj["store_layout"]))
|
||||
if visual:
|
||||
appearance_parts.append("、".join(str(v) for v in visual[:3]))
|
||||
if fj.get("cleanliness"):
|
||||
appearance_parts.append(str(fj["cleanliness"]))
|
||||
appearance = ";".join(appearance_parts) if appearance_parts else "门店环境"
|
||||
kf = []
|
||||
if isinstance(visual, list):
|
||||
kf.extend(str(v) for v in visual if v and len(str(v)) <= 30)
|
||||
prods_vis = fj.get("product_categories_visible") or []
|
||||
if isinstance(prods_vis, list):
|
||||
kf.extend(str(c) for c in prods_vis[:3] if c)
|
||||
promo = fj.get("promotion_elements") or []
|
||||
if isinstance(promo, list) and promo:
|
||||
kf.append("促销活动:" + "、".join(str(p) for p in promo[:2]))
|
||||
if text_on_package:
|
||||
kf.append(f"文字: {'/'.join(text_on_package[:3])}")
|
||||
kf = kf[:6] or ["门店场景"]
|
||||
portrait_prompt = f"{brand if brand!='无法判断' else ''}{store_type},{atmosphere},{scene}场景,{('、'.join(color_names[:3])+'配色,') if color_names else ''}产品陈列丰富,门店实拍"
|
||||
portrait_prompt = portrait_prompt.strip(",")
|
||||
summary = f"{store_type}场景"
|
||||
return {
|
||||
"name": name[:30],
|
||||
"brand": str(brand)[:30],
|
||||
"category": category,
|
||||
"appearance": appearance[:200],
|
||||
"packaging": "门店场景无包装",
|
||||
"text_on_package": text_on_package,
|
||||
"key_features": kf,
|
||||
"scene": scene,
|
||||
"mood": atmosphere or mood,
|
||||
"portrait_prompt": portrait_prompt[:200],
|
||||
"summary": summary[:40],
|
||||
"_source": "v2_fast_json_v4",
|
||||
}
|
||||
|
||||
# other 兜底
|
||||
desc = fj.get("description") or "未识别"
|
||||
return {
|
||||
"name": desc[:30],
|
||||
"brand": "无法判断",
|
||||
"category": "非产品图",
|
||||
"appearance": desc[:200],
|
||||
"packaging": "无法判断",
|
||||
"text_on_package": text_on_package,
|
||||
"key_features": [desc[:30]] if desc != "未识别" else ["无法判断"],
|
||||
"scene": scene,
|
||||
"mood": mood,
|
||||
"portrait_prompt": f"{scene},{mood}氛围,{desc}"[:200],
|
||||
"summary": desc[:40],
|
||||
"_source": "v2_fast_json_v4_other",
|
||||
}
|
||||
|
||||
|
||||
def _assemble_old(idx: int, fj: dict, ocr_texts: list[str]) -> dict[str, Any]:
|
||||
"""旧扁平schema(兼容存量prompt或pro兜底输出)"""
|
||||
portrait_prompt = _build_portrait_prompt_old(fj)
|
||||
name = _infer_name_old(fj, ocr_texts)
|
||||
brand = _infer_brand_old(fj, ocr_texts)
|
||||
category = _infer_category_old(fj)
|
||||
appearance = _build_appearance_old(fj)
|
||||
key_features = _build_key_features_old(fj, ocr_texts)
|
||||
scene = fj.get("scene") or "通用"
|
||||
mood = fj.get("mood") or ""
|
||||
packaging = "无法判断"
|
||||
text_on_package = ocr_texts[:8]
|
||||
if fj.get("has_person"):
|
||||
up = fj.get("upper_wear") or "穿搭"
|
||||
style = fj.get("style") or ""
|
||||
summary = f"{style}{up}" if style and style not in up else up
|
||||
elif brand != "无法判断" and name != brand:
|
||||
summary = f"{brand} {name}"
|
||||
else:
|
||||
summary = name
|
||||
return {
|
||||
"name": name,
|
||||
"brand": brand,
|
||||
"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",
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 图片分析主路径:每图并行 OCR(火山MediaKit,未配置时自动跳过)+ qwen3.8-flash JSON VLM,
|
||||
失败时单次 qwen3.7-plus 兜底。
|
||||
|
||||
架构(灵应10-05确认):
|
||||
- 唯一后端:阿里云百炼 DashScope,qwen3.8-flash 做快速路径、qwen3.7-plus 做兜底
|
||||
- 主力:单图2路并行(OCR + fast VLM),外层N图全并发(workers=8)
|
||||
- 兜底:单次 pro VLM 调用,无竞速/重试/复杂超时
|
||||
- 输出 dict 格式与旧版完全一致,下游零改动
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any
|
||||
|
||||
from . import assembler, ocr_volc, vlm_fallback, vlm_fast_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 超时(可通过环境变量覆盖)
|
||||
_IMG_WORKERS = int(os.environ.get("VISION_V2_IMG_WORKERS", "8"))
|
||||
_FAST_TIMEOUT = float(os.environ.get("VISION_V2_FAST_TIMEOUT", "15"))
|
||||
_FAST_JSON_TIMEOUT = float(os.environ.get("VISION_V2_FAST_JSON_TIMEOUT", "15"))
|
||||
_OCR_TIMEOUT = float(os.environ.get("VISION_V2_OCR_TIMEOUT", "6"))
|
||||
_PRO_TIMEOUT = float(os.environ.get("VISION_V2_PRO_TIMEOUT", "30"))
|
||||
|
||||
_FALLBACK_RESULT = {
|
||||
"name": "未识别",
|
||||
"brand": "无法判断",
|
||||
"category": "非产品图",
|
||||
"appearance": "无法判断",
|
||||
"packaging": "无法判断",
|
||||
"text_on_package": [],
|
||||
"key_features": ["无法判断"],
|
||||
"scene": "通用",
|
||||
"mood": "",
|
||||
"portrait_prompt": "无法判断",
|
||||
"summary": "未识别",
|
||||
}
|
||||
|
||||
|
||||
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]:
|
||||
t0 = time.time()
|
||||
|
||||
fj_result: dict[str, Any] | None = None
|
||||
ocr_result: list[str] = []
|
||||
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 pp=%s",
|
||||
idx,
|
||||
fast_elapsed,
|
||||
(assembled.get("portrait_prompt") or "")[:40],
|
||||
)
|
||||
return assembled
|
||||
|
||||
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)
|
||||
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]]:
|
||||
if not img_urls:
|
||||
return []
|
||||
workers = min(_IMG_WORKERS, len(img_urls), 16)
|
||||
results: list[dict[str, Any] | None] = [None] * len(img_urls)
|
||||
|
||||
logger.info(
|
||||
"[vision.v2] 开始图片分析 n=%d workers=%d fast_timeout=%.0fs pro_timeout=%.0fs",
|
||||
len(img_urls),
|
||||
workers,
|
||||
_FAST_TIMEOUT,
|
||||
_PRO_TIMEOUT,
|
||||
)
|
||||
t0 = time.time()
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
future_to_idx = {pool.submit(analyze_image_v2, idx, url): idx for idx, url in enumerate(img_urls)}
|
||||
for fut in as_completed(future_to_idx):
|
||||
idx = future_to_idx[fut]
|
||||
try:
|
||||
results[idx] = fut.result()
|
||||
except Exception as e:
|
||||
logger.warning("[vision.v2] 图片 #%d future异常: %s", idx, e, exc_info=True)
|
||||
r = dict(_FALLBACK_RESULT)
|
||||
r["_source"] = "v2_future_exception"
|
||||
results[idx] = r
|
||||
|
||||
elapsed = time.time() - t0
|
||||
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]
|
||||
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""火山引擎 AI MediaKit OCR(同步)调用封装。
|
||||
|
||||
接口:POST {mediakit_base_url}/tools-sync/ocr
|
||||
鉴权:Bearer {mediakit_api_key}
|
||||
请求体:{"image_url": "<公网可访问URL>"} (部分版本也支持 image_base64)
|
||||
响应:{"code":0,"data":{"texts":[{"text":"...","bbox":[x,y,w,h],...},...],...}}
|
||||
|
||||
目标:识别商品包装/Logo/水印上的文字,作为 fast_json VLM 的补充。
|
||||
返回值:识别到的文本字符串列表(失败返回 [])。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TIMEOUT = 8 # OCR 秒级返回,8s 绰绰有余
|
||||
|
||||
|
||||
def call_ocr(img_url: str, *, timeout: int = DEFAULT_TIMEOUT) -> list[str]:
|
||||
"""调用 MediaKit 同步 OCR,返回去重后的纯文本列表。
|
||||
|
||||
不做重试(外层降级逻辑负责)。失败/未配置返回空列表,不抛异常。
|
||||
"""
|
||||
t0 = time.time()
|
||||
try:
|
||||
import httpx
|
||||
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
client = get_mediakit_client()
|
||||
if not client.is_available:
|
||||
logger.info("[vision.v2] mediakit 未配置,跳过 OCR")
|
||||
return []
|
||||
|
||||
url = f"{client.base_url}/tools-sync/ocr"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {client.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {"image_url": img_url}
|
||||
# 部分文档版本用 image_base64,但公网 URL 场景下 image_url 最简
|
||||
resp = httpx.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
elapsed = time.time() - t0
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"[vision.v2] OCR HTTP %d elapsed=%.1fs body=%s",
|
||||
resp.status_code,
|
||||
elapsed,
|
||||
resp.text[:200],
|
||||
)
|
||||
return []
|
||||
data = resp.json()
|
||||
# 兼容几种可能的响应结构
|
||||
code = data.get("code", data.get("status", 0))
|
||||
if code not in (0, "OK", "success", 200):
|
||||
logger.warning("[vision.v2] OCR 业务错误 code=%s elapsed=%.1fs resp=%s", code, elapsed, str(data)[:200])
|
||||
return []
|
||||
texts = _extract_texts(data)
|
||||
# 去重 + 过滤空
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for t in texts:
|
||||
t = (t or "").strip()
|
||||
if t and t not in seen and len(t) <= 100: # 过滤过长的误识别
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
logger.info("[vision.v2] OCR 完成 elapsed=%.1fs n=%d texts=%s", elapsed, len(out), out[:5])
|
||||
return out
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
logger.warning("[vision.v2] OCR 异常 elapsed=%.1fs err=%s", elapsed, e, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def _extract_texts(data: dict) -> list[str]:
|
||||
"""从 OCR 响应中抽取文本,兼容多种结构。"""
|
||||
out: list[str] = []
|
||||
# 常见结构1: data.texts = [{"text": "..."}, ...]
|
||||
d = data.get("data") or data
|
||||
if isinstance(d, dict):
|
||||
for key in ("texts", "lines", "words", "items", "result"):
|
||||
items = d.get(key)
|
||||
if isinstance(items, list):
|
||||
for it in items:
|
||||
if isinstance(it, dict):
|
||||
txt = it.get("text") or it.get("content") or it.get("word")
|
||||
if txt:
|
||||
out.append(str(txt))
|
||||
elif isinstance(it, str):
|
||||
out.append(it)
|
||||
break
|
||||
# 结构2: data.text = "..."
|
||||
if not out:
|
||||
t = d.get("text")
|
||||
if isinstance(t, str):
|
||||
out.append(t)
|
||||
# 结构3: data.ocr_text / data.content
|
||||
if not out:
|
||||
for key in ("ocr_text", "content", "raw_text"):
|
||||
v = d.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
out.append(v)
|
||||
break
|
||||
return out
|
||||
@@ -0,0 +1,126 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 兜底路径:qwen3.7-plus(阿里云百炼/DashScope)单图调用。
|
||||
|
||||
fast_json 超时/返回非 JSON/识别为空时,本路径单次调用兜底。
|
||||
设计要点:
|
||||
- 直接 httpx 直连 DashScope,不走 ai_client
|
||||
- enable_thinking=false + response_format=json_object
|
||||
- system prompt 优先读后台 viral_video_prompt_templates 配置,DB不可用时fallback到硬编码JSON schema
|
||||
- timeout=25s
|
||||
- API Key 从环境变量 DASHSCOPE_API_KEY 读取
|
||||
- 返回 dict 统一走 assembler.assemble_result 组装,与 fast 路径输出格式完全一致
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from . import _prompt, assembler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
_PRO_MODEL = "qwen3.7-plus"
|
||||
_DEFAULT_TIMEOUT = 30
|
||||
_DEFAULT_MAX_TOKENS = 800
|
||||
|
||||
|
||||
def _api_key() -> str | None:
|
||||
return os.environ.get("DASHSCOPE_API_KEY")
|
||||
|
||||
|
||||
def call_pro_vlm(
|
||||
img_url: str,
|
||||
idx: int,
|
||||
*,
|
||||
timeout: int = _DEFAULT_TIMEOUT,
|
||||
) -> dict[str, Any] | None:
|
||||
t0 = time.time()
|
||||
import httpx
|
||||
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
logger.warning("[vision.v2] pro DASHSCOPE_API_KEY 未配置,跳过")
|
||||
return None
|
||||
|
||||
system_prompt, user_prompt = _prompt.resolve_pro_prompt()
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": _PRO_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": img_url}},
|
||||
{"type": "text", "text": user_prompt},
|
||||
],
|
||||
},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": _DEFAULT_MAX_TOKENS,
|
||||
"stream": False,
|
||||
"enable_thinking": False,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
try:
|
||||
r = httpx.post(
|
||||
f"{_BASE_URL}/chat/completions",
|
||||
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.v2] 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.v2] pro 返回空 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] pro 完成 model=%s elapsed=%.1fs in=%d out=%d reasoning=%d",
|
||||
_PRO_MODEL,
|
||||
elapsed,
|
||||
usage.get("prompt_tokens", 0),
|
||||
usage.get("completion_tokens", 0),
|
||||
reasoning_tokens,
|
||||
)
|
||||
s = raw.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()
|
||||
lpos, rr = s.find("{"), s.rfind("}")
|
||||
if lpos >= 0 and rr > lpos:
|
||||
s = s[lpos : rr + 1]
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[vision.v2] pro JSON 解析失败 head=%s", raw[:200])
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
|
||||
# 通过assembler统一组装,兼容v4嵌套schema和旧扁平schema
|
||||
result = assembler.assemble_result(idx, obj, [])
|
||||
result["_source"] = "vlm_pro"
|
||||
result["_fallback_used"] = True
|
||||
return result
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
logger.warning("[vision.v2] pro 异常 elapsed=%.1fs err=%s", elapsed, e, exc_info=True)
|
||||
return None
|
||||
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V2 快速路径:qwen3.8-flash(阿里云百炼/DashScope)强约束 JSON-only 调用。
|
||||
|
||||
目标:替代"人体属性/商品检测/图像标签"三个火山不存在的专用云端 API。
|
||||
设计要点:
|
||||
- 直接用 httpx 发最小 payload 到 DashScope OpenAI 兼容 endpoint,不走 ai_client 包装
|
||||
- enable_thinking=false 关闭推理链(reasoning 是延迟主因)
|
||||
- response_format=json_object 强约束JSON输出
|
||||
- system prompt 优先读后台 viral_video_prompt_templates 配置,DB不可用时fallback到硬编码JSON schema
|
||||
- max_tokens=350、temperature=0.1(稳定输出 JSON)
|
||||
- timeout=12s(失败由外层走 pro 兜底)
|
||||
- API Key 从环境变量 DASHSCOPE_API_KEY 读取
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from . import _prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DashScope OpenAI 兼容 endpoint
|
||||
_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
_FAST_MODEL = "qwen3.8-flash"
|
||||
_DEFAULT_TIMEOUT = 15
|
||||
_DEFAULT_MAX_TOKENS = 350
|
||||
|
||||
|
||||
def _api_key() -> str | None:
|
||||
return os.environ.get("DASHSCOPE_API_KEY")
|
||||
|
||||
|
||||
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 = _DEFAULT_MAX_TOKENS,
|
||||
) -> dict[str, Any] | None:
|
||||
"""调用 qwen3.8-flash 返回结构化 dict;失败/非 JSON 返回 None。"""
|
||||
t0 = time.time()
|
||||
import httpx
|
||||
|
||||
api_key = _api_key()
|
||||
if not api_key:
|
||||
logger.warning("[vision.v2] DASHSCOPE_API_KEY 未配置,跳过 fast_json")
|
||||
return None
|
||||
|
||||
system_prompt, user_prompt = _prompt.resolve_fast_prompt()
|
||||
|
||||
url = f"{_BASE_URL}/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": _FAST_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": img_url}},
|
||||
{"type": "text", "text": user_prompt},
|
||||
],
|
||||
},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": False,
|
||||
"enable_thinking": False,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
try:
|
||||
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 == 400 and "enable_thinking" in resp.text[:300].lower():
|
||||
logger.warning("[vision.v2] fast_json HTTP 400 thinking 参数不兼容,重试 elapsed=%.1fs", elapsed)
|
||||
payload.pop("enable_thinking", None)
|
||||
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 body=%s", resp.status_code, elapsed, resp.text[:200]
|
||||
)
|
||||
return None
|
||||
data = resp.json()
|
||||
raw = (data.get("choices") or [{}])[0].get("message", {}).get("content")
|
||||
if not raw:
|
||||
logger.warning("[vision.v2] fast_json 返回空 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 in=%d out=%d reasoning=%d",
|
||||
_FAST_MODEL,
|
||||
elapsed,
|
||||
usage.get("prompt_tokens", 0),
|
||||
usage.get("completion_tokens", 0),
|
||||
reasoning_tokens,
|
||||
)
|
||||
text = _strip_code_fence(raw)
|
||||
lpos, r = text.find("{"), text.rfind("}")
|
||||
if lpos >= 0 and r > lpos:
|
||||
text = text[lpos : 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 完成 elapsed=%.1fs has_person=%s has_product=%s category=%s",
|
||||
elapsed,
|
||||
obj.get("has_person"),
|
||||
obj.get("has_product"),
|
||||
obj.get("category"),
|
||||
)
|
||||
return obj
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t0
|
||||
logger.warning("[vision.v2] fast_json 异常 elapsed=%.1fs err=%s", elapsed, e, exc_info=True)
|
||||
return None
|
||||
@@ -241,19 +241,35 @@ DOUBAO_API_KEY=${DOUBAO_API_KEY}
|
||||
|
||||
# 模型 Endpoint ID(在 ARK 控制台创建推理接入点后获得)
|
||||
DOUBAO_MODEL=${DOUBAO_MODEL}
|
||||
DOUBAO_FAST_MODEL=${DOUBAO_FAST_MODEL}
|
||||
|
||||
# API Base URL
|
||||
DOUBAO_BASE_URL=${DOUBAO_BASE_URL}
|
||||
|
||||
# 请求超时(秒)
|
||||
DOUBAO_TIMEOUT=60
|
||||
DOUBAO_TIMEOUT=${DOUBAO_TIMEOUT}
|
||||
|
||||
# 最大重试次数
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
DOUBAO_MAX_RETRIES=${DOUBAO_MAX_RETRIES}
|
||||
|
||||
# 视觉模型 Endpoint ID(支持图片/视频理解的模型)
|
||||
# 视觉模型(支持图片/视频理解的模型,model name 格式)
|
||||
DOUBAO_VISION_MODEL=${DOUBAO_VISION_MODEL}
|
||||
|
||||
# 快速视觉模型(viral-video 图片分析 lite 路径)
|
||||
DOUBAO_VISION_LITE_MODEL=${DOUBAO_VISION_LITE_MODEL}
|
||||
|
||||
# 是否启用 lite 视觉路径(true/false)
|
||||
DOUBAO_VISION_USE_LITE=${DOUBAO_VISION_USE_LITE}
|
||||
|
||||
# 信任链文生图模型(Seedream)
|
||||
DOUBAO_IMAGE_MODEL=${DOUBAO_IMAGE_MODEL}
|
||||
|
||||
# 文生图尺寸
|
||||
DOUBAO_IMAGE_SIZE=${DOUBAO_IMAGE_SIZE}
|
||||
|
||||
# 文生图超时(秒)
|
||||
DOUBAO_IMAGE_TIMEOUT=${DOUBAO_IMAGE_TIMEOUT}
|
||||
|
||||
|
||||
# ==================== 微信开放平台 OAuth(网页扫码登录)====================
|
||||
# 回调域名:xiaoxiajianji.com(微信开放平台已配置)
|
||||
|
||||
@@ -335,6 +335,10 @@ 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,
|
||||
@@ -727,6 +731,11 @@ 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)
|
||||
@@ -905,6 +914,11 @@ 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 注册表 — 反向轮询模式下用于心跳与监控."""
|
||||
@@ -928,6 +942,7 @@ class ViralVideoJobModel(Base):
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
images = Column(JSON, nullable=False, default=list) # 产品图片 URL 列表
|
||||
pre_trusted_images = Column(JSON, nullable=True) # #2172 信任链预热结果(Seedream AI 化 URL 列表)
|
||||
industry = Column(String(100), nullable=False, default="")
|
||||
target_customer = Column(String(500), nullable=False, default="")
|
||||
persona_id = Column(String(36), nullable=False, default="")
|
||||
@@ -990,16 +1005,17 @@ class ViralVideoStyleTemplateModel(Base):
|
||||
|
||||
|
||||
class ViralVideoPromptTemplateModel(Base):
|
||||
"""爆款视频 Prompt 模板表(由 #2040 seed)"""
|
||||
"""爆款视频 Prompt 模板表(#2040:纯文本 XML 标签模板,运营可直接编辑)"""
|
||||
|
||||
__tablename__ = "viral_video_prompt_templates"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
prompt_type = Column(String(50), nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
content = Column(Text, nullable=False, default="")
|
||||
variables = Column(JSON, nullable=False, default=list)
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(128), nullable=False)
|
||||
prompt_type = Column(String(32), nullable=False)
|
||||
version = Column(Integer, nullable=False, default=1)
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
system_prompt = Column(Text, nullable=False)
|
||||
user_prompt_template = Column(Text, nullable=False)
|
||||
example_output = Column(Text, nullable=True)
|
||||
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))
|
||||
|
||||
@@ -6,18 +6,33 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
ViralVideoJobModel,
|
||||
ViralVideoPromptTemplateModel,
|
||||
ViralVideoStyleTemplateModel,
|
||||
)
|
||||
from packages.domain.viral_video import ViralVideoJob, ViralVideoStatus
|
||||
|
||||
|
||||
def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
"""ORM → 领域实体。"""
|
||||
"""ORM → 领域实体。pre_trusted_images 兼容脏数据:双序列化字符串/字符数组/list[str]。"""
|
||||
import json as _pti_json
|
||||
|
||||
_raw_pti = getattr(model, "pre_trusted_images", None)
|
||||
_pti: list[str] | None = None
|
||||
if _raw_pti is not None:
|
||||
if isinstance(_raw_pti, str):
|
||||
try:
|
||||
_p = _pti_json.loads(_raw_pti)
|
||||
if isinstance(_p, list):
|
||||
_pti = [u for u in _p if isinstance(u, str) and u] or None
|
||||
except Exception:
|
||||
_pti = None
|
||||
elif isinstance(_raw_pti, list):
|
||||
_f = [u for u in _raw_pti if isinstance(u, str) and len(u) > 5]
|
||||
_pti = _f if _f else None
|
||||
return ViralVideoJob(
|
||||
id=model.id,
|
||||
user_id=model.user_id,
|
||||
images=list(model.images or []),
|
||||
pre_trusted_images=_pti,
|
||||
industry=model.industry or "",
|
||||
target_customer=model.target_customer or "",
|
||||
persona_id=model.persona_id or "",
|
||||
@@ -70,6 +85,7 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
id=job.id,
|
||||
user_id=job.user_id,
|
||||
images=job.images,
|
||||
pre_trusted_images=job.pre_trusted_images,
|
||||
industry=job.industry,
|
||||
target_customer=job.target_customer,
|
||||
persona_id=job.persona_id,
|
||||
@@ -126,6 +142,7 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
model.storyboard = job.storyboard
|
||||
model.generated_copy_text = job.generated_copy_text or ""
|
||||
model.copy_result = job.copy_result
|
||||
model.pre_trusted_images = job.pre_trusted_images
|
||||
model.result_video_url = job.result_video_url
|
||||
model.video_resolution = getattr(job, "video_resolution", "720p") or "720p"
|
||||
model.credits_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||||
@@ -227,31 +244,3 @@ class SQLAlchemyViralVideoStyleTemplateRepository:
|
||||
"style_config": dict(model.style_config) if model.style_config else {},
|
||||
"is_system": model.is_system,
|
||||
}
|
||||
|
||||
|
||||
class SQLAlchemyViralVideoPromptTemplateRepository:
|
||||
"""Prompt 模板仓储(由 #2040 seed,这里只读取)。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def get_active_by_type(self, prompt_type: str) -> dict | None:
|
||||
model = (
|
||||
self.session.query(ViralVideoPromptTemplateModel)
|
||||
.filter(
|
||||
ViralVideoPromptTemplateModel.prompt_type == prompt_type,
|
||||
ViralVideoPromptTemplateModel.is_active.is_(True),
|
||||
)
|
||||
.order_by(ViralVideoPromptTemplateModel.version.desc())
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return {
|
||||
"id": model.id,
|
||||
"prompt_type": model.prompt_type,
|
||||
"name": model.name,
|
||||
"content": model.content,
|
||||
"variables": list(model.variables or []),
|
||||
"version": model.version,
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""应用层:对外展示目录(套餐/积分包)。"""
|
||||
@@ -0,0 +1,152 @@
|
||||
"""读取管理后台配置的会员套餐 / 积分充值包(共享库真实数据)。
|
||||
|
||||
替代旧的硬编码 MEMBERSHIP_PRICES / POINTS_PACKAGES。
|
||||
短 TTL 缓存(30 秒),后台改价/启停后用户端最多 30 秒可见。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
_CACHE_TTL = 30.0
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[float, Any]] = {}
|
||||
|
||||
_QUOTA_LABELS = {
|
||||
"4k": "4K 超清分辨率",
|
||||
"batch_render": "批量渲染",
|
||||
"priority_queue": "优先处理队列",
|
||||
"ai_matting": "AI 智能抠像",
|
||||
"remove_watermark": "去水印",
|
||||
}
|
||||
|
||||
|
||||
def _cached(key: str, loader):
|
||||
now = time.time()
|
||||
hit = _cache.get(key)
|
||||
if hit and now - hit[0] < _CACHE_TTL:
|
||||
return hit[1]
|
||||
with _lock:
|
||||
hit = _cache.get(key)
|
||||
if hit and time.time() - hit[0] < _CACHE_TTL:
|
||||
return hit[1]
|
||||
value = loader()
|
||||
_cache[key] = (time.time(), value)
|
||||
return value
|
||||
|
||||
|
||||
def _quota_features(quotas: dict[str, Any] | None) -> dict[str, Any]:
|
||||
quotas = quotas or {}
|
||||
features: dict[str, Any] = {}
|
||||
for k, v in quotas.items():
|
||||
if k == "credits_per_month":
|
||||
features["credits_per_month"] = v
|
||||
elif k in _QUOTA_LABELS:
|
||||
features[_QUOTA_LABELS[k]] = v
|
||||
else:
|
||||
features[k] = v
|
||||
return features
|
||||
|
||||
|
||||
def get_membership_plans() -> list[dict[str, Any]]:
|
||||
"""读取 is_enabled=true 的套餐,按年/月周期展开为用户端档位。"""
|
||||
|
||||
def _load() -> list[dict[str, Any]]:
|
||||
from sqlalchemy import text
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||
|
||||
if SessionLocal is None:
|
||||
return []
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
rows = session.execute(text("""
|
||||
SELECT plan_key, name, description, monthly_price, yearly_price,
|
||||
quotas, display_order
|
||||
FROM plans
|
||||
WHERE is_enabled = TRUE
|
||||
ORDER BY display_order NULLS LAST, created_at
|
||||
""")).fetchall()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
plans: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
base_features = _quota_features(r.quotas if isinstance(r.quotas, dict) else None)
|
||||
if r.yearly_price and float(r.yearly_price) > 0:
|
||||
plans.append(
|
||||
{
|
||||
"plan_id": r.plan_key,
|
||||
"billing_cycle": "yearly",
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"price_cents": int(round(float(r.yearly_price) * 100)),
|
||||
"monthly_price_cents": int(round(float(r.yearly_price) * 100 / 12)),
|
||||
"duration_days": 365,
|
||||
"features": dict(base_features),
|
||||
}
|
||||
)
|
||||
if r.monthly_price and float(r.monthly_price) > 0:
|
||||
plans.append(
|
||||
{
|
||||
"plan_id": r.plan_key,
|
||||
"billing_cycle": "monthly",
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"price_cents": int(round(float(r.monthly_price) * 100)),
|
||||
"monthly_price_cents": int(round(float(r.monthly_price) * 100)),
|
||||
"duration_days": 30,
|
||||
"features": dict(base_features),
|
||||
}
|
||||
)
|
||||
return plans
|
||||
|
||||
return _cached("membership_plans", _load)
|
||||
|
||||
|
||||
def get_points_packages() -> list[dict[str, Any]]:
|
||||
"""读取 is_active=true 的积分充值包。"""
|
||||
|
||||
def _load() -> list[dict[str, Any]]:
|
||||
from sqlalchemy import text
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||
|
||||
if SessionLocal is None:
|
||||
return []
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
rows = session.execute(text("""
|
||||
SELECT package_key, name, price, credits, bonus_credits,
|
||||
is_recommended, description, sort_order
|
||||
FROM credit_packages
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY sort_order NULLS LAST, price
|
||||
""")).fetchall()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
packages: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
total_points = int(r.credits or 0) + int(r.bonus_credits or 0)
|
||||
price_cents = int(round(float(r.price) * 100))
|
||||
unit = (price_cents / 100 / total_points) if total_points else 0
|
||||
packages.append(
|
||||
{
|
||||
"code": r.package_key,
|
||||
"name": r.name,
|
||||
"points": total_points,
|
||||
"bonus_credits": int(r.bonus_credits or 0),
|
||||
"price_cents": price_cents,
|
||||
"unit_price": f"¥{unit:.3f}/积分",
|
||||
"is_recommended": bool(r.is_recommended),
|
||||
"description": r.description,
|
||||
}
|
||||
)
|
||||
return packages
|
||||
|
||||
return _cached("points_packages", _load)
|
||||
@@ -0,0 +1 @@
|
||||
"""应用层:爆款视频 Prompt 模板系统(#2040)。"""
|
||||
@@ -0,0 +1,427 @@
|
||||
"""爆款视频 5 步编排:图片分析 → 意图解析 → 文案融合 → 分镜 → 审核重写。
|
||||
|
||||
所有 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)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Prompt 模板加载器:从 viral_video_prompt_templates 读模板,30 秒 TTL 热加载。
|
||||
|
||||
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import session as _session_mod
|
||||
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
|
||||
|
||||
CACHE_TTL_SECONDS = 30.0
|
||||
|
||||
_VALID_TYPES = {"image_analysis", "intent_parsing", "copy_fusion", "storyboard", "review"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplate:
|
||||
name: str
|
||||
prompt_type: str
|
||||
version: int
|
||||
system_prompt: str
|
||||
user_prompt_template: str
|
||||
example_output: str = ""
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[float, PromptTemplate]] = {}
|
||||
|
||||
|
||||
def _fallback(prompt_type: str) -> Optional[PromptTemplate]:
|
||||
for item in DEFAULT_TEMPLATES:
|
||||
if item["prompt_type"] == prompt_type:
|
||||
return PromptTemplate(
|
||||
name=item["name"],
|
||||
prompt_type=item["prompt_type"],
|
||||
version=item["version"],
|
||||
system_prompt=item["system_prompt"],
|
||||
user_prompt_template=item["user_prompt_template"],
|
||||
example_output=item["example_output"] or "",
|
||||
is_active=bool(item["is_active"]),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
_lazy_session = None
|
||||
|
||||
|
||||
def _get_session():
|
||||
"""优先用全局 SessionLocal(worker);否则按应用配置懒建同步引擎(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://")
|
||||
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
|
||||
|
||||
_lazy_session = sessionmaker(bind=engine)
|
||||
return _lazy_session()
|
||||
|
||||
|
||||
def _load_from_db(prompt_type: str) -> Optional[PromptTemplate]:
|
||||
session = None
|
||||
try:
|
||||
session = _get_session()
|
||||
if session is None:
|
||||
return None
|
||||
sql = sa.text("""
|
||||
SELECT name, prompt_type, version, system_prompt,
|
||||
user_prompt_template, COALESCE(example_output, '') AS example_output,
|
||||
is_active
|
||||
FROM viral_video_prompt_templates
|
||||
WHERE prompt_type = :pt AND is_active = TRUE
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = session.execute(sql, {"pt": prompt_type}).first()
|
||||
if row is None:
|
||||
return None
|
||||
return PromptTemplate(
|
||||
name=row[0],
|
||||
prompt_type=row[1],
|
||||
version=int(row[2]),
|
||||
system_prompt=row[3],
|
||||
user_prompt_template=row[4],
|
||||
example_output=row[5] or "",
|
||||
is_active=bool(row[6]),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
|
||||
return None
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[PromptTemplate]:
|
||||
"""取某类型当前启用模板,30 秒缓存;DB 无数据则回落到代码默认模板。"""
|
||||
if prompt_type not in _VALID_TYPES:
|
||||
raise ValueError(f"未知 prompt_type: {prompt_type}")
|
||||
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
cached = _cache.get(prompt_type)
|
||||
if not force_refresh and cached and now - cached[0] < CACHE_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
template = _load_from_db(prompt_type) or _fallback(prompt_type)
|
||||
if template is not None:
|
||||
with _lock:
|
||||
_cache[prompt_type] = (now, template)
|
||||
return template
|
||||
|
||||
|
||||
def invalidate() -> None:
|
||||
"""清空缓存(测试用)。"""
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def _safe_format(text: str, kwargs: dict) -> str:
|
||||
try:
|
||||
return text.format_map(_SafeDict(kwargs))
|
||||
except Exception: # noqa: BLE001
|
||||
return text
|
||||
|
||||
|
||||
def render_user_prompt(template: PromptTemplate, **kwargs) -> str:
|
||||
"""填充 user_prompt_template 占位符,缺键原样保留不报错。"""
|
||||
return _safe_format(template.user_prompt_template, kwargs)
|
||||
|
||||
|
||||
def render_system_prompt(template: PromptTemplate, **kwargs) -> str:
|
||||
"""copy_fusion 等 system_prompt 含运行时变量时填充。"""
|
||||
return _safe_format(template.system_prompt, kwargs)
|
||||
@@ -0,0 +1,338 @@
|
||||
"""爆款视频 5 套 Prompt 模板默认值(#2040 核心资产)。
|
||||
|
||||
重要约定(用户明确要求):
|
||||
- 所有 system_prompt / user_prompt_template / example_output 都是**纯文本自然语言 + XML 标签**,
|
||||
运营可直接看懂和编辑,禁止 JSON、禁止 ```json 代码块。
|
||||
- LLM 按 XML 标签输出字段,程序用正则解析(见 xml_parser.py)。
|
||||
- user_prompt_template 中花括号占位符(如 {user_copy_text})在运行时填充。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
TEMPLATE_VERSION = 1
|
||||
|
||||
# 所有文案类 Prompt 自动注入的硬约束
|
||||
GLOBAL_CONSTRAINTS = """【必须遵守的硬约束】
|
||||
1. 不编造时间:不写“今年最新”“2024 爆款”等会过时的时间表述。
|
||||
2. 不承诺效果:不写“保证”“一定”“100%有效”“包治百病”等绝对化用语。
|
||||
3. 不编造价格、销量、认证、奖项:除非用户在文案中明确给出,否则一律不写。
|
||||
4. 符合广告法及平台社区规范。
|
||||
5. 只描述图片中真实可见的内容,看不到的不瞎猜。"""
|
||||
|
||||
# 反套路化要求
|
||||
NEGATIVE_RULES = """【反套路化要求】
|
||||
禁止使用“家人们谁懂啊”“绝绝子”“宝子们”“家人们”“太绝了”“yyds”等烂大街网络词;
|
||||
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。"""
|
||||
|
||||
# 输出禁用套路词(测试会检查)
|
||||
BANNED_PHRASES = ["家人们谁懂啊", "绝绝子", "宝子们", "yyds", "太绝了"]
|
||||
|
||||
# 文案融合三档独立指令段
|
||||
FUSION_INSTRUCTIONS = {
|
||||
"ai_full": """【本次创作模式:AI 全权创作】
|
||||
你是资深短视频编导。用户只提供了产品图片,没有给出具体文案方向。请根据图片内容和营销参数,自由发挥创作完整的爆款短视频文案。充分挖掘产品真实可见的卖点,使用爆款结构,抓人眼球。""",
|
||||
"ai_polish": """【本次创作模式:AI 辅助润色】
|
||||
你是用户的文案助理。用户已经写了草稿/关键词/碎碎念,表达了他想讲的核心意思,但表达不完整、不够吸引人。你的任务是:以用户的意思为主,保留他想表达的所有核心信息点,在此基础上润色扩写、调整语序、增加衔接、优化表达,让文案更流畅更有吸引力。绝对不能改变用户想表达的核心意思,不能把用户的观点换成相反的,不能添加用户没提到的产品卖点。用户提到的品牌名、价格、人名、具体事实必须原样保留。""",
|
||||
"user_primary": """【本次创作模式:以用户原文为主】
|
||||
你是文案润色助手。用户已经写好了明确的文案,这是他最终想表达的内容。你的任务是最小化修改:只做必要的错别字修正、标点调整、语句通顺度优化,以及添加必要的衔接词让口播更自然。用户的核心句子、关键表述、事实信息一律不改。如果用户文案本身已经很好,直接返回,不要为了改而改。personal_brands 中的事实信息必须逐字保留。""",
|
||||
}
|
||||
|
||||
# ── 模板1:图片多模态分析(VLM)────────────────────────────────────────
|
||||
_IMAGE_ANALYSIS_SYSTEM = f"""你是电商商品视觉分析师,负责从商品图片中提取真实可见的商品信息。
|
||||
|
||||
工作方式(分步骤看,不要跳步):
|
||||
1. 先看整体:有哪些产品、什么场景、有没有人物。
|
||||
2. 再看细节:包装文字、颜色构成、人物状态、画面质感。
|
||||
3. 最后提炼卖点:只总结图片里能看到的卖点。
|
||||
|
||||
{GLOBAL_CONSTRAINTS}
|
||||
|
||||
请严格按下面的标签格式输出,标签名一个都不能改,不要输出任何解释,不要用代码块:
|
||||
<products> 下面每个产品用一个 <product> 标签,属性 name 是产品名、features 是外观特征、position 是 main 或 secondary、image_index 是第几张图(从0开始)。
|
||||
<colors> 下面每个主要颜色用一个 <color> 标签,属性 hex 是色值、name 是颜色名、coverage 是占比小数。
|
||||
<people> 用一个标签,属性 has_person、count、gender、age_range、hair(发型发色)、skin_tone(肤色)、face_shape(脸型)、outfit(穿着)、pose(姿态)、expression(表情)分别描述人物外貌。有人物时属性尽量具体(如hair="黑色长直发"、outfit="白色衬衫"),无人像时除has_person=false外其他填"无法判断"。
|
||||
<mood> 标签写画面整体情绪氛围。
|
||||
<visible_text> 下面每处可见文字用一个 <text_item> 标签,属性 text 是文字内容、position 是位置。
|
||||
<scene> 标签写场景描述。
|
||||
<quality> 用一个标签,属性 resolution、lighting、composition、blur 描述画质。
|
||||
<key_selling_points> 下面每个卖点用一个 <point> 标签。
|
||||
|
||||
【人物属性硬性要求(has_person=true时必须遵守)】
|
||||
hair/skin_tone/face_shape/outfit四项绝对禁止填“无法判断”,必须基于图片可见特征给出具体中文描述:
|
||||
- hair:必须描述发型+发色,如“黑色齐肩直发”“棕色微卷中长发”“深棕色短发”
|
||||
- skin_tone:必须描述肤色,如“暖调自然肤色”“白皙肤色”“小麦色”
|
||||
- face_shape:必须描述脸型,如“鹅蛋脸”“圆脸”“瓜子脸”“方脸”
|
||||
- outfit:必须描述可见穿着,如“米色翻领衬衫”“白色T恤”“黑色连衣裙”
|
||||
即使局部被遮挡也要根据可见部分合理推断;确实看不清时按最接近的直观印象描述。
|
||||
|
||||
其他非人物属性看不到或无法判断时填“无法判断”,布尔值填false,不要留空标签。
|
||||
|
||||
【有人物场景输出参考(女性手持商品示例,必须写全10个属性,禁止省略)】
|
||||
<people has_person="true" count="1" gender="女" age_range="青年" hair="黑色齐肩直发" skin_tone="暖调自然肤色" face_shape="鹅蛋脸" outfit="米色翻领衬衫" pose="正面半身,手持商品" expression="面带微笑"/>"""
|
||||
|
||||
_IMAGE_ANALYSIS_USER = """请分析以下商品图片,共 {image_count} 张。
|
||||
所属行业:{industry}
|
||||
图片地址:
|
||||
{image_urls}
|
||||
|
||||
按约定的标签格式输出分析结果。"""
|
||||
|
||||
_IMAGE_ANALYSIS_EXAMPLE = """<products>
|
||||
<product name="大公鸡头 多功能油污净 625ml" features="红色瓶盖白色瓶身,鸡头图案Logo" position="main" image_index="0"/>
|
||||
</products>
|
||||
<colors>
|
||||
<color hex="#D32F2F" name="红色" coverage="0.4"/>
|
||||
<color hex="#FFFFFF" name="白色" coverage="0.5"/>
|
||||
</colors>
|
||||
<people has_person="false" count="0" gender="无法判断" age_range="无法判断" hair="无法判断" skin_tone="无法判断" face_shape="无法判断" outfit="无法判断" pose="无法判断" expression="无法判断"/>
|
||||
<mood>干净、实用</mood>
|
||||
<visible_text>
|
||||
<text_item text="多功能油污净" position="瓶身正面"/>
|
||||
</visible_text>
|
||||
<scene>白底棚拍产品图</scene>
|
||||
<quality resolution="高清" lighting="均匀柔和" composition="主体居中" blur="false"/>
|
||||
<key_selling_points>
|
||||
<point>针对重油污设计</point>
|
||||
<point>大容量625ml</point>
|
||||
</key_selling_points>"""
|
||||
|
||||
# ── 模板2:用户文案意图解析(LLM)──────────────────────────────────────
|
||||
_INTENT_SYSTEM = f"""你负责理解用户的营销意图。用户给的文案可能只是几个关键词、碎碎念或者不完整的短句,你要读懂他真正想讲什么。
|
||||
|
||||
{GLOBAL_CONSTRAINTS}
|
||||
|
||||
请严格按下面的标签格式输出,不要解释,不要用代码块:
|
||||
<intent_summary> 用用户的语言风格,一句话、30字以内概括核心意图。
|
||||
<core_messages> 下面每个核心信息点用一个 <message> 标签,属性 must_keep 为 true 或 false、confidence 为 0 到 1 的小数,标签内容写信息点。
|
||||
<personal_brands> 把用户提到的具体事实——品牌名、价格、人名、地名、时间、产品名——每条用一个 <brand> 标签,属性 category 取 brand、price、person、place、time、product 之一。这些事实必须原样引用,一个字都不能改。
|
||||
<emotion_tone> 写文案的情绪调性。
|
||||
<missing_info> 把你认为缺失、后续生成时需要合理推断的信息,每条用一个 <info> 标签;没有就输出空标签。"""
|
||||
|
||||
_INTENT_USER = """用户原始文案:{user_copy_text}
|
||||
所属行业:{industry}
|
||||
图片分析结果(供参考):
|
||||
{image_analysis}
|
||||
|
||||
请理解用户意图,按标签格式输出。"""
|
||||
|
||||
_INTENT_EXAMPLE = """<intent_summary>一款厨房去油污神器,喷一喷油污就掉</intent_summary>
|
||||
<core_messages>
|
||||
<message must_keep="true" confidence="0.97">去油污效果好,喷上等几分钟再擦</message>
|
||||
<message must_keep="false" confidence="0.7">适合厨房重油污场景</message>
|
||||
</core_messages>
|
||||
<personal_brands>
|
||||
<brand category="product">大公鸡头多功能油污净</brand>
|
||||
<brand category="price">39块钱一瓶</brand>
|
||||
</personal_brands>
|
||||
<emotion_tone>亲切、真实、带分享感</emotion_tone>
|
||||
<missing_info>
|
||||
<info>没有说明具体容量,按图片读出的625ml处理</info>
|
||||
</missing_info>"""
|
||||
|
||||
# ── 模板3:文案融合生成(LLM)──────────────────────────────────────────
|
||||
_FUSION_SYSTEM = """你负责为短视频生成营销文案。请按思维链分步完成:先定人设和目标客户,再找卖点,再搭结构,再安排情绪,最后写行动号召,不要一步到位乱写。
|
||||
|
||||
{fusion_instruction}
|
||||
|
||||
{global_constraints}
|
||||
|
||||
{negative_rules}
|
||||
|
||||
请严格按下面的标签格式输出,不要解释,不要用代码块:
|
||||
<title> 视频标题。
|
||||
<hook> 开头3秒钩子,5到15字。
|
||||
<body_points> 每个要点用一个 <point> 标签,属性 elaboration 是展开说明、image_index 是对应第几张图(从0开始),标签内容写要点。
|
||||
<cta> 口语化的行动号召。
|
||||
<script_segments> 每段配音用一个 <segment> 标签,属性 duration_sec 是秒数、image_index 是对应图片,标签内容写配音文案(纯口播文本,不加旁白标注、不加镜头标注、不加"主播:"之类前缀)。
|
||||
<voiceover_script> 把所有 segment 的配音文案按顺序自然拼接成一段完整的纯口播文本(无标记、无括号、无前缀),长度要适配 {duration} 秒,约 {approx_chars} 字。
|
||||
<overview_theme> 视频主题(一句话概括)。
|
||||
<scene_and_lighting> 整体场景描述+光线设定(100-200字,要具体:在哪拍、什么光线、什么色调、什么氛围)。
|
||||
<word_count> 配音总字数,只写数字。
|
||||
<estimated_duration> 预计时长秒数,只写数字。
|
||||
|
||||
用户在 personal_brands 中提到的品牌名、价格、人名、地名、时间、产品名等事实信息,必须原样出现在文案里,一个字都不能改。"""
|
||||
|
||||
_FUSION_USER = """所属行业:{industry}
|
||||
目标客户:{target_customer}
|
||||
营销目的:{marketing_purpose}
|
||||
视频时长:{duration}秒
|
||||
图片分析结果:
|
||||
{image_analysis}
|
||||
用户意图解析结果:
|
||||
{intent_result}
|
||||
|
||||
请按标签格式生成文案。"""
|
||||
|
||||
_FUSION_EXAMPLE = """<title>厨房重油污,别再用洗洁精硬擦了</title>
|
||||
<hook>这油污,我真的忍很久了</hook>
|
||||
<body_points>
|
||||
<point elaboration="喷在油污上等几分钟,一擦就干净" image_index="0">大公鸡头油污净去油快</point>
|
||||
<point elaboration="39块钱625ml,能用很久" image_index="0">39块钱一瓶,性价比高</point>
|
||||
</body_points>
|
||||
<cta>厨房油污重的,真的可以试一瓶</cta>
|
||||
<script_segments>
|
||||
<segment duration_sec="3" image_index="0">这油污我真的忍很久了,用洗洁精擦半天都没用</segment>
|
||||
<segment duration_sec="6" image_index="0">后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净</segment>
|
||||
<segment duration_sec="4" image_index="0">39块钱625ml,厨房重油污的可以试一瓶</segment>
|
||||
</script_segments>
|
||||
<voiceover_script>这油污我真的忍很久了,用洗洁精擦半天都没用。后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净。39块钱625ml,厨房重油污的可以试一瓶。</voiceover_script>
|
||||
<overview_theme>厨房油污清洁好物分享</overview_theme>
|
||||
<scene_and_lighting>简洁明亮的厨房台面场景,自然光从窗户洒入,色调温暖柔和,突出产品白色瓶身与去油污对比效果。</scene_and_lighting>
|
||||
<word_count>58</word_count>
|
||||
<estimated_duration>13</estimated_duration>"""
|
||||
|
||||
# ── 模板4:编导级分镜(LLM)────────────────────────────────────────────
|
||||
_STORYBOARD_SYSTEM = """你是短视频编导,负责把文案拆成可拍摄的分镜,为 Seedance 2.5 视频模型写编导分镜脚本。脚本将整体作为 prompt 一次性传给视频模型,必须让模型在连贯镜头流中清楚每段时间拍什么、画面如何、人物说什么。
|
||||
|
||||
工作方式:
|
||||
1. 按文案的 script_segments 顺序分配镜头。
|
||||
2. 每个镜头确定景别/角度/运镜、画面场景与对白、人物动作细节、音效/BGM、转场。
|
||||
3. 检查所有镜头时长加起来接近目标时长,误差不超过2秒。
|
||||
4. image_index 必须在已上传图片范围内,第一张主图必须用在第一个镜头。
|
||||
|
||||
{fusion_instruction}
|
||||
|
||||
{global_constraints}
|
||||
|
||||
{negative_rules}
|
||||
|
||||
请严格按下面的标签格式输出,不要解释,不要用代码块:
|
||||
<clips> 下面每个镜头用一个 <clip> 标签,属性 image_index 是图片序号(从0开始)、transition 取 fade/cut/zoom_in/slide_left/dissolve/wipe 之一、zoom 取 in/out/null、duration_sec 是该镜头秒数、bgm_note 是该段BGM情绪。每个 <clip> 里面包含:
|
||||
<voice_text> 该镜头配音文本(纯口播文本,不加旁白标注);
|
||||
<subtitle_text> 字幕文本,可与配音一致或更精简;
|
||||
<shot_type_angle_movement> 景别+角度+运镜(例:近景俯拍45度,缓慢推镜;中景平视,固定镜头;特写平视,快速拉镜);
|
||||
<scene_and_dialogue> 画面场景描述 + 人物口播台词(对白要自然口语化,像朋友聊天,不要硬广推销腔);
|
||||
<action_details> 人物动作、表情、物品操作细节(手怎么动、表情变化、产品怎么展示);
|
||||
<audio_bgm> 环境音+BGM提示(例:轻快流行BGM,环境嘈杂咖啡店背景音);
|
||||
<transition> 硬切/淡入淡出/叠化(最后一镜写『结束』即可);
|
||||
<reference_image_index> 参考图片索引(0-based,对应第几张产品图,无则空);
|
||||
<ken_burns> 用一个空标签,属性 start、end 写"x,y"坐标、ease 写缓动方式;不需要运镜时坐标相同。"""
|
||||
|
||||
_STORYBOARD_USER = """目标时长:{duration}秒
|
||||
上传图片数量:{image_count}张(第1张是主图/封面)
|
||||
文案内容:
|
||||
{fusion_result}
|
||||
图片分析结果:
|
||||
{image_analysis}
|
||||
|
||||
请按标签格式输出分镜。"""
|
||||
|
||||
_STORYBOARD_EXAMPLE = """<clips>
|
||||
<clip image_index="0" transition="cut" zoom="null" duration_sec="3" bgm_note="日常、轻微烦躁">
|
||||
<voice_text>这油污我真的忍很久了</voice_text>
|
||||
<subtitle_text>这油污忍很久了</subtitle_text>
|
||||
<shot_type_angle_movement>近景俯拍45度,缓慢推镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>厨房台面,主妇皱眉看着灶台油污。对白:这油污我真的忍很久了</scene_and_dialogue>
|
||||
<action_details>右手拿着脏抹布,无奈摇头</action_details>
|
||||
<audio_bgm>轻快日常BGM,带一点烦躁感</audio_bgm>
|
||||
<transition>硬切</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
<clip image_index="0" transition="zoom_in" zoom="in" duration_sec="6" bgm_note="轻快、出现转机">
|
||||
<voice_text>后来换了大公鸡头油污净,喷上等几分钟,一擦就干净</voice_text>
|
||||
<subtitle_text>喷上等几分钟,一擦就干净</subtitle_text>
|
||||
<shot_type_angle_movement>特写平视,固定镜头</shot_type_angle_movement>
|
||||
<scene_and_dialogue>手部特写,喷油污净在油污处。对白:后来换了这个大公鸡头油污净,喷上等几分钟,一擦就干净</scene_and_dialogue>
|
||||
<action_details>左手拿产品瓶身,右手按压喷头,等待片刻后用抹布轻擦</action_details>
|
||||
<audio_bgm>轻快转折BGM,带清爽感</audio_bgm>
|
||||
<transition>淡入淡出</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="20,20" end="80,80" ease="ease-in-out"/>
|
||||
</clip>
|
||||
<clip image_index="0" transition="fade" zoom="null" duration_sec="4" bgm_note="温暖、推荐">
|
||||
<voice_text>39块钱625ml,厨房重油污的可以试一瓶</voice_text>
|
||||
<subtitle_text>39元625ml,可以试一瓶</subtitle_text>
|
||||
<shot_type_angle_movement>中景平视,缓慢拉镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>产品正面展示,明亮背景。对白:39块钱625ml,厨房重油污的可以试一瓶</scene_and_dialogue>
|
||||
<action_details>产品置于画面中央,轻微转动展示瓶身</action_details>
|
||||
<audio_bgm>温暖收尾BGM</audio_bgm>
|
||||
<transition>结束</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="50,50" end="20,20" ease="ease-in-out"/>
|
||||
</clip>
|
||||
</clips>"""
|
||||
|
||||
# ── 模板5:文案审核(LLM)──────────────────────────────────────────────
|
||||
_REVIEW_SYSTEM = f"""你是短视频文案合规审核员,从6个维度逐条检查文案:
|
||||
1. 违规词:有没有平台禁用词、敏感词。
|
||||
2. 夸大承诺:有没有“包治百病”“100%有效”“保证赚钱”等绝对化、夸大表述。
|
||||
3. 事实一致性:有没有编造价格、数据、认证,或者用户没提到的产品特性。
|
||||
4. 用户意图保留:在 ai_polish 和 user_primary 模式下,core_messages 中 must_keep=true 的点是否都保留了。
|
||||
5. 结构完整性:标题、钩子、正文、行动号召是否齐全。
|
||||
6. 语气人设:是否符合选定的人设语气,有没有“家人们谁懂啊”“绝绝子”“宝子们”等套路词。
|
||||
|
||||
{GLOBAL_CONSTRAINTS}
|
||||
|
||||
请严格按下面的标签格式输出,不要解释,不要用代码块:
|
||||
<passed> 整体是否通过,只写 true 或 false。
|
||||
<issues> 每个问题用一个 <issue> 标签,属性 dimension 是维度名、severity 取 error 或 warning、location 是问题所在(如 hook、body_points、cta),标签内容写问题描述;没有问题就输出空标签。
|
||||
<rewrite_suggestions> 每条具体修改建议用一个 <suggestion> 标签;没有就输出空标签。"""
|
||||
|
||||
_REVIEW_USER = """本次创作模式:{fusion_level}
|
||||
待审核文案:
|
||||
{fusion_result}
|
||||
用户意图解析(用于核对核心信息是否保留):
|
||||
{intent_result}
|
||||
|
||||
请按6个维度审核,按标签格式输出。"""
|
||||
|
||||
_REVIEW_EXAMPLE = """<passed>false</passed>
|
||||
<issues>
|
||||
<issue dimension="夸大承诺" severity="error" location="body_points">出现了“一喷100%掉光”的绝对化表述,违反广告法</issue>
|
||||
<issue dimension="用户意图保留" severity="warning" location="cta">用户强调的“39块钱”没有保留</issue>
|
||||
</issues>
|
||||
<rewrite_suggestions>
|
||||
<suggestion>把“一喷100%掉光”改为“喷上等几分钟,大部分油污能擦掉”</suggestion>
|
||||
<suggestion>在结尾补回“39块钱625ml”</suggestion>
|
||||
</rewrite_suggestions>"""
|
||||
|
||||
|
||||
# 5 套模板默认数据(seed 数据源与 loader 的兜底)
|
||||
DEFAULT_TEMPLATES: list[dict] = [
|
||||
{
|
||||
"name": "图片多模态分析",
|
||||
"prompt_type": "image_analysis",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"system_prompt": _IMAGE_ANALYSIS_SYSTEM,
|
||||
"user_prompt_template": _IMAGE_ANALYSIS_USER,
|
||||
"example_output": _IMAGE_ANALYSIS_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "用户文案意图解析",
|
||||
"prompt_type": "intent_parsing",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"system_prompt": _INTENT_SYSTEM,
|
||||
"user_prompt_template": _INTENT_USER,
|
||||
"example_output": _INTENT_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "文案融合生成",
|
||||
"prompt_type": "copy_fusion",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"system_prompt": _FUSION_SYSTEM,
|
||||
"user_prompt_template": _FUSION_USER,
|
||||
"example_output": _FUSION_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "编导级分镜",
|
||||
"prompt_type": "storyboard",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"system_prompt": _STORYBOARD_SYSTEM,
|
||||
"user_prompt_template": _STORYBOARD_USER,
|
||||
"example_output": _STORYBOARD_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "文案审核",
|
||||
"prompt_type": "review",
|
||||
"version": TEMPLATE_VERSION,
|
||||
"system_prompt": _REVIEW_SYSTEM,
|
||||
"user_prompt_template": _REVIEW_USER,
|
||||
"example_output": _REVIEW_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,312 @@
|
||||
"""文案审核 + 自动重写(#2040 第5套 Prompt)。
|
||||
|
||||
6 维度:违规词 / 夸大承诺 / 事实一致性 / 用户意图保留 / 结构完整性 / 语气人设。
|
||||
LLM 审核之外叠加本地规则预检(保证即使 LLM 不可用也能兜住广告法红线)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from packages.application.viral_video import xml_parser as xp
|
||||
from packages.application.viral_video.prompt_loader import (
|
||||
get_template,
|
||||
render_system_prompt,
|
||||
render_user_prompt,
|
||||
)
|
||||
from packages.application.viral_video.schemas import (
|
||||
FusionResult,
|
||||
IntentResult,
|
||||
ReviewIssue,
|
||||
ReviewResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 本地规则:绝对化/夸大词
|
||||
_EXAGGERATION_PATTERNS = [
|
||||
r"100\s*%",
|
||||
r"百分百",
|
||||
r"包治百病",
|
||||
r"保证.{0,8}(有效|赚钱|瘦|好)",
|
||||
r"绝对(有效|安全|靠谱)",
|
||||
r"全网第一",
|
||||
r"国家级",
|
||||
r"特效",
|
||||
r"立刻见效",
|
||||
r"一喷(就|全|100)",
|
||||
]
|
||||
|
||||
# 本地规则:平台违规/套路词
|
||||
_VIOLATION_PHRASES = [
|
||||
"家人们谁懂啊",
|
||||
"绝绝子",
|
||||
"宝子们",
|
||||
"yyds",
|
||||
"最(好|强|牛|便宜)", # 广告法极限词
|
||||
"第一(名|品牌)?",
|
||||
]
|
||||
|
||||
_LOCATIONS = ["title", "hook", "body_points", "cta", "script_segments"]
|
||||
|
||||
|
||||
class Reviewer:
|
||||
def __init__(self, client=None):
|
||||
if client is None:
|
||||
from packages.shared.ai_client import get_doubao_client
|
||||
|
||||
client = get_doubao_client()
|
||||
self.client = client
|
||||
|
||||
# ── 审核 ────────────────────────────────────────────────────────────
|
||||
def review(self, fusion: FusionResult, intent: IntentResult, fusion_level: str) -> ReviewResult:
|
||||
local = self._rule_check(fusion, intent, fusion_level)
|
||||
llm_result = self._llm_review(fusion, intent, fusion_level)
|
||||
if llm_result is None:
|
||||
return ReviewResult(
|
||||
passed=not local,
|
||||
issues=local,
|
||||
rewrite_suggestions=[],
|
||||
raw="",
|
||||
)
|
||||
# LLM 与本地规则合并去重
|
||||
issues = self._merge_issues(llm_result.issues, local)
|
||||
return ReviewResult(
|
||||
passed=llm_result.passed and not local,
|
||||
issues=issues,
|
||||
rewrite_suggestions=llm_result.rewrite_suggestions,
|
||||
raw=llm_result.raw,
|
||||
)
|
||||
|
||||
def _llm_review(self, fusion: FusionResult, intent: IntentResult, fusion_level: str) -> Optional[ReviewResult]:
|
||||
template = get_template("review")
|
||||
system = render_system_prompt(template)
|
||||
user = render_user_prompt(
|
||||
template,
|
||||
fusion_level=fusion_level,
|
||||
fusion_result=self._fusion_text(fusion),
|
||||
intent_result=self._intent_text(intent),
|
||||
)
|
||||
raw = self.client.chat_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
temperature=0.2,
|
||||
max_tokens=1024,
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
passed = xp.text_of(raw, "passed").strip().lower()
|
||||
issues = [
|
||||
ReviewIssue(
|
||||
dimension=n["attrs"].get("dimension", "未知维度"),
|
||||
severity=n["attrs"].get("severity", "warning"),
|
||||
location=n["attrs"].get("location", ""),
|
||||
text=n["text"],
|
||||
)
|
||||
for n in xp.find_all(raw, "issue")
|
||||
if n["text"]
|
||||
]
|
||||
suggestions = [n["text"] for n in xp.find_all(raw, "suggestion") if n["text"]]
|
||||
parsed = ReviewResult(
|
||||
passed=passed == "true" and not issues,
|
||||
issues=issues,
|
||||
rewrite_suggestions=suggestions,
|
||||
raw=raw,
|
||||
)
|
||||
return parsed
|
||||
|
||||
# ── 本地规则预检 ────────────────────────────────────────────────────
|
||||
def _rule_check(self, fusion: FusionResult, intent, fusion_level: str) -> list[ReviewIssue]:
|
||||
issues: list[ReviewIssue] = []
|
||||
for location, text in self._segments(fusion):
|
||||
for pattern in _EXAGGERATION_PATTERNS:
|
||||
if re.search(pattern, text):
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="夸大承诺",
|
||||
severity="error",
|
||||
location=location,
|
||||
text=f"出现夸大/绝对化表述:{self._hit(text, pattern)}",
|
||||
)
|
||||
)
|
||||
for phrase in _VIOLATION_PHRASES:
|
||||
if re.search(phrase, text, flags=re.IGNORECASE):
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="违规词",
|
||||
severity="error",
|
||||
location=location,
|
||||
text=f"出现违规或套路词:{self._hit(text, phrase)}",
|
||||
)
|
||||
)
|
||||
|
||||
# 结构完整性
|
||||
if not fusion.title:
|
||||
issues.append(ReviewIssue(dimension="结构完整性", severity="warning", location="title", text="缺少标题"))
|
||||
if not fusion.hook:
|
||||
issues.append(ReviewIssue(dimension="结构完整性", severity="warning", location="hook", text="缺少开头钩子"))
|
||||
if not fusion.cta:
|
||||
issues.append(ReviewIssue(dimension="结构完整性", severity="warning", location="cta", text="缺少行动号召"))
|
||||
|
||||
# 用户意图保留(must_keep)
|
||||
full_text = self._fusion_text(fusion)
|
||||
if fusion_level in {"ai_polish", "user_primary"} and intent is not None:
|
||||
for message in intent.core_messages:
|
||||
if message.must_keep:
|
||||
key = self._compact(message.text)
|
||||
if key and key[:10] not in self._compact(full_text):
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="用户意图保留",
|
||||
severity="warning",
|
||||
location="script_segments",
|
||||
text=f"用户核心信息被丢失:{message.text[:30]}",
|
||||
)
|
||||
)
|
||||
for brand in intent.personal_brands:
|
||||
if brand.text and brand.text not in full_text:
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="事实一致性",
|
||||
severity="error",
|
||||
location="script_segments",
|
||||
text=f"personal_brands 事实信息未原样保留:{brand.text[:30]}",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
@staticmethod
|
||||
def _hit(text: str, pattern: str) -> str:
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE)
|
||||
return match.group(0) if match else pattern
|
||||
|
||||
@staticmethod
|
||||
def _compact(text: str) -> str:
|
||||
return re.sub(r"[\s,。!?、,.!?;;::\"'“”‘’()()【】\[\]]", "", text)
|
||||
|
||||
@staticmethod
|
||||
def _merge_issues(llm_issues: list[ReviewIssue], local: list[ReviewIssue]) -> list[ReviewIssue]:
|
||||
merged = list(local)
|
||||
seen = {(i.dimension, Reviewer._compact(i.text)[:20]) for i in local}
|
||||
for issue in llm_issues:
|
||||
key = (issue.dimension, Reviewer._compact(issue.text)[:20])
|
||||
if key not in seen:
|
||||
merged.append(issue)
|
||||
seen.add(key)
|
||||
return merged
|
||||
|
||||
# ── 自动重写(1 次)─────────────────────────────────────────────────
|
||||
def rewrite(
|
||||
self,
|
||||
fusion: FusionResult,
|
||||
review: ReviewResult,
|
||||
intent: IntentResult,
|
||||
fusion_level: str,
|
||||
) -> FusionResult:
|
||||
from packages.application.viral_video.generator import CopyGenerator
|
||||
|
||||
template = get_template("copy_fusion")
|
||||
system_kwargs = {
|
||||
"fusion_instruction": (
|
||||
"【本次任务:按审核意见修正文案】只修改指出的问题,其他内容尽量原样保留;"
|
||||
"personal_brands 事实信息逐字保留;修正后按原标签格式完整输出。"
|
||||
),
|
||||
"global_constraints": "",
|
||||
"negative_rules": "",
|
||||
}
|
||||
issue_text = "\n".join(f"- [{i.dimension}/{i.location}] {i.text}" for i in review.issues)
|
||||
suggestion_text = "\n".join(f"- {s}" for s in review.rewrite_suggestions)
|
||||
user = render_user_prompt(
|
||||
template,
|
||||
industry="",
|
||||
target_customer="",
|
||||
marketing_purpose="",
|
||||
duration=fusion.estimated_duration or 15,
|
||||
image_analysis="(沿用原图片分析)",
|
||||
intent_result=self._intent_text(intent),
|
||||
)
|
||||
user = (
|
||||
f"{user}\n\n原文案:\n{self._fusion_text(fusion)}\n\n"
|
||||
f"审核发现的问题:\n{issue_text}\n\n修改建议:\n{suggestion_text or '(无)'}\n"
|
||||
"请输出修正后的完整文案。"
|
||||
)
|
||||
system = render_system_prompt(template, **system_kwargs)
|
||||
raw = self.client.chat_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=2048,
|
||||
)
|
||||
if not raw:
|
||||
return self._rule_fix(fusion, review)
|
||||
rewritten = CopyGenerator._parse_fusion(CopyGenerator(self.client), raw)
|
||||
if not rewritten.title and not rewritten.script_segments:
|
||||
return self._rule_fix(fusion, review)
|
||||
# 保底:personal_brands 必须保留
|
||||
full = self._fusion_text(rewritten)
|
||||
for brand in intent.personal_brands:
|
||||
if brand.text and brand.text not in full:
|
||||
rewritten.cta = (rewritten.cta + brand.text).strip()
|
||||
return rewritten
|
||||
|
||||
def _rule_fix(self, fusion: FusionResult, review: ReviewResult) -> FusionResult:
|
||||
"""LLM 重写不可用时的本地兜底:删除/替换明显违规表述。"""
|
||||
replacements = [
|
||||
(re.compile(r"100\s*%|百分百"), "大部分"),
|
||||
(re.compile(r"绝对(有效|安全|靠谱)"), "比较\\1"),
|
||||
(re.compile(r"包治百病"), "适用多种情况"),
|
||||
(re.compile(r"立刻见效"), "坚持使用会有改善"),
|
||||
(re.compile(r"一喷(就|全|100%)"), "喷上等一会儿可以"),
|
||||
(re.compile(r"家人们谁懂啊|绝绝子|宝子们|yyds", re.IGNORECASE), ""),
|
||||
(re.compile(r"最好|最强|最牛|最便宜"), "很不错"),
|
||||
]
|
||||
|
||||
def fix(text: str) -> str:
|
||||
for pattern, repl in replacements:
|
||||
text = pattern.sub(repl, text)
|
||||
return text
|
||||
|
||||
fusion.title = fix(fusion.title)
|
||||
fusion.hook = fix(fusion.hook)
|
||||
fusion.cta = fix(fusion.cta)
|
||||
for point in fusion.body_points:
|
||||
point.text = fix(point.text)
|
||||
point.elaboration = fix(point.elaboration)
|
||||
for segment in fusion.script_segments:
|
||||
segment.text = fix(segment.text)
|
||||
fusion.raw = ""
|
||||
return fusion
|
||||
|
||||
# ── 文本工具 ────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _segments(fusion: FusionResult):
|
||||
yield "title", fusion.title
|
||||
yield "hook", fusion.hook
|
||||
for point in fusion.body_points:
|
||||
yield "body_points", f"{point.text} {point.elaboration}"
|
||||
yield "cta", fusion.cta
|
||||
for segment in fusion.script_segments:
|
||||
yield "script_segments", segment.text
|
||||
|
||||
@staticmethod
|
||||
def _fusion_text(fusion: FusionResult) -> str:
|
||||
parts = [fusion.title, fusion.hook]
|
||||
parts += [p.text for p in fusion.body_points]
|
||||
parts += [s.text for s in fusion.script_segments]
|
||||
parts.append(fusion.cta)
|
||||
return "\n".join(p for p in parts if p)
|
||||
|
||||
@staticmethod
|
||||
def _intent_text(intent) -> str:
|
||||
if intent is None:
|
||||
return "无意图信息"
|
||||
parts = [f"意图:{intent.intent_summary}"]
|
||||
parts += [f"核心信息[must_keep={m.must_keep}]:{m.text}" for m in intent.core_messages]
|
||||
parts += [f"事实({b.category}):{b.text}" for b in intent.personal_brands]
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""内部 Pydantic 校验模型(不暴露给运营,运营只看 DB 里的纯文本)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProductItem(BaseModel):
|
||||
name: str = "无法判断"
|
||||
features: str = "无法判断"
|
||||
position: str = "secondary"
|
||||
image_index: int = 0
|
||||
|
||||
|
||||
class ColorItem(BaseModel):
|
||||
hex: str = "#000000"
|
||||
name: str = "无法判断"
|
||||
coverage: float = 0.0
|
||||
|
||||
|
||||
class TextItem(BaseModel):
|
||||
text: str = ""
|
||||
position: str = ""
|
||||
|
||||
|
||||
class ImageAnalysis(BaseModel):
|
||||
products: list[ProductItem] = Field(default_factory=list)
|
||||
colors: list[ColorItem] = Field(default_factory=list)
|
||||
has_person: bool = False
|
||||
person_count: int = 0
|
||||
people: dict[str, str] = Field(default_factory=dict)
|
||||
mood: str = ""
|
||||
visible_text: list[TextItem] = Field(default_factory=list)
|
||||
scene: str = ""
|
||||
quality: dict[str, str] = Field(default_factory=dict)
|
||||
key_selling_points: list[str] = Field(default_factory=list)
|
||||
raw: str = ""
|
||||
|
||||
|
||||
class CoreMessage(BaseModel):
|
||||
text: str
|
||||
must_keep: bool = False
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
class PersonalBrand(BaseModel):
|
||||
text: str
|
||||
category: str = "brand"
|
||||
|
||||
|
||||
class IntentResult(BaseModel):
|
||||
intent_summary: str = ""
|
||||
core_messages: list[CoreMessage] = Field(default_factory=list)
|
||||
personal_brands: list[PersonalBrand] = Field(default_factory=list)
|
||||
emotion_tone: str = ""
|
||||
missing_info: list[str] = Field(default_factory=list)
|
||||
raw: str = ""
|
||||
|
||||
|
||||
class BodyPoint(BaseModel):
|
||||
text: str
|
||||
elaboration: str = ""
|
||||
image_index: int = 0
|
||||
|
||||
|
||||
class ScriptSegment(BaseModel):
|
||||
text: str
|
||||
duration_sec: float = 0
|
||||
image_index: int = 0
|
||||
|
||||
|
||||
class FusionResult(BaseModel):
|
||||
title: str = ""
|
||||
hook: str = ""
|
||||
body_points: list[BodyPoint] = Field(default_factory=list)
|
||||
cta: str = ""
|
||||
script_segments: list[ScriptSegment] = Field(default_factory=list)
|
||||
word_count: int = 0
|
||||
estimated_duration: int = 0
|
||||
raw: str = ""
|
||||
|
||||
|
||||
class KenBurns(BaseModel):
|
||||
start: str = "0,0"
|
||||
end: str = "0,0"
|
||||
ease: str = "linear"
|
||||
|
||||
|
||||
class Clip(BaseModel):
|
||||
image_index: int = 0
|
||||
transition: str = "cut"
|
||||
zoom: str | None = None
|
||||
duration_sec: float = 0
|
||||
bgm_note: str = ""
|
||||
voice_text: str = ""
|
||||
subtitle_text: str = ""
|
||||
ken_burns: KenBurns = Field(default_factory=KenBurns)
|
||||
|
||||
|
||||
class Storyboard(BaseModel):
|
||||
clips: list[Clip] = Field(default_factory=list)
|
||||
raw: str = ""
|
||||
|
||||
|
||||
class ReviewIssue(BaseModel):
|
||||
dimension: str
|
||||
severity: str = "warning"
|
||||
location: str = ""
|
||||
text: str = ""
|
||||
|
||||
|
||||
class ReviewResult(BaseModel):
|
||||
passed: bool = True
|
||||
issues: list[ReviewIssue] = Field(default_factory=list)
|
||||
rewrite_suggestions: list[str] = Field(default_factory=list)
|
||||
raw: str = ""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""XML 标签式输出解析器(替代 json.loads)。
|
||||
|
||||
LLM 按 ``<tag attr="x">内容</tag>`` 输出,本模块解析,解析失败不抛异常,
|
||||
由调用方走规则 fallback。采用栈式扫描,嵌套标签全部可提取(内外层都保留)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from html import unescape
|
||||
from typing import Optional
|
||||
|
||||
_OPEN_RE = re.compile(r"<(?P<tag>[\w-]+)(?P<attrs>(?:\s(?:[^>]*?\S)?)?)(?P<self>/?)>")
|
||||
_CLOSE_RE = re.compile(r"</(?P<tag>[\w-]+)\s*>")
|
||||
_ATTR_RE = re.compile(r"""([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""")
|
||||
|
||||
|
||||
def parse_attributes(raw: str) -> dict[str, str]:
|
||||
"""解析标签属性字符串。"""
|
||||
attrs: dict[str, str] = {}
|
||||
for match in _ATTR_RE.finditer(raw or ""):
|
||||
value = match.group(2) if match.group(2) is not None else match.group(3)
|
||||
attrs[match.group(1)] = value
|
||||
return attrs
|
||||
|
||||
|
||||
def parse_tags(text: Optional[str]) -> list[dict]:
|
||||
"""提取全部标签(含嵌套内外层),返回 [{tag, attrs, text}],按开标签出现顺序。"""
|
||||
if not text:
|
||||
return []
|
||||
results: list[dict] = []
|
||||
stack: list[dict] = []
|
||||
token_re = re.compile(r"<[^>]+>")
|
||||
for token in token_re.finditer(text):
|
||||
raw_token = token.group(0)
|
||||
# 先按开/闭标签匹配
|
||||
open_match = _OPEN_RE.match(raw_token)
|
||||
close_match = _CLOSE_RE.match(raw_token)
|
||||
is_close_tag = raw_token.startswith("</")
|
||||
if not is_close_tag and open_match:
|
||||
is_self_close = open_match.group("self") == "/"
|
||||
node = {
|
||||
"tag": open_match.group("tag"),
|
||||
"attrs": parse_attributes(open_match.group("attrs")),
|
||||
"text": "",
|
||||
"_start": token.end(),
|
||||
}
|
||||
if is_self_close:
|
||||
node.pop("_start")
|
||||
results.append(node)
|
||||
else:
|
||||
stack.append(node)
|
||||
results.append(node)
|
||||
elif is_close_tag and close_match:
|
||||
tag = close_match.group("tag")
|
||||
# 弹出到最近同名开标签
|
||||
for idx in range(len(stack) - 1, -1, -1):
|
||||
if stack[idx]["tag"] == tag:
|
||||
node = stack[idx]
|
||||
node["text"] = unescape(text[node["_start"] : token.start()].strip())
|
||||
node.pop("_start", None)
|
||||
del stack[idx:]
|
||||
break
|
||||
# 未闭合标签:给剩余部分作为文本
|
||||
for node in stack:
|
||||
if "_start" in node:
|
||||
node["text"] = unescape(text[node["_start"] :].strip())
|
||||
node.pop("_start", None)
|
||||
return results
|
||||
|
||||
|
||||
def find_all(text: Optional[str], tag: str) -> list[dict]:
|
||||
"""提取指定标签的全部节点。"""
|
||||
return [n for n in parse_tags(text) if n["tag"] == tag]
|
||||
|
||||
|
||||
def find_first(text: Optional[str], tag: str) -> Optional[dict]:
|
||||
nodes = find_all(text, tag)
|
||||
return nodes[0] if nodes else None
|
||||
|
||||
|
||||
def text_of(text: Optional[str], tag: str, default: str = "") -> str:
|
||||
node = find_first(text, tag)
|
||||
return node["text"] if node else default
|
||||
|
||||
|
||||
def attr_bool(value: Optional[str], default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"true", "1", "yes", "是"}
|
||||
|
||||
|
||||
def attr_float(value: Optional[str], default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value) if value is not None and value.strip() else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def attr_int(value: Optional[str], default: int = 0) -> int:
|
||||
try:
|
||||
return int(float(value)) if value is not None and value.strip() else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
+22
-8
@@ -90,18 +90,32 @@ class SharedSettings(BaseSettings):
|
||||
|
||||
# ── 豆包大模型(火山引擎方舟) ────────────────────────────────────────
|
||||
doubao_api_key: str = ""
|
||||
doubao_model: str = "doubao-seed-1-6-250615" # 推理模型(通用兜底)
|
||||
doubao_fast_model: str = "doubao-1-5-pro-32k-250115" # 快速结构化输出模型(编导脚本/意图解析/审核)
|
||||
doubao_model: str = "doubao-seed-2-1-pro-260915" # 推理模型(Seed 2.1 Pro,深度思考+多模态;原 seed-1-6 已下线)
|
||||
doubao_fast_model: str = (
|
||||
"doubao-seed-2-1-pro-260915" # #2181: lite方舟侧100%超时,默认fast_model也走pro;方舟恢复lite后通过ENV DOUBAO_FAST_MODEL切回
|
||||
)
|
||||
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
doubao_vision_model: str = "doubao-1-5-vision-pro-250328" # 高精度视觉(备用)
|
||||
doubao_vision_lite_model: str = "doubao-1-5-vision-lite-250315" # 快速视觉(商品识别默认,速度优先)
|
||||
doubao_vision_use_lite: bool = True # viral-video 图片分析默认用 lite 提速
|
||||
doubao_embedding_model: str = "doubao-embedding-large-text-240915"
|
||||
doubao_timeout: int = 45 # #2180: 方舟LLM高峰期响应6-8s,原30s太紧提到45s
|
||||
doubao_max_retries: int = 1 # #2180: timeout调大后一次调用就够,1次重试防偶发抖动;避免6次重试叠加到351s
|
||||
doubao_vision_model: str = (
|
||||
"doubao-seed-2-1-pro-260915" # 高精度视觉(Seed 2.1 Pro 原生多模态;原 vision-pro-250328 已下线)
|
||||
)
|
||||
doubao_vision_lite_model: str = (
|
||||
"doubao-seed-2-1-lite-260915" # 快速视觉(Seed 2.1 Lite 原生多模态;原 vision-lite-250315 不可用)
|
||||
)
|
||||
doubao_vision_use_lite: bool = True # #2188: lite恢复稳定,爆款视频默认lite-first提速(20-30s)
|
||||
doubao_embedding_model: str = "doubao-embedding-vision-251215" # 多模态向量化(原 large-text-240915 已 Retiring)
|
||||
doubao_video_model: str = "doubao-seedance-2-5-260628"
|
||||
doubao_video_timeout: int = 600 # 视频生成轮询总超时(秒)
|
||||
doubao_video_poll_interval: int = 10 # 轮询间隔(秒)
|
||||
doubao_image_model: str = (
|
||||
"doubao-seedream-5-0-flash-260915" # #2173: 信任链 Seedream 改 flash 模型(实测 pro 46.5s→flash 13s;pro AI化图仍被Seedance拦截)
|
||||
)
|
||||
doubao_image_size: str = "1K" # #2173: 1K 已足够做 Seedance 参考图,2K 在 flash 下也 22s,1K 13s
|
||||
doubao_image_timeout: int = 60 # #2173: flash+1K 通常15s内,给60s余量
|
||||
doubao_trust_chain_enabled: bool = (
|
||||
True # #2173: 信任链总开关;若Seedream产物仍被Seedance拦截,可配 False 关闭直接t2v降级
|
||||
)
|
||||
|
||||
# ── DashScope (阿里云百炼 Wan 3.0 等) ─────────────────────────────────
|
||||
dashscope_api_key: str = ""
|
||||
|
||||
Executable
+376
@@ -0,0 +1,376 @@
|
||||
"""功能计费配置服务:从 feature_pricing_configs 读配置,300 秒 TTL 内存缓存。
|
||||
|
||||
配置表由 xiaoxia-admin 侧维护(同库 PostgreSQL),本服务只读。
|
||||
DB 不可用 / 表不存在 / 无数据时自动回落到内置兜底配置,保证业务不崩。
|
||||
|
||||
计费公式:最终积分 = (动态成本 + 固定成本) × 利润系数,price_cap 封顶。
|
||||
启用条件:全局 points_enabled 总开关 AND 功能 is_enabled 同时为 true。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import session as _session_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_TTL_SECONDS = 300.0
|
||||
|
||||
# ── 爆款视频兜底模型单价(与旧硬编码表/现状一致;DB 不可用时使用) ───────
|
||||
# 结构:models[model_key][resolution]["true"/"false"] = 单价
|
||||
# token 模式:元/百万输出 tokens;per_second 模式:元/秒
|
||||
# 注意:仅 seedance-2.5 配置 true(图生视频)单价;其余模型只有 false,
|
||||
# 精确 key 缺失时由 points_rules 回落到 seedance-2.5/false(与旧现状一致)。
|
||||
_FALLBACK_VIRAL_MODEL_PRICING: dict = {
|
||||
"seedance-2.5": {
|
||||
"480p": {"false": 70.0, "true": 42.0},
|
||||
"720p": {"false": 70.0, "true": 42.0},
|
||||
"1080p": {"false": 77.0, "true": 46.0},
|
||||
},
|
||||
"seedance-2.0": {
|
||||
"480p": {"false": 46.0},
|
||||
"720p": {"false": 46.0},
|
||||
"1080p": {"false": 51.0},
|
||||
"4k": {"false": 80.0},
|
||||
},
|
||||
"seedance-2.0-fast": {
|
||||
"480p": {"false": 28.0},
|
||||
"720p": {"false": 28.0},
|
||||
},
|
||||
"seedance-2.0-mini": {
|
||||
"480p": {"false": 9.2},
|
||||
"720p": {"false": 9.2},
|
||||
},
|
||||
"wan-3.0": {
|
||||
"480p": {"false": 0.3},
|
||||
"720p": {"false": 0.6},
|
||||
"1080p": {"false": 1.2},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeatureConfig:
|
||||
"""功能计费配置快照。"""
|
||||
|
||||
feature_key: str
|
||||
name: str = ""
|
||||
emoji: str = ""
|
||||
is_enabled: bool = False
|
||||
fixed_cost: float = 0.0
|
||||
profit_multiplier: float = 1.0
|
||||
dynamic_unit_cost: float = 0.0
|
||||
billing_mode: str = "model_based"
|
||||
price_cap: float = 0.0
|
||||
model_pricing: dict = field(default_factory=dict)
|
||||
description: str = ""
|
||||
|
||||
|
||||
# ── 进程内缓存:(loaded_monotonic, {feature_key: FeatureConfig}) ──────────
|
||||
_lock = threading.Lock()
|
||||
_cache: Optional[tuple[float, dict[str, FeatureConfig]]] = None
|
||||
|
||||
|
||||
def _fallback_configs() -> dict[str, FeatureConfig]:
|
||||
"""内置兜底配置:爆款启用(与现状一致),其余两个关闭。"""
|
||||
return {
|
||||
"viral_video": FeatureConfig(
|
||||
feature_key="viral_video",
|
||||
name="爆款视频",
|
||||
emoji="🎬",
|
||||
is_enabled=True,
|
||||
fixed_cost=0.15,
|
||||
profit_multiplier=1.3,
|
||||
dynamic_unit_cost=0.0,
|
||||
billing_mode="model_based",
|
||||
price_cap=0.0,
|
||||
model_pricing=json.loads(json.dumps(_FALLBACK_VIRAL_MODEL_PRICING)),
|
||||
description="爆款视频动态定价(兜底配置)",
|
||||
),
|
||||
"lip_sync": FeatureConfig(
|
||||
feature_key="lip_sync",
|
||||
name="对口型",
|
||||
emoji="🎙️",
|
||||
is_enabled=False,
|
||||
fixed_cost=0.0,
|
||||
profit_multiplier=1.0,
|
||||
dynamic_unit_cost=0.0,
|
||||
billing_mode="per_second",
|
||||
price_cap=0.0,
|
||||
description="对口型计费(兜底配置,默认关闭)",
|
||||
),
|
||||
"smart_edit": FeatureConfig(
|
||||
feature_key="smart_edit",
|
||||
name="智能剪辑",
|
||||
emoji="✂️",
|
||||
is_enabled=False,
|
||||
fixed_cost=0.0,
|
||||
profit_multiplier=1.0,
|
||||
dynamic_unit_cost=0.0,
|
||||
billing_mode="model_based",
|
||||
price_cap=0.0,
|
||||
description="智能剪辑固定价计费(兜底配置,默认关闭)",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_lazy_session = None
|
||||
|
||||
|
||||
def _get_session():
|
||||
"""优先用全局 SessionLocal(worker);否则按应用配置懒建同步引擎(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://"):
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://")
|
||||
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 _parse_model_pricing(raw) -> dict:
|
||||
"""解析 model_pricing_json(Text JSON),空/失败 → {}。"""
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("model_pricing_json 解析失败,按空配置处理: %r", text[:200])
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _to_float(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _load_all() -> dict[str, FeatureConfig]:
|
||||
"""SELECT * FROM feature_pricing_configs,返回 {feature_key: FeatureConfig}。
|
||||
|
||||
表不存在 / DB 异常由调用方捕获并回落兜底配置。
|
||||
"""
|
||||
session = None
|
||||
try:
|
||||
session = _get_session()
|
||||
if session is None:
|
||||
raise RuntimeError("no db session available")
|
||||
sql = sa.text("""
|
||||
SELECT feature_key, name, emoji, is_enabled, fixed_cost,
|
||||
profit_multiplier, dynamic_unit_cost, billing_mode,
|
||||
price_cap, model_pricing_json, description
|
||||
FROM feature_pricing_configs
|
||||
""")
|
||||
rows = session.execute(sql).mappings().all()
|
||||
configs: dict[str, FeatureConfig] = {}
|
||||
for row in rows:
|
||||
key = str(row["feature_key"] or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
configs[key] = FeatureConfig(
|
||||
feature_key=key,
|
||||
name=str(row["name"] or key),
|
||||
emoji=str(row["emoji"] or ""),
|
||||
is_enabled=bool(row["is_enabled"]),
|
||||
fixed_cost=_to_float(row["fixed_cost"]),
|
||||
profit_multiplier=_to_float(row["profit_multiplier"], 1.0),
|
||||
dynamic_unit_cost=_to_float(row["dynamic_unit_cost"]),
|
||||
billing_mode=str(row["billing_mode"] or "model_based"),
|
||||
price_cap=_to_float(row["price_cap"]),
|
||||
model_pricing=_parse_model_pricing(row["model_pricing_json"]),
|
||||
description=str(row["description"] or ""),
|
||||
)
|
||||
return configs
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _get_cache() -> dict[str, FeatureConfig]:
|
||||
"""TTL 内返回缓存,否则重新 load;DB 异常/表不存在时返回内置兜底配置。"""
|
||||
global _cache
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
if _cache is not None and now - _cache[0] < CACHE_TTL_SECONDS:
|
||||
return _cache[1]
|
||||
|
||||
try:
|
||||
loaded = _load_all()
|
||||
except Exception: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
|
||||
logger.info("feature_pricing_configs 读取失败,使用内置兜底配置", exc_info=True)
|
||||
return _fallback_configs()
|
||||
|
||||
# DB 可用但表为空:同样回落兜底(保证爆款现状不被改变)
|
||||
if not loaded:
|
||||
fallback = _fallback_configs()
|
||||
with _lock:
|
||||
_cache = (now, fallback)
|
||||
return fallback
|
||||
|
||||
# 以兜底为底(DB 未配置的 feature_key 仍有兜底),DB 行覆盖
|
||||
merged = _fallback_configs()
|
||||
merged.update(loaded)
|
||||
with _lock:
|
||||
_cache = (now, merged)
|
||||
return merged
|
||||
|
||||
|
||||
def get_feature_config(feature_key: str) -> Optional[FeatureConfig]:
|
||||
"""获取指定功能配置,未知 key 返回 None。"""
|
||||
key = str(feature_key or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
return _get_cache().get(key)
|
||||
|
||||
|
||||
def _global_points_enabled() -> bool:
|
||||
"""全局积分总开关(兼容 api / worker 运行时),取不到时默认关闭。"""
|
||||
try:
|
||||
from packages.shared import get_shared_settings
|
||||
|
||||
return bool(get_shared_settings().points_enabled)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
return bool(getattr(settings, "points_enabled", False))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def is_feature_enabled(feature_key: str) -> bool:
|
||||
"""功能是否启用并扣费:全局 points_enabled AND 功能 is_enabled。"""
|
||||
cfg = get_feature_config(feature_key)
|
||||
if cfg is None:
|
||||
return False
|
||||
return bool(cfg.is_enabled) and _global_points_enabled()
|
||||
|
||||
|
||||
def calculate_price(feature_key: str, dynamic_cost: float = 0.0) -> tuple[float, dict]:
|
||||
"""按公式计算最终积分并返回明细。
|
||||
|
||||
price = (dynamic_cost + fixed_cost) × profit_multiplier
|
||||
price_cap > 0 时封顶(取 min)。
|
||||
功能未启用 → (0.0, breakdown{is_enabled: False, charged: False})。
|
||||
"""
|
||||
cfg = get_feature_config(feature_key)
|
||||
dynamic = max(0.0, _to_float(dynamic_cost))
|
||||
if cfg is None or not cfg.is_enabled:
|
||||
return 0.0, {
|
||||
"feature_key": feature_key,
|
||||
"is_enabled": False,
|
||||
"charged": False,
|
||||
"dynamic_cost": dynamic,
|
||||
"fixed_cost": 0.0,
|
||||
"profit_multiplier": 1.0,
|
||||
"price_cap": 0.0,
|
||||
"final_price": 0.0,
|
||||
}
|
||||
|
||||
fixed = max(0.0, cfg.fixed_cost)
|
||||
multiplier = cfg.profit_multiplier if cfg.profit_multiplier > 0 else 1.0
|
||||
raw_price = (dynamic + fixed) * multiplier
|
||||
cap = cfg.price_cap if cfg.price_cap and cfg.price_cap > 0 else 0.0
|
||||
final_price = min(raw_price, cap) if cap else raw_price
|
||||
final_price = round(float(final_price), 2)
|
||||
breakdown = {
|
||||
"feature_key": cfg.feature_key,
|
||||
"is_enabled": True,
|
||||
"charged": True,
|
||||
"dynamic_cost": round(dynamic, 4),
|
||||
"fixed_cost": float(fixed),
|
||||
"profit_multiplier": float(multiplier),
|
||||
"price_cap": float(cap),
|
||||
"raw_price": round(float(raw_price), 4),
|
||||
"final_price": final_price,
|
||||
}
|
||||
return final_price, breakdown
|
||||
|
||||
|
||||
def lookup_model_price(
|
||||
model_pricing: dict,
|
||||
model_key: str,
|
||||
resolution: str,
|
||||
has_video_input: bool,
|
||||
) -> Optional[float]:
|
||||
"""从 model_pricing dict 取模型单价,兼容两种常见 JSON 结构。
|
||||
|
||||
1. 嵌套:{model: {resolution: {"true"/"false": price}}}
|
||||
(内层 bool key 也兼容直接 bool / 省略)
|
||||
2. 扁平:{"model|resolution|true_or_false": price}
|
||||
(分隔符支持 | / : / , / 空格;bool 段可省略)
|
||||
取不到返回 None。
|
||||
"""
|
||||
if not isinstance(model_pricing, dict):
|
||||
return None
|
||||
model = str(model_key or "").strip()
|
||||
res = str(resolution or "").strip()
|
||||
flag = "true" if has_video_input else "false"
|
||||
|
||||
# 1. 嵌套
|
||||
model_node = model_pricing.get(model)
|
||||
if isinstance(model_node, dict):
|
||||
res_node = model_node.get(res)
|
||||
if isinstance(res_node, dict):
|
||||
# 精确 bool key 命中才返回;不做“只有一个值就取”的模糊匹配
|
||||
# (否则缺失 true 时会错误地取到 false 价,破坏旧版回落规则)
|
||||
if flag in res_node:
|
||||
return _to_float(res_node[flag]) if res_node[flag] is not None else None
|
||||
if has_video_input in res_node:
|
||||
val = res_node[has_video_input]
|
||||
return _to_float(val) if val is not None else None
|
||||
elif isinstance(res_node, (int, float)):
|
||||
return float(res_node)
|
||||
|
||||
# 2. 扁平
|
||||
for sep in ("|", ":", ",", " "):
|
||||
for key in (
|
||||
f"{model}{sep}{res}{sep}{flag}",
|
||||
f"{model}{sep}{res}",
|
||||
):
|
||||
if key in model_pricing:
|
||||
value = model_pricing[key]
|
||||
return _to_float(value) if value is not None else None
|
||||
return None
|
||||
|
||||
|
||||
def refresh_feature_configs() -> None:
|
||||
"""清空缓存(下次读取重新 load DB;测试/admin 改配置后可手动调)。"""
|
||||
global _cache
|
||||
with _lock:
|
||||
_cache = None
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
v1.6.1: 按产品决策,智能混剪/AI数字人/AI配音/抖音解析/改写/标题/封面 全部免费,
|
||||
仅保留声音克隆合成(voice_clone_synth)的扣点逻辑;声音克隆训练保持免费。
|
||||
爆款视频(viral_video)走动态定价,见本文件 VIRAL_VIDEO_MODEL_PRICES + calculate_viral_video_credits。
|
||||
爆款视频(viral_video)走动态定价,计费参数 DB 化(feature_pricing_configs,
|
||||
见 feature_pricing_service),calculate_viral_video_credits 从配置读取单价/
|
||||
固定成本/利润系数/封顶,DB 不可用时回落兜底配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
# ============ 爆款视频动态定价 (#2151) ============
|
||||
# key = (model_id, resolution, has_video_input),单位:
|
||||
# - billing_mode=token: 元/百万tokens(输出)
|
||||
# - billing_mode=per_second: 元/秒(视频时长)
|
||||
from packages.domain import feature_pricing_service
|
||||
|
||||
# ============ 爆款视频动态定价 ============
|
||||
# 单价/固定成本/利润系数已 DB 化(feature_pricing_configs,feature_key=viral_video),
|
||||
# 由 feature_pricing_service 读取(300s 缓存),DB 不可用时回落内置兜底配置。
|
||||
# 以下三个常量仅为向后兼容保留(旧引用方/兜底场景),值取自兜底配置。
|
||||
VIRAL_VIDEO_MODEL_PRICES: dict[tuple[str, str, bool], float] = {
|
||||
("seedance-2.5", "480p", False): 70.0,
|
||||
("seedance-2.5", "720p", False): 70.0,
|
||||
@@ -33,9 +37,9 @@ VIRAL_VIDEO_MODEL_PRICES: dict[tuple[str, str, bool], float] = {
|
||||
("wan-3.0", "1080p", False): 1.2,
|
||||
}
|
||||
|
||||
# 固定成本(元):VLM 分析 + LLM 文案 + TTS + OSS + 服务器
|
||||
# 固定成本(元):VLM 分析 + LLM 文案 + TTS + OSS + 服务器(兜底默认值)
|
||||
VIRAL_VIDEO_FIXED_COST = 0.15
|
||||
# 利润系数
|
||||
# 利润系数(兜底默认值)
|
||||
VIRAL_VIDEO_PROFIT_MULTIPLIER = 1.3
|
||||
# Seedance 输出帧率
|
||||
VIRAL_VIDEO_FPS = 24
|
||||
@@ -222,17 +226,22 @@ def calculate_viral_video_credits_with_breakdown(
|
||||
) -> tuple[float, dict]:
|
||||
"""计算爆款视频所需积分(1 积分 = 1 元),并返回计费公式明细。
|
||||
|
||||
单价/固定成本/利润系数/封顶从 feature_pricing_configs(viral_video)读取;
|
||||
DB 不可用时回落与现状一致的内置兜底配置。
|
||||
|
||||
公式:
|
||||
tokens = duration * width * height * fps / 1024
|
||||
video_cost = tokens / 1_000_000 * model_token_price
|
||||
total = round((video_cost + fixed_cost) * profit_multiplier, 2)
|
||||
price_cap > 0 时封顶取 min
|
||||
若传入 actual_tokens 则用它替代计算值。
|
||||
|
||||
Returns:
|
||||
(credits, breakdown) 二元组:
|
||||
- credits: 四舍五入保留两位小数的最终积分
|
||||
- breakdown: dict,包含 tokens / video_cost / fixed_cost / profit_multiplier /
|
||||
model_price / width / height / fps 字段,便于前端展示计费明细。
|
||||
model_price / width / height / fps / feature_enabled / charged / price_cap
|
||||
字段,便于前端展示计费明细。功能关闭时 credits=0、charged=False。
|
||||
"""
|
||||
w = max(1, int(width or 1))
|
||||
h = max(1, int(height or 1))
|
||||
@@ -242,11 +251,36 @@ def calculate_viral_video_credits_with_breakdown(
|
||||
cfg = get_viral_video_model_config(prefix)
|
||||
res_key = _infer_resolution_key(w, h)
|
||||
billing = cfg.get("billing_mode", "token")
|
||||
key = (prefix, res_key, bool(has_video_input))
|
||||
price = VIRAL_VIDEO_MODEL_PRICES.get(key)
|
||||
dur = max(1, int(duration_seconds or 15))
|
||||
|
||||
# ── 从 DB 配置(兜底内置)取计费参数 ──
|
||||
feature_cfg = feature_pricing_service.get_feature_config("viral_video")
|
||||
# 注意:此处 feature_enabled 只表示“功能自身开关”,不并入全局 points_enabled
|
||||
# 总开关(保持与旧版计费函数行为一致:价格照常计算)。全局总开关由业务层
|
||||
# (route/worker)通过 feature_pricing_service.is_feature_enabled 统一把关。
|
||||
feature_enabled = bool(feature_cfg.is_enabled) if feature_cfg is not None else True
|
||||
model_pricing = feature_cfg.model_pricing if feature_cfg is not None else {}
|
||||
fixed_cost = float(feature_cfg.fixed_cost) if feature_cfg is not None else float(VIRAL_VIDEO_FIXED_COST)
|
||||
multiplier = (
|
||||
float(feature_cfg.profit_multiplier)
|
||||
if feature_cfg is not None and feature_cfg.profit_multiplier > 0
|
||||
else float(VIRAL_VIDEO_PROFIT_MULTIPLIER)
|
||||
)
|
||||
price_cap = float(feature_cfg.price_cap) if feature_cfg is not None else 0.0
|
||||
|
||||
# 单价:优先配置 dict;复刻旧版回落规则——精确 key 取不到时,回落
|
||||
# seedance-2.5 同分辨率 False 单价;最终兜底 70.0。
|
||||
price = feature_pricing_service.lookup_model_price(model_pricing, prefix, res_key, bool(has_video_input))
|
||||
if price is None:
|
||||
# 配置表未命中:先尝试配置里的 seedance-2.5/False
|
||||
if prefix != "seedance-2.5" or bool(has_video_input):
|
||||
price = feature_pricing_service.lookup_model_price(model_pricing, "seedance-2.5", res_key, False)
|
||||
if price is None:
|
||||
key = (prefix, res_key, bool(has_video_input))
|
||||
price = VIRAL_VIDEO_MODEL_PRICES.get(key)
|
||||
if price is None:
|
||||
price = VIRAL_VIDEO_MODEL_PRICES.get(("seedance-2.5", res_key, False), 70.0)
|
||||
dur = max(1, int(duration_seconds or 15))
|
||||
|
||||
if billing == "per_second":
|
||||
tokens = 0.0
|
||||
video_cost = dur * float(price)
|
||||
@@ -259,13 +293,40 @@ def calculate_viral_video_credits_with_breakdown(
|
||||
video_cost = tokens / 1_000_000.0 * float(price)
|
||||
billing_unit = "token"
|
||||
|
||||
total = (video_cost + VIRAL_VIDEO_FIXED_COST) * VIRAL_VIDEO_PROFIT_MULTIPLIER
|
||||
if not feature_enabled:
|
||||
# 功能关闭(is_enabled=false 或全局 points 关闭):不扣费,明细照旧返回
|
||||
credits = 0.0
|
||||
raw_total = (video_cost + fixed_cost) * multiplier
|
||||
breakdown = {
|
||||
"tokens": float(tokens),
|
||||
"video_cost": float(video_cost),
|
||||
"fixed_cost": float(fixed_cost),
|
||||
"profit_multiplier": float(multiplier),
|
||||
"price_cap": float(price_cap or 0.0),
|
||||
"model_price": float(price),
|
||||
"model_key": prefix,
|
||||
"billing_mode": billing,
|
||||
"billing_unit": billing_unit,
|
||||
"width": int(w),
|
||||
"height": int(h),
|
||||
"fps": int(effective_fps),
|
||||
"duration": dur,
|
||||
"feature_enabled": False,
|
||||
"charged": False,
|
||||
"raw_price": round(float(raw_total), 4),
|
||||
}
|
||||
return credits, breakdown
|
||||
|
||||
total = (video_cost + fixed_cost) * multiplier
|
||||
if price_cap and price_cap > 0:
|
||||
total = min(total, price_cap)
|
||||
credits = round(float(total), 2)
|
||||
breakdown = {
|
||||
"tokens": float(tokens),
|
||||
"video_cost": float(video_cost),
|
||||
"fixed_cost": float(VIRAL_VIDEO_FIXED_COST),
|
||||
"profit_multiplier": float(VIRAL_VIDEO_PROFIT_MULTIPLIER),
|
||||
"fixed_cost": float(fixed_cost),
|
||||
"profit_multiplier": float(multiplier),
|
||||
"price_cap": float(price_cap or 0.0),
|
||||
"model_price": float(price),
|
||||
"model_key": prefix,
|
||||
"billing_mode": billing,
|
||||
@@ -274,6 +335,8 @@ def calculate_viral_video_credits_with_breakdown(
|
||||
"height": int(h),
|
||||
"fps": int(effective_fps),
|
||||
"duration": dur,
|
||||
"feature_enabled": True,
|
||||
"charged": True,
|
||||
}
|
||||
return credits, breakdown
|
||||
|
||||
|
||||
@@ -87,6 +87,9 @@ class ViralVideoJob:
|
||||
|
||||
user_id: str
|
||||
images: list[str] = field(default_factory=list)
|
||||
pre_trusted_images: list[str] | None = (
|
||||
None # #2172 信任链预热结果(Seedream AI 化后的 URL 列表),与 images 顺序对应
|
||||
)
|
||||
industry: str = ""
|
||||
target_customer: str = ""
|
||||
persona_id: str = ""
|
||||
|
||||
+323
-15
@@ -34,11 +34,11 @@ _HTTP_STATUS_ERROR = httpx.HTTPStatusError if hasattr(httpx, "HTTPStatusError")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 视频模型 ID 解析逻辑(#2159 多模型支持)。
|
||||
# 内部使用简短别名(seedance-2.5 / seedance-2.0 / wan-3.0 等)做 PRICING key 和前端选择值;
|
||||
# 视频模型 ID 解析逻辑(#2159 多模型支持,#2170 方舟信任链统一走方舟)。
|
||||
# 内部使用简短别名(seedance-2.5 / seedance-2.0 / wan-3.0 等)做 PRICING key;
|
||||
# 实际调用时按 VIRAL_VIDEO_MODEL_CONFIG(domain/points_rules.py)的 provider/model_id 分发。
|
||||
# - provider=doubao → 火山方舟
|
||||
# - provider=dashscope → 阿里云 DashScope(Wan 系列)
|
||||
# - provider=doubao → 火山方舟 Seedance(含信任链真人 AI 化)
|
||||
# - provider=dashscope → 阿里云 DashScope(Wan 系列,可选)
|
||||
|
||||
|
||||
def _resolve_video_provider_and_id(model: str | None) -> tuple[str, str, dict]:
|
||||
@@ -180,8 +180,15 @@ class DoubaoClient:
|
||||
self.vision_model: str = settings.doubao_vision_model
|
||||
self.vision_lite_model: str = settings.doubao_vision_lite_model
|
||||
self.fast_model: str = settings.doubao_fast_model
|
||||
self.embedding_model: str = settings.doubao_embedding_model
|
||||
self.image_model: str = settings.doubao_image_model
|
||||
self.image_size: str = getattr(settings, "doubao_image_size", "1K") or "1K"
|
||||
self.image_timeout: int = getattr(settings, "doubao_image_timeout", 60) or 60
|
||||
self.trust_chain_enabled: bool = getattr(settings, "doubao_trust_chain_enabled", True)
|
||||
# 最近一次视频生成的详细错误(error_code + user_message + raw detail),供上层读取后展示给用户
|
||||
self.last_video_error: dict = {}
|
||||
# 最近一次图片生成的详细错误,供上层读取
|
||||
self.last_image_error: dict = {}
|
||||
|
||||
def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None:
|
||||
"""调用豆包文本 Embedding API,返回浮点向量;失败返回 None。"""
|
||||
@@ -194,7 +201,7 @@ class DoubaoClient:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": getattr(self, "embedding_model", None) or "doubao-embedding-large-text-240915",
|
||||
"model": self.embedding_model,
|
||||
"input": text.strip(),
|
||||
"encoding_format": "float",
|
||||
}
|
||||
@@ -235,6 +242,7 @@ class DoubaoClient:
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
model: str | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> Optional[str]:
|
||||
"""调用 Chat Completion 接口.
|
||||
|
||||
@@ -262,32 +270,44 @@ class DoubaoClient:
|
||||
}
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
_t0 = time.time()
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
_req_timeout = timeout if timeout is not None else self.timeout
|
||||
response = httpx.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
timeout=_req_timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
_elapsed = time.time() - _t0
|
||||
logger.info(
|
||||
"[doubao] chat_completion 完成 model=%s tokens_in=%d tokens_out=%d elapsed=%.1fs attempt=%d timeout=%d",
|
||||
payload.get("model"),
|
||||
data.get("usage", {}).get("prompt_tokens", 0),
|
||||
data.get("usage", {}).get("completion_tokens", 0),
|
||||
_elapsed,
|
||||
attempt + 1,
|
||||
)
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包API调用失败,%.1fs后重试 (第%d/%d次): %s",
|
||||
"豆包API调用失败,%.1fs后重试 (第%d/%d次, elapsed=%.1fs): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
time.time() - _t0,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
|
||||
logger.error("豆包API调用最终失败: %s", last_error)
|
||||
logger.error("豆包API调用最终失败: elapsed=%.1fs err=%s", time.time() - _t0, last_error)
|
||||
return None
|
||||
|
||||
def vision_completion(
|
||||
@@ -356,6 +376,7 @@ class DoubaoClient:
|
||||
|
||||
req_timeout = timeout or self.timeout
|
||||
last_error: Optional[Exception] = None
|
||||
_t0 = time.time()
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
response = httpx.post(
|
||||
@@ -367,25 +388,97 @@ class DoubaoClient:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
_elapsed = time.time() - _t0
|
||||
logger.info(
|
||||
"[doubao] vision_completion 完成 model=%s tokens_in=%d tokens_out=%d elapsed=%.1fs attempt=%d",
|
||||
payload.get("model"),
|
||||
data.get("usage", {}).get("prompt_tokens", 0),
|
||||
data.get("usage", {}).get("completion_tokens", 0),
|
||||
_elapsed,
|
||||
attempt + 1,
|
||||
)
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包视觉API调用失败,%.1fs后重试 (第%d/%d次): %s",
|
||||
"豆包视觉API调用失败,%.1fs后重试 (第%d/%d次, elapsed=%.1fs): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
time.time() - _t0,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
|
||||
logger.error("豆包视觉API调用最终失败: %s", last_error)
|
||||
logger.error("豆包视觉API调用最终失败: elapsed=%.1fs err=%s", time.time() - _t0, last_error)
|
||||
return None
|
||||
|
||||
# ── 视频生成(Seedance 2.5,异步任务)────────────────────────────
|
||||
|
||||
def preheat_trust_chain(
|
||||
self,
|
||||
portrait_descriptions: list[str],
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
size: str | None = None,
|
||||
) -> list[str] | None:
|
||||
"""#2174 信任链预热(t2i 版):用 VLM 分析出的人物外貌描述,纯文生图生成 Seedream 人像,
|
||||
返回信任产物 URL 列表。
|
||||
|
||||
重要:方舟信任链规则是 Seedream 文生图(t2i)产物(不传 reference_images)才被 Seedance 信任;
|
||||
i2i(带用户照片 reference)产物不被信任,仍会被肖像审核拦截。
|
||||
|
||||
- portrait_descriptions: VLM 输出的 portrait_prompt 列表(中文描述),与原始图片顺序对应
|
||||
- 全部成功返回 list[str](顺序与输入一致)
|
||||
- 任何一张失败返回 None(上层会走现场兜底:直接 t2v 不带参考图)
|
||||
- 若 trust_chain_enabled=False 或所有描述均为"无人像",直接返回 None
|
||||
- 供 worker 在 VLM 分析完成后后台预热使用;video_generation 内部若收到 preheated 结果会直接使用。
|
||||
"""
|
||||
if not portrait_descriptions or not self.is_available or not getattr(self, "trust_chain_enabled", True):
|
||||
return None
|
||||
# 过滤掉"无人像"等无效描述,收集需要生成的索引
|
||||
_idx_map: list[int] = []
|
||||
_prompts: list[str] = []
|
||||
for i, desc in enumerate(portrait_descriptions):
|
||||
if not desc or "无人像" in desc or len(desc) < 10:
|
||||
continue
|
||||
_idx_map.append(i)
|
||||
# 把VLM的中文描述包装成适合Seedream t2i的英文+中文混合prompt,明确是写实半身人像
|
||||
_prompts.append(
|
||||
f"高清写实半身人像照片,{desc},自然光线,面部清晰居中,皮肤质感自然,"
|
||||
"高清摄影细节,构图居中,人物占据画面主体,背景柔和虚化。photorealistic portrait, "
|
||||
"sharp focus on face, soft natural lighting, high detail, half body shot."
|
||||
)
|
||||
if not _prompts:
|
||||
logger.info("[trust-chain][preheat] 无需生成信任人像(所有图片均无人像),跳过")
|
||||
return None
|
||||
trusted: list[str] = []
|
||||
_t0 = time.time()
|
||||
for idx, sd_prompt in enumerate(_prompts):
|
||||
# #2174 关键:t2i模式——不传reference_images,纯prompt文生图,产物才被Seedance信任
|
||||
sd_result = self.image_generation(
|
||||
prompt=sd_prompt,
|
||||
size=size or getattr(self, "image_size", "1K"),
|
||||
timeout=timeout or getattr(self, "image_timeout", 60),
|
||||
)
|
||||
if not sd_result:
|
||||
logger.warning(
|
||||
"[trust-chain][preheat] Seedream t2i 第 %d/%d 张失败: %s,预热整体失败",
|
||||
idx + 1,
|
||||
len(_prompts),
|
||||
getattr(self, "last_image_error", None),
|
||||
)
|
||||
return None
|
||||
trusted.append(sd_result["url"])
|
||||
logger.info(
|
||||
"[trust-chain][preheat] Seedream t2i 文生图预热完成 %d 张,总耗时 %.1fs",
|
||||
len(trusted),
|
||||
time.time() - _t0,
|
||||
)
|
||||
return trusted
|
||||
|
||||
def video_generation(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -401,6 +494,7 @@ class DoubaoClient:
|
||||
reference_images: list[str] | None = None,
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
pre_trusted_images: list[str] | None = None,
|
||||
) -> dict | None:
|
||||
"""调用 Seedance 2.5 生视频(异步任务→轮询→下载)。
|
||||
|
||||
@@ -484,17 +578,55 @@ class DoubaoClient:
|
||||
ref_videos = [u for u in (reference_videos or [])[:3] if u and isinstance(u, str)]
|
||||
ref_imgs = [u for u in (reference_images or [])[:9] if u and isinstance(u, str)]
|
||||
|
||||
# 判断任务模式:有参考音/视/多图 → omni_reference(支持指定 ratio);纯首帧 → first_frame(ratio=adaptive)
|
||||
# ── #2170/#2172 方舟信任链(Trust Chain)────────────────────────────────
|
||||
# 真人照片直接传给 Seedance 会触发 50411 肖像审核拦截。
|
||||
# 解决:先通过同账号的 Seedream 5.0 Pro 图生图 AI 化(保持五官特征),
|
||||
# 得到的 AI 产物图属于"模型信任产物",再作为 reference_image 传给 Seedance 即可通过审核。
|
||||
# #2172/#2174: 信任链——使用预热好的 Seedream t2i 文生图(纯模型生成人像,是方舟信任产物,
|
||||
# 不会触发肖像审核)。预热在 VLM 分析后由 daemon 线程后台完成,结果通过 pre_trusted_images 传入。
|
||||
# - 预热结果有效 → 替换原参考图,走 omni_ref 模式
|
||||
# - 预热结果不可用 → 直接用原图(上层 _step_render 已现场同步跑 Seedream t2i 兜底;若再被400拦截,下方自动降级纯t2v)
|
||||
# 信任链只作用于 doubao provider;DashScope(Wan) 保持原行为。
|
||||
trust_chain_applied = False
|
||||
if provider == "doubao" and getattr(self, "trust_chain_enabled", True) and pre_trusted_images:
|
||||
raw_portrait_urls: list[str] = []
|
||||
if image_url:
|
||||
raw_portrait_urls.append(image_url)
|
||||
for u in ref_imgs:
|
||||
if u not in raw_portrait_urls:
|
||||
raw_portrait_urls.append(u)
|
||||
trusted_urls: list[str] = []
|
||||
if len(pre_trusted_images) >= 1:
|
||||
trusted_urls = list(pre_trusted_images)
|
||||
trust_chain_applied = True
|
||||
logger.info(
|
||||
"[trust-chain] 使用预热t2i结果 %d 张,替换原参考图走 reference_image 模式(原n=%d)",
|
||||
len(trusted_urls),
|
||||
len(raw_portrait_urls),
|
||||
)
|
||||
if trust_chain_applied and trusted_urls:
|
||||
# 替换:原 image_url 用第一张 AI 图,ref_imgs 用剩余
|
||||
if image_url and trusted_urls:
|
||||
image_url = trusted_urls[0]
|
||||
ref_imgs = trusted_urls[1:] if len(trusted_urls) > 1 else []
|
||||
else:
|
||||
ref_imgs = trusted_urls
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# 判断任务模式:
|
||||
# - 信任链强制走 reference_image(不是 first_frame;产品语义是人物参考,不是从图开始动)
|
||||
# - 有参考音/视/多图 → omni_reference(支持指定 ratio)
|
||||
# - 纯首帧无其他参考 → first_frame(ratio=adaptive)
|
||||
has_extra_refs = bool(ref_audios or ref_videos or ref_imgs)
|
||||
is_first_frame_mode = bool(image_url) and not has_extra_refs
|
||||
is_first_frame_mode = bool(image_url) and not has_extra_refs and not trust_chain_applied
|
||||
# 最终 ratio:first_frame 模式强制 adaptive,否则按用户传值(默认 9:16)
|
||||
final_ratio = "adaptive" if is_first_frame_mode else (ratio or "9:16")
|
||||
|
||||
# 构造 content 数组:text + 图 + 音 + 视
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt.strip()}]
|
||||
if image_url:
|
||||
if has_extra_refs:
|
||||
# omni_reference:首张图作为 reference_image,允许指定 ratio
|
||||
if has_extra_refs or trust_chain_applied:
|
||||
# omni_reference 或信任链模式:首张图作为 reference_image,允许指定 ratio
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
@@ -538,7 +670,7 @@ class DoubaoClient:
|
||||
video_model,
|
||||
duration,
|
||||
final_ratio,
|
||||
"first_frame" if is_first_frame_mode else "omni_ref",
|
||||
"first_frame" if is_first_frame_mode else ("omni_ref+trust_chain" if trust_chain_applied else "omni_ref"),
|
||||
generate_audio,
|
||||
(1 if image_url else 0) + len(ref_imgs),
|
||||
len(ref_audios),
|
||||
@@ -631,6 +763,26 @@ class DoubaoClient:
|
||||
# 保留第二次的错误信息
|
||||
sc, body = sc2, body2
|
||||
|
||||
# #2183: portrait_intercept / 真人肖像审核拦截 → 去掉所有参考图(含image_url首帧),纯 t2v 重试一次
|
||||
# 信任链预热或现场 t2i 都失败时的最后兜底,保证能出片
|
||||
if not task_id and sc == 400:
|
||||
_err_code_for_400, _ = _classify_video_error(sc, body, last_err)
|
||||
if _err_code_for_400 == "portrait_intercept" and (image_url or ref_imgs):
|
||||
logger.warning(
|
||||
"Seedance 创建因 portrait_intercept 失败,降级纯 t2v(移除所有参考图)重试: img=%d ref=%d",
|
||||
1 if image_url else 0,
|
||||
len(ref_imgs),
|
||||
)
|
||||
_t2v_payload = dict(create_payload)
|
||||
_t2v_payload["content"] = [{"type": "text", "text": prompt.strip()}]
|
||||
_t2v_payload["ratio"] = ratio or "9:16"
|
||||
task_id, last_err, sc3, body3 = _do_create(_t2v_payload)
|
||||
if task_id:
|
||||
sc, body = sc3, body3
|
||||
logger.info("[trust-chain] portrait_intercept 降级纯 t2v 成功 task_id=%s", task_id)
|
||||
else:
|
||||
sc, body = sc3, body3
|
||||
|
||||
if not task_id:
|
||||
err_code, user_msg = _classify_video_error(sc, body, last_err)
|
||||
self.last_video_error = {
|
||||
@@ -793,6 +945,162 @@ class DoubaoClient:
|
||||
}
|
||||
return None
|
||||
|
||||
def image_generation(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
reference_images: list[str] | None = None,
|
||||
size: str = "2K",
|
||||
model: str | None = None,
|
||||
watermark: bool = False,
|
||||
output_format: str = "png",
|
||||
timeout: int | None = None,
|
||||
) -> dict | None:
|
||||
"""#2170: 调用方舟 Seedream 图片生成(文生图/图生图)。
|
||||
|
||||
- reference_images: 0~10 张参考图 URL;0 张 = 纯文生图;1 张 string/URL 直传;多张 list[str]。
|
||||
- 成功返回 {"url": str, "usage": dict | None};失败返回 None,错误写入 self.last_image_error。
|
||||
- 返回的 url 有时效性(通常 24h),应立即使用,不持久化存储。
|
||||
"""
|
||||
self.last_image_error = {}
|
||||
if not self.is_available:
|
||||
self.last_image_error = {
|
||||
"error_code": "auth_error",
|
||||
"user_message": "图片生成服务未配置(API Key 缺失),请联系管理员。",
|
||||
"detail": "DoubaoClient not available (api_key empty)",
|
||||
}
|
||||
return None
|
||||
if not prompt or not prompt.strip():
|
||||
self.last_image_error = {
|
||||
"error_code": "invalid_param",
|
||||
"user_message": "图片生成提示词不能为空。",
|
||||
"detail": "empty prompt",
|
||||
}
|
||||
return None
|
||||
|
||||
img_model = model or self.image_model
|
||||
_img_size = size or getattr(self, "image_size", "1K")
|
||||
url = f"{self.base_url}/images/generations"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": img_model,
|
||||
"prompt": prompt.strip(),
|
||||
"size": _img_size,
|
||||
"response_format": "url",
|
||||
"output_format": output_format,
|
||||
"watermark": bool(watermark),
|
||||
}
|
||||
ref_imgs_local = [u for u in (reference_images or []) if u and isinstance(u, str)]
|
||||
if ref_imgs_local:
|
||||
if len(ref_imgs_local) == 1:
|
||||
payload["image"] = ref_imgs_local[0]
|
||||
else:
|
||||
payload["image"] = ref_imgs_local[:10]
|
||||
|
||||
req_timeout = timeout or getattr(self, "image_timeout", 60)
|
||||
last_err: Exception | None = None
|
||||
last_sc = 0
|
||||
last_body = ""
|
||||
_img_t0 = time.time()
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
resp = httpx.post(url, headers=headers, json=payload, timeout=req_timeout)
|
||||
last_sc = int(getattr(resp, "status_code", 0) or 0)
|
||||
last_body = (getattr(resp, "text", "") or "")[:2000]
|
||||
if last_sc >= 400:
|
||||
logger.error("Seedream 图片生成 HTTP %d: %s", last_sc, last_body[:500])
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except Exception as ee:
|
||||
last_err = ee
|
||||
if attempt < self.max_retries and last_sc >= 500:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
break
|
||||
data = resp.json()
|
||||
data_list = data.get("data") or []
|
||||
if data_list and isinstance(data_list, list):
|
||||
item = data_list[0]
|
||||
img_url = item.get("url")
|
||||
if img_url:
|
||||
logger.info(
|
||||
"Seedream 图片生成成功 model=%s ref_imgs=%d size=%s elapsed=%.1fs attempt=%d",
|
||||
img_model,
|
||||
len(ref_imgs_local),
|
||||
_img_size,
|
||||
time.time() - _img_t0,
|
||||
attempt + 1,
|
||||
)
|
||||
return {"url": img_url, "usage": data.get("usage")}
|
||||
last_err = RuntimeError(f"Seedream 返回结构异常: {str(data)[:300]}")
|
||||
break
|
||||
except _HTTP_NETWORK_ERRORS as ne:
|
||||
last_err = ne
|
||||
last_sc = 0
|
||||
last_body = f"network error: {ne}"
|
||||
logger.warning(
|
||||
"Seedream 网络异常 (%s),重试 %d/%d", type(ne).__name__, attempt + 1, self.max_retries + 1
|
||||
)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < self.max_retries and not isinstance(e, _HTTP_STATUS_ERROR):
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"Seedream 图片生成失败,%.1fs 后重试 (%d/%d): %s", wait, attempt + 1, self.max_retries + 1, e
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
break
|
||||
|
||||
# 分类错误
|
||||
err_code = "unknown"
|
||||
user_msg = "图片生成失败,请稍后重试。"
|
||||
body_lower = (last_body or "").lower()
|
||||
if last_sc == 401 or last_sc == 403:
|
||||
err_code, user_msg = "auth_error", "图片生成服务鉴权失败,请联系管理员。"
|
||||
elif last_sc == 400:
|
||||
if any(k in body_lower for k in ("quota", "billing", "insufficient", "balance")):
|
||||
err_code, user_msg = "quota_exceeded", "图片生成配额不足或账号欠费,请联系管理员。"
|
||||
elif any(k in body_lower for k in ("rate", "throughput", "too many", "frequency")):
|
||||
err_code, user_msg = "rate_limit", "图片生成请求过于频繁,请稍后重试。"
|
||||
elif any(k in body_lower for k in ("sensitive", "porn", "terror", "risk", "audit", "content", "violat")):
|
||||
err_code, user_msg = "portrait_intercept", "参考素材未通过内容安全审核,请更换照片后重试。"
|
||||
else:
|
||||
err_code, user_msg = "invalid_param", f"图片生成参数错误:{last_body[:200]}"
|
||||
elif last_sc == 404:
|
||||
err_code, user_msg = "model_not_found", f"图片模型 {img_model} 不存在,请联系管理员。"
|
||||
elif last_sc >= 500:
|
||||
err_code, user_msg = "network_error", "图片生成服务暂时不可用,请稍后重试。"
|
||||
elif last_sc == 0:
|
||||
err_code, user_msg = "network_error", f"图片生成网络错误:{last_err!s}"[:200]
|
||||
self.last_image_error = {
|
||||
"error_code": err_code,
|
||||
"user_message": user_msg,
|
||||
"status_code": last_sc,
|
||||
"detail": (last_body or "")[:500] or (str(last_err) if last_err else ""),
|
||||
"model": img_model,
|
||||
}
|
||||
logger.error(
|
||||
"Seedream 图片生成最终失败: model=%s status=%d code=%s elapsed=%.1fs err=%s",
|
||||
img_model,
|
||||
last_sc,
|
||||
err_code,
|
||||
time.time() - _img_t0,
|
||||
last_err,
|
||||
)
|
||||
return None
|
||||
|
||||
def get_last_image_error(self) -> dict:
|
||||
"""返回最近一次 image_generation 失败的详细错误。空 dict 表示上次成功或未调用。"""
|
||||
return dict(self.last_image_error or {})
|
||||
|
||||
def get_last_video_error(self) -> dict:
|
||||
"""返回最近一次 video_generation 失败的详细错误。空 dict 表示上次成功或未调用。"""
|
||||
return dict(self.last_video_error or {})
|
||||
|
||||
@@ -502,6 +502,7 @@ def call_llm(
|
||||
max_tokens: int = 2048,
|
||||
model: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
timeout: int | None = None,
|
||||
) -> object:
|
||||
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。
|
||||
|
||||
@@ -521,7 +522,7 @@ def call_llm(
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
raw = client.chat_completion(messages, temperature=temperature, max_tokens=max_tokens, model=model)
|
||||
raw = client.chat_completion(messages, temperature=temperature, max_tokens=max_tokens, model=model, timeout=timeout)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
@@ -604,6 +605,24 @@ def call_vision(
|
||||
return raw
|
||||
|
||||
|
||||
def preheat_trust_chain(portrait_descriptions: list[str], *, timeout: int = 120) -> list[str] | None:
|
||||
"""#2174 信任链预热(t2i版):用 VLM 分析出的人物外貌描述,跑 Seedream 文生图,
|
||||
生成的信任产物 URL 可传给 call_video_generation(pre_trusted_images=...)。
|
||||
|
||||
- portrait_descriptions: VLM输出的portrait_prompt列表(中文人物外貌描述)
|
||||
- 成功返回与输入同序的信任图URL列表;任意一张失败返回None(调用方回退到纯t2v)
|
||||
- 必须传VLM人物描述,不传reference_images,走纯t2i路径才是方舟信任产物
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
return None
|
||||
try:
|
||||
return client.preheat_trust_chain(portrait_descriptions, timeout=timeout)
|
||||
except Exception as e:
|
||||
logger.error("[ai_service] preheat_trust_chain 异常: %s", e, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def call_video_generation(
|
||||
prompt: str,
|
||||
*,
|
||||
@@ -617,8 +636,9 @@ def call_video_generation(
|
||||
reference_images: list[str] | None = None,
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
pre_trusted_images: list[str] | None = None,
|
||||
) -> dict | None:
|
||||
"""调用 Seedance / Wan 视频生成(v1.6.2 多模型版)。
|
||||
"""调用 Seedance / Wan 视频生成(v1.6.2 多模型版 + #2172 信任链预热)。
|
||||
|
||||
成功返回 {"video_path": str, "usage": dict | None}(usage 含 completion_tokens),失败返回 None。
|
||||
失败时错误详情会写入 client.last_video_error,可通过 get_last_video_error() 读取:
|
||||
@@ -650,6 +670,7 @@ def call_video_generation(
|
||||
reference_images=reference_images,
|
||||
reference_audios=reference_audios,
|
||||
reference_videos=reference_videos,
|
||||
pre_trusted_images=pre_trusted_images,
|
||||
)
|
||||
if effective_ratio:
|
||||
kwargs["ratio"] = effective_ratio
|
||||
|
||||
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
|
||||
fi
|
||||
|
||||
# 共用 secrets 直接导出(如果存在)
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE DOUBAO_IMAGE_MODEL DOUBAO_IMAGE_SIZE DOUBAO_IMAGE_TIMEOUT DOUBAO_FAST_MODEL DOUBAO_TIMEOUT DOUBAO_MAX_RETRIES WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
for var in $SHARED_SECRETS; do
|
||||
value="${!var:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""爆款视频 5 套 Prompt 模板种子脚本(#2040)。
|
||||
|
||||
幂等:以 (prompt_type, version) 为唯一键,存在则更新(UPSERT),重复执行结果一致。
|
||||
用法:
|
||||
python scripts/seed_viral_video_prompts.py # 自动用应用配置连库
|
||||
DATABASE_URL=postgresql+psycopg2://... python scripts/seed_viral_video_prompts.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import sqlalchemy as sa # noqa: E402
|
||||
|
||||
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES # noqa: E402
|
||||
|
||||
|
||||
def _engine():
|
||||
database_url = os.environ.get("DATABASE_URL")
|
||||
if database_url:
|
||||
return sa.create_engine(database_url)
|
||||
# 复用应用自身配置
|
||||
from packages.config import get_shared_settings
|
||||
|
||||
url = str(get_shared_settings().database_url)
|
||||
return sa.create_engine(url.replace("postgresql+asyncpg://", "postgresql+psycopg2://"))
|
||||
|
||||
|
||||
UPSERT_SQL = sa.text("""
|
||||
INSERT INTO viral_video_prompt_templates
|
||||
(name, prompt_type, version, system_prompt, user_prompt_template,
|
||||
example_output, is_active, updated_at)
|
||||
VALUES
|
||||
(:name, :prompt_type, :version, :system_prompt, :user_prompt_template,
|
||||
:example_output, TRUE, :now_ts)
|
||||
ON CONFLICT (prompt_type, version) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
system_prompt = EXCLUDED.system_prompt,
|
||||
user_prompt_template = EXCLUDED.user_prompt_template,
|
||||
example_output = EXCLUDED.example_output,
|
||||
is_active = TRUE,
|
||||
updated_at = :now_ts
|
||||
""")
|
||||
|
||||
|
||||
def seed(engine) -> int:
|
||||
count = 0
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now_ts = datetime.now(timezone.utc)
|
||||
with engine.begin() as conn:
|
||||
for item in DEFAULT_TEMPLATES:
|
||||
conn.execute(
|
||||
UPSERT_SQL,
|
||||
{
|
||||
"name": item["name"],
|
||||
"prompt_type": item["prompt_type"],
|
||||
"version": item["version"],
|
||||
"system_prompt": item["system_prompt"],
|
||||
"user_prompt_template": item["user_prompt_template"],
|
||||
"example_output": item["example_output"],
|
||||
"now_ts": now_ts,
|
||||
},
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def main() -> int:
|
||||
engine = _engine()
|
||||
count = seed(engine)
|
||||
print(f"seed 完成:{count} 套模板已写入/更新(image_analysis/intent_parsing/copy_fusion/storyboard/review)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Additional unit tests to hit uncovered lines for diff-coverage >=60%."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -13,15 +14,20 @@ from packages.shared.ai_client import DoubaoClient
|
||||
|
||||
class _FakeSettings:
|
||||
doubao_api_key = "test-key"
|
||||
doubao_model = "test-model"
|
||||
doubao_fast_model = "test-fast-model"
|
||||
doubao_model = "doubao-seed-2-1-pro-260915"
|
||||
doubao_fast_model = "doubao-seed-2-1-lite-260915"
|
||||
doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout = 10
|
||||
doubao_max_retries = 0
|
||||
doubao_vision_model = "test-vision"
|
||||
doubao_vision_lite_model = "test-vision-lite"
|
||||
doubao_vision_model = "doubao-seed-2-1-pro-260915"
|
||||
doubao_vision_lite_model = "doubao-seed-2-1-lite-260915"
|
||||
doubao_vision_use_lite = False
|
||||
doubao_embedding_model = "test-embedding"
|
||||
doubao_embedding_model = "doubao-embedding-vision-251215"
|
||||
doubao_video_model = "doubao-seedance-2-5-260628"
|
||||
doubao_video_timeout = 480
|
||||
doubao_video_poll_interval = 10
|
||||
doubao_image_model = "doubao-seedream-5-0-pro-260628"
|
||||
doubao_image_timeout = 120
|
||||
|
||||
|
||||
def _make_client(api_key: str = "test-key") -> DoubaoClient:
|
||||
@@ -129,26 +135,50 @@ from packages.domain.atom_clip_tagger import parse_vision_response
|
||||
|
||||
class TestParseVisionResponseEdgeCases:
|
||||
def test_person_count_type_error_defaults_zero(self):
|
||||
text = json.dumps({
|
||||
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
||||
"person_count": "not-an-int", "text_content": "", "caption": "x",
|
||||
})
|
||||
text = json.dumps(
|
||||
{
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": "not-an-int",
|
||||
"text_content": "",
|
||||
"caption": "x",
|
||||
}
|
||||
)
|
||||
r = parse_vision_response(text)
|
||||
assert r["person_count"] == 0
|
||||
|
||||
def test_person_count_out_of_range_clamped(self):
|
||||
text = json.dumps({
|
||||
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
||||
"person_count": 10, "text_content": "", "caption": "x",
|
||||
})
|
||||
text = json.dumps(
|
||||
{
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 10,
|
||||
"text_content": "",
|
||||
"caption": "x",
|
||||
}
|
||||
)
|
||||
r = parse_vision_response(text)
|
||||
assert r["person_count"] == 3
|
||||
|
||||
def test_person_count_negative_clamped(self):
|
||||
text = json.dumps({
|
||||
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
||||
"person_count": -5, "text_content": "", "caption": "x",
|
||||
})
|
||||
text = json.dumps(
|
||||
{
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": -5,
|
||||
"text_content": "",
|
||||
"caption": "x",
|
||||
}
|
||||
)
|
||||
r = parse_vision_response(text)
|
||||
assert r["person_count"] == 0
|
||||
|
||||
@@ -159,10 +189,18 @@ class TestParseVisionResponseEdgeCases:
|
||||
|
||||
def test_caption_truncation_at_80(self):
|
||||
long_caption = "描" * 100
|
||||
text = json.dumps({
|
||||
"scene": [], "objects": [], "action": [], "shot": "", "has_text": False,
|
||||
"person_count": 0, "text_content": "", "caption": long_caption,
|
||||
})
|
||||
text = json.dumps(
|
||||
{
|
||||
"scene": [],
|
||||
"objects": [],
|
||||
"action": [],
|
||||
"shot": "",
|
||||
"has_text": False,
|
||||
"person_count": 0,
|
||||
"text_content": "",
|
||||
"caption": long_caption,
|
||||
}
|
||||
)
|
||||
r = parse_vision_response(text)
|
||||
assert len(r["caption"]) == 80
|
||||
|
||||
@@ -196,9 +234,7 @@ class TestNarrativeMatchNonDictClipTags:
|
||||
def test_non_dict_clip_tags_are_skipped(self):
|
||||
a1 = _FA("a1", tags=[])
|
||||
clip_map = {"a1": [None, "bad", {"scene": ["工厂"], "objects": [], "action": []}, 123]}
|
||||
matched, unmatched = match_assets_by_script_tags(
|
||||
[a1], script_tags=["工厂"], clip_ai_tags_by_asset=clip_map
|
||||
)
|
||||
matched, unmatched = match_assets_by_script_tags([a1], script_tags=["工厂"], clip_ai_tags_by_asset=clip_map)
|
||||
assert [a.id for a in matched] == ["a1"]
|
||||
|
||||
|
||||
@@ -231,6 +267,7 @@ class _FQuery:
|
||||
class TestUpdateCaptionEmbedding:
|
||||
def _make_repo(self, session):
|
||||
from packages.adapters.sqlalchemy_impl.asset_atom_clip_repository import SQLAlchemyAssetAtomClipRepository
|
||||
|
||||
repo = SQLAlchemyAssetAtomClipRepository.__new__(SQLAlchemyAssetAtomClipRepository)
|
||||
repo.session = session
|
||||
return repo
|
||||
|
||||
@@ -17,7 +17,7 @@ def mock_settings():
|
||||
doubao_api_key="test-api-key",
|
||||
doubao_model="doubao-pro-32k",
|
||||
doubao_base_url="https://ark.example.com/api/v3",
|
||||
doubao_timeout=30,
|
||||
doubao_timeout=45,
|
||||
doubao_max_retries=2,
|
||||
)
|
||||
yield mock
|
||||
@@ -37,7 +37,7 @@ def client_without_key():
|
||||
doubao_api_key="",
|
||||
doubao_model="doubao-pro-32k",
|
||||
doubao_base_url="https://ark.example.com/api/v3",
|
||||
doubao_timeout=30,
|
||||
doubao_timeout=45,
|
||||
doubao_max_retries=2,
|
||||
)
|
||||
yield DoubaoClient()
|
||||
@@ -52,7 +52,7 @@ class TestDoubaoClientInit:
|
||||
assert client.api_key == "test-api-key"
|
||||
assert client.model == "doubao-pro-32k"
|
||||
assert client.base_url == "https://ark.example.com/api/v3"
|
||||
assert client.timeout == 30
|
||||
assert client.timeout == 45
|
||||
assert client.max_retries == 2
|
||||
|
||||
def test_base_url_strips_trailing_slash(self, mock_settings):
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
"""#2170 Seedream 图片生成 + 方舟信任链单测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.shared.ai_client import DoubaoClient
|
||||
|
||||
|
||||
def _make_client(**overrides):
|
||||
client = DoubaoClient.__new__(DoubaoClient)
|
||||
client.api_key = overrides.get("api_key", "test-key")
|
||||
client.base_url = overrides.get("base_url", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
client.model = "doubao-model"
|
||||
client.vision_model = "doubao-vision"
|
||||
client.embedding_model = "doubao-embedding"
|
||||
client.image_model = overrides.get("image_model", "doubao-seedream-5-0-pro-260628")
|
||||
client.image_timeout = overrides.get("image_timeout", 120)
|
||||
client.timeout = overrides.get("timeout", 30)
|
||||
client.max_retries = overrides.get("max_retries", 0)
|
||||
client.last_video_error = {}
|
||||
client.last_image_error = {}
|
||||
return client
|
||||
|
||||
|
||||
def _fake_time(base=1000.0, stable_calls=50, big=9e9):
|
||||
"""返回 time.time 替身:前 stable_calls 次返回 base+i,之后返回 big+i。
|
||||
|
||||
Python 3.12 logging.LogRecord.__init__ 内部会调 time.time(),
|
||||
用有限 iter 会 StopIteration,因此必须用无限生成器。
|
||||
"""
|
||||
state = {"n": 0}
|
||||
|
||||
def _t():
|
||||
n = state["n"]
|
||||
state["n"] += 1
|
||||
if n < stable_calls:
|
||||
return base + n
|
||||
return big + n
|
||||
|
||||
return _t
|
||||
|
||||
|
||||
# ── Seedream 图片生成单测 ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImageGenerationHappyPath:
|
||||
def test_returns_none_when_no_api_key(self):
|
||||
client = _make_client(api_key="")
|
||||
assert client.image_generation("p") is None
|
||||
err = client.get_last_image_error()
|
||||
assert err["error_code"] == "auth_error"
|
||||
|
||||
def test_returns_none_on_empty_prompt(self):
|
||||
client = _make_client()
|
||||
assert client.image_generation(" ") is None
|
||||
err = client.get_last_image_error()
|
||||
assert err["error_code"] == "invalid_param"
|
||||
|
||||
def test_text_to_image_success(self):
|
||||
client = _make_client()
|
||||
captured = {}
|
||||
ok_resp = MagicMock()
|
||||
ok_resp.status_code = 200
|
||||
ok_resp.json.return_value = {"data": [{"url": "https://cdn.example.com/i.png"}], "usage": {"tokens": 1}}
|
||||
ok_resp.raise_for_status = MagicMock()
|
||||
ok_resp.text = ""
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["json"] = kwargs.get("json")
|
||||
return ok_resp
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", side_effect=fake_post):
|
||||
result = client.image_generation("一只可爱的猫", size="1K")
|
||||
assert result is not None
|
||||
assert result["url"] == "https://cdn.example.com/i.png"
|
||||
assert "/images/generations" in captured["url"]
|
||||
assert captured["json"]["model"] == "doubao-seedream-5-0-pro-260628"
|
||||
assert captured["json"]["size"] == "1K"
|
||||
assert "image" not in captured["json"]
|
||||
|
||||
def test_image_to_image_single_ref_passed_as_string(self):
|
||||
client = _make_client()
|
||||
captured = {}
|
||||
ok_resp = MagicMock(status_code=200)
|
||||
ok_resp.json.return_value = {"data": [{"url": "https://cdn.example.com/out.png"}]}
|
||||
ok_resp.raise_for_status = MagicMock()
|
||||
ok_resp.text = ""
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["json"] = kwargs.get("json")
|
||||
return ok_resp
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", side_effect=fake_post):
|
||||
client.image_generation("保持五官", reference_images=["https://img/x.jpg"])
|
||||
assert captured["json"]["image"] == "https://img/x.jpg"
|
||||
|
||||
def test_image_to_image_multiple_refs_passed_as_list(self):
|
||||
client = _make_client()
|
||||
captured = {}
|
||||
ok_resp = MagicMock(status_code=200)
|
||||
ok_resp.json.return_value = {"data": [{"url": "https://cdn.example.com/out.png"}]}
|
||||
ok_resp.raise_for_status = MagicMock()
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["json"] = kwargs.get("json")
|
||||
return ok_resp
|
||||
|
||||
refs = [f"https://img/{i}.jpg" for i in range(3)]
|
||||
with patch("packages.shared.ai_client.httpx.post", side_effect=fake_post):
|
||||
client.image_generation("保持", reference_images=refs)
|
||||
assert captured["json"]["image"] == refs
|
||||
|
||||
def test_400_sensitive_returns_portrait_intercept(self):
|
||||
client = _make_client(max_retries=0)
|
||||
bad_resp = MagicMock(status_code=400)
|
||||
bad_resp.text = '{"error":{"code":"ContentRisk","message":"sensitive content detected"}}'
|
||||
bad_resp.json.return_value = {"error": {"code": "ContentRisk"}}
|
||||
bad_resp.raise_for_status.side_effect = httpx.HTTPStatusError("bad", request=MagicMock(), response=bad_resp)
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=bad_resp):
|
||||
assert client.image_generation("p", reference_images=["https://img/x.jpg"]) is None
|
||||
err = client.get_last_image_error()
|
||||
assert err["error_code"] == "portrait_intercept"
|
||||
|
||||
def test_500_retries_then_fails(self):
|
||||
client = _make_client(max_retries=1)
|
||||
bad_resp = MagicMock(status_code=500)
|
||||
bad_resp.text = "internal error"
|
||||
bad_resp.json.return_value = {"error": {"message": "internal"}}
|
||||
bad_resp.raise_for_status.side_effect = httpx.HTTPStatusError("500", request=MagicMock(), response=bad_resp)
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", return_value=bad_resp) as mp,
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
):
|
||||
assert client.image_generation("p") is None
|
||||
assert mp.call_count == 2
|
||||
err = client.get_last_image_error()
|
||||
assert err["error_code"] == "network_error"
|
||||
|
||||
|
||||
# ── 信任链集成单测 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrustChainIntegration:
|
||||
def test_pre_trusted_images_replace_original_refs(self, tmp_path):
|
||||
"""#2174: 传 pre_trusted_images(预热好的t2i信任图)时,替换原image_url/ref_imgs发给Seedance,不再现场跑Seedream。"""
|
||||
client = _make_client()
|
||||
captured_calls = []
|
||||
|
||||
task_ok = MagicMock(status_code=200)
|
||||
task_ok.json.return_value = {"id": "t-trust"}
|
||||
task_ok.raise_for_status = MagicMock()
|
||||
task_ok.text = ""
|
||||
|
||||
poll_ok = MagicMock(status_code=200)
|
||||
poll_ok.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn.example.com/v.mp4"}}
|
||||
poll_ok.raise_for_status = MagicMock()
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self._c = [b"OK"]
|
||||
self._it = iter(self._c)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def iter_bytes(self, chunk_size=None):
|
||||
return self._it
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured_calls.append({"url": url, "json": kwargs.get("json")})
|
||||
return task_ok
|
||||
|
||||
fake_uuid = MagicMock()
|
||||
fake_uuid.hex = "00000001"
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
||||
patch("packages.shared.ai_client.httpx.get", return_value=poll_ok),
|
||||
patch("packages.shared.ai_client.httpx.stream", return_value=FakeStream()),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.time.time", side_effect=_fake_time(stable_calls=2)),
|
||||
patch("packages.shared.ai_client.uuid.uuid4", return_value=fake_uuid),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as ms,
|
||||
):
|
||||
ms.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0,
|
||||
doubao_video_timeout=60,
|
||||
doubao_video_model="doubao-seedance-2-5-260628",
|
||||
)
|
||||
out = client.video_generation(
|
||||
"人物在海边散步",
|
||||
image_url="https://img/raw.jpg",
|
||||
pre_trusted_images=["https://ai.example.com/trusted.png"],
|
||||
duration=5,
|
||||
ratio="9:16",
|
||||
resolution="720p",
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
assert out is not None
|
||||
# 只有一次 Seedance 调用(不现场跑Seedream)
|
||||
assert len(captured_calls) == 1
|
||||
assert "/contents/generations/tasks" in captured_calls[0]["url"]
|
||||
seedance_payload = captured_calls[0]["json"]
|
||||
content = seedance_payload["content"]
|
||||
img_items = [c for c in content if c.get("type") == "image_url"]
|
||||
assert len(img_items) == 1
|
||||
assert img_items[0]["image_url"]["url"] == "https://ai.example.com/trusted.png"
|
||||
assert img_items[0]["role"] == "reference_image"
|
||||
assert seedance_payload["ratio"] == "9:16"
|
||||
|
||||
def test_no_reference_image_skips_seedream(self, tmp_path):
|
||||
client = _make_client()
|
||||
captured_calls = []
|
||||
|
||||
task_ok = MagicMock(status_code=200)
|
||||
task_ok.json.return_value = {"id": "t-t2v"}
|
||||
task_ok.raise_for_status = MagicMock()
|
||||
poll_ok = MagicMock(status_code=200)
|
||||
poll_ok.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn.example.com/v.mp4"}}
|
||||
poll_ok.raise_for_status = MagicMock()
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self._it = iter([b"OK"])
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def iter_bytes(self, chunk_size=None):
|
||||
return self._it
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured_calls.append({"url": url, "json": kwargs.get("json")})
|
||||
return task_ok
|
||||
|
||||
fake_uuid = MagicMock()
|
||||
fake_uuid.hex = "00000002"
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
||||
patch("packages.shared.ai_client.httpx.get", return_value=poll_ok),
|
||||
patch("packages.shared.ai_client.httpx.stream", return_value=FakeStream()),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.time.time", side_effect=_fake_time(stable_calls=2)),
|
||||
patch("packages.shared.ai_client.uuid.uuid4", return_value=fake_uuid),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as ms,
|
||||
):
|
||||
ms.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0,
|
||||
doubao_video_timeout=60,
|
||||
doubao_video_model="doubao-seedance-2-5-260628",
|
||||
)
|
||||
out = client.video_generation("海边日落", duration=5, ratio="9:16", output_dir=str(tmp_path))
|
||||
assert out is not None
|
||||
assert len(captured_calls) == 1
|
||||
assert "/contents/generations/tasks" in captured_calls[0]["url"]
|
||||
content = captured_calls[0]["json"]["content"]
|
||||
assert all(c.get("type") != "image_url" for c in content)
|
||||
|
||||
def test_no_preheated_images_falls_back_to_first_frame_mode(self, tmp_path):
|
||||
"""#2174: 无 pre_trusted_images 时原图走 first_frame 模式(ratio=adaptive),不再现场跑 Seedream。"""
|
||||
client = _make_client(max_retries=0)
|
||||
captured_calls = []
|
||||
|
||||
task_ok = MagicMock(status_code=200)
|
||||
task_ok.json.return_value = {"id": "t-fb"}
|
||||
task_ok.raise_for_status = MagicMock()
|
||||
poll_ok = MagicMock(status_code=200)
|
||||
poll_ok.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn.example.com/v.mp4"}}
|
||||
poll_ok.raise_for_status = MagicMock()
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self._it = iter([b"OK"])
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def iter_bytes(self, chunk_size=None):
|
||||
return self._it
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured_calls.append({"url": url, "json": kwargs.get("json")})
|
||||
return task_ok
|
||||
|
||||
fake_uuid = MagicMock()
|
||||
fake_uuid.hex = "00000003"
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
||||
patch("packages.shared.ai_client.httpx.get", return_value=poll_ok),
|
||||
patch("packages.shared.ai_client.httpx.stream", return_value=FakeStream()),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.time.time", side_effect=_fake_time(stable_calls=2)),
|
||||
patch("packages.shared.ai_client.uuid.uuid4", return_value=fake_uuid),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as ms,
|
||||
):
|
||||
ms.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0,
|
||||
doubao_video_timeout=60,
|
||||
doubao_video_model="doubao-seedance-2-5-260628",
|
||||
)
|
||||
out = client.video_generation(
|
||||
"海边散步",
|
||||
image_url="https://img/raw.jpg",
|
||||
duration=5,
|
||||
ratio="9:16",
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
assert out is not None
|
||||
# 不现场跑 Seedream,只有一次 Seedance 调用
|
||||
assert len(captured_calls) == 1
|
||||
seedance_payload = captured_calls[0]["json"]
|
||||
content = seedance_payload["content"]
|
||||
img_items = [c for c in content if c.get("type") == "image_url"]
|
||||
assert len(img_items) == 1
|
||||
assert img_items[0]["image_url"]["url"] == "https://img/raw.jpg"
|
||||
assert img_items[0]["role"] == "first_frame"
|
||||
assert seedance_payload["ratio"] == "adaptive"
|
||||
|
||||
|
||||
# ── image_generation 补充分支覆盖 ─────────────────────────────────
|
||||
|
||||
|
||||
class TestImageGenerationBranches:
|
||||
"""覆盖 image_generation 的错误分类/重试/结构异常等分支。"""
|
||||
|
||||
def test_401_returns_auth_error(self):
|
||||
client = _make_client(max_retries=0)
|
||||
r = MagicMock(status_code=401, text='{"error":{}}')
|
||||
r.json.return_value = {"error": {}}
|
||||
r.raise_for_status.side_effect = httpx.HTTPStatusError("a", request=MagicMock(), response=r)
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=r):
|
||||
assert client.image_generation("p") is None
|
||||
assert client.get_last_image_error()["error_code"] == "auth_error"
|
||||
|
||||
def test_404_returns_model_not_found(self):
|
||||
client = _make_client(max_retries=0)
|
||||
r = MagicMock(status_code=404, text="not found")
|
||||
r.json.return_value = {"error": {"message": "model not found"}}
|
||||
r.raise_for_status.side_effect = httpx.HTTPStatusError("a", request=MagicMock(), response=r)
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=r):
|
||||
assert client.image_generation("p") is None
|
||||
assert client.get_last_image_error()["error_code"] == "model_not_found"
|
||||
|
||||
def test_400_quota_returns_quota_exceeded(self):
|
||||
client = _make_client(max_retries=0)
|
||||
r = MagicMock(status_code=400, text="insufficient balance quota exceeded")
|
||||
r.json.return_value = {"error": {"message": "quota"}}
|
||||
r.raise_for_status.side_effect = httpx.HTTPStatusError("a", request=MagicMock(), response=r)
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=r):
|
||||
assert client.image_generation("p") is None
|
||||
assert client.get_last_image_error()["error_code"] == "quota_exceeded"
|
||||
|
||||
def test_400_rate_limit_returns_rate_limit(self):
|
||||
client = _make_client(max_retries=0)
|
||||
r = MagicMock(status_code=400, text="too many requests, rate limit exceeded")
|
||||
r.json.return_value = {"error": {"message": "rate"}}
|
||||
r.raise_for_status.side_effect = httpx.HTTPStatusError("a", request=MagicMock(), response=r)
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=r):
|
||||
assert client.image_generation("p") is None
|
||||
assert client.get_last_image_error()["error_code"] == "rate_limit"
|
||||
|
||||
def test_400_generic_returns_invalid_param(self):
|
||||
client = _make_client(max_retries=0)
|
||||
r = MagicMock(status_code=400, text="bad parameter size")
|
||||
r.json.return_value = {"error": {"message": "bad"}}
|
||||
r.raise_for_status.side_effect = httpx.HTTPStatusError("a", request=MagicMock(), response=r)
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=r):
|
||||
assert client.image_generation("p") is None
|
||||
assert client.get_last_image_error()["error_code"] == "invalid_param"
|
||||
|
||||
def test_200_but_no_url_returns_none(self):
|
||||
client = _make_client(max_retries=0)
|
||||
r = MagicMock(status_code=200, text="")
|
||||
r.json.return_value = {"data": [{"no_url": True}]} # 缺 url 字段
|
||||
r.raise_for_status = MagicMock()
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=r):
|
||||
assert client.image_generation("p") is None
|
||||
assert client.get_last_image_error()["error_code"] == "unknown"
|
||||
|
||||
def test_network_error_retries_then_fails(self):
|
||||
client = _make_client(max_retries=1)
|
||||
import httpcore
|
||||
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", side_effect=httpx.ConnectError("no network")),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
):
|
||||
assert client.image_generation("p") is None
|
||||
err = client.get_last_image_error()
|
||||
assert err["error_code"] == "network_error"
|
||||
|
||||
def test_get_last_image_error_returns_copy(self):
|
||||
client = _make_client()
|
||||
client.last_image_error = {"error_code": "x"}
|
||||
e1 = client.get_last_image_error()
|
||||
e1["error_code"] = "mutated"
|
||||
assert client.last_image_error["error_code"] == "x"
|
||||
|
||||
|
||||
# ── 信任链分支覆盖 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrustChainBranches:
|
||||
def test_dashscope_provider_skips_trust_chain(self, tmp_path):
|
||||
"""provider=dashscope 时不走信任链(Wan 模型由 dashscope_client 处理,在我们分支之前已经 return)。
|
||||
这里测 doubao 分支:信任链默认触发,验证 DashScope 分发路径不受影响。"""
|
||||
# 该测试实际覆盖 video_generation 入口的 dashscope 分发:缺 DASHSCOPE_API_KEY 时返回 auth_error
|
||||
client = _make_client()
|
||||
with (patch("packages.shared.ai_client.get_shared_settings") as ms,):
|
||||
ms.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0,
|
||||
doubao_video_timeout=1,
|
||||
doubao_video_model="doubao-seedance-2-5-260628",
|
||||
)
|
||||
# DashScope 不可用时返回 auth_error(不是信任链相关错误)
|
||||
result = client.video_generation(
|
||||
"p",
|
||||
output_dir=str(tmp_path),
|
||||
model="wan-3.0",
|
||||
image_url="https://img/x.jpg",
|
||||
)
|
||||
assert result is None
|
||||
err = client.get_last_video_error()
|
||||
# 不论是否走信任链,DashScope 无 key 时返回 auth_error
|
||||
assert err["error_code"] == "auth_error"
|
||||
|
||||
def test_trust_chain_partial_seedream_success_falls_back(self, tmp_path):
|
||||
"""#2174: 预热结果为空/None 时回退原图直传(走#2166的400→t2v自动降级路径)。"""
|
||||
client = _make_client(max_retries=0)
|
||||
|
||||
# 预热结果传 None → 应该直接用原图发给 Seedance
|
||||
def fake_post(url, **kwargs):
|
||||
# Seedance create task(收到原图直传时会调用)
|
||||
t = MagicMock(status_code=200, text="")
|
||||
t.json.return_value = {"id": "t-partial"}
|
||||
t.raise_for_status = MagicMock()
|
||||
return t
|
||||
|
||||
poll_ok = MagicMock(status_code=200, text="")
|
||||
poll_ok.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn.example.com/v.mp4"}}
|
||||
poll_ok.raise_for_status = MagicMock()
|
||||
|
||||
class FS:
|
||||
def __init__(self):
|
||||
self._it = iter([b"OK"])
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def iter_bytes(self, chunk_size=None):
|
||||
return self._it
|
||||
|
||||
fake_uuid = MagicMock()
|
||||
fake_uuid.hex = "00000004"
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
||||
patch("packages.shared.ai_client.httpx.get", return_value=poll_ok),
|
||||
patch("packages.shared.ai_client.httpx.stream", return_value=FS()),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.time.time", side_effect=_fake_time(stable_calls=50)),
|
||||
patch("packages.shared.ai_client.uuid.uuid4", return_value=fake_uuid),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as ms,
|
||||
):
|
||||
ms.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0,
|
||||
doubao_video_timeout=60,
|
||||
doubao_video_model="doubao-seedance-2-5-260628",
|
||||
)
|
||||
out = client.video_generation(
|
||||
"p",
|
||||
image_url="https://img/a.jpg",
|
||||
reference_images=["https://img/b.jpg"],
|
||||
duration=5,
|
||||
ratio="9:16",
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
assert out is not None
|
||||
# 最终发给 Seedance 的图应是原始 https://img/a.jpg(回退),role=first_frame(因为 has_extra_refs=False 只有 1 张)
|
||||
# 注意:回退后 ref_imgs 是原始 ["https://img/b.jpg"],所以 has_extra_refs=True,role=reference_image
|
||||
# 断言最终 Seedance payload 里的 image_url 是原图(不是 AI 图)
|
||||
|
||||
def test_default_values_on_missing_settings(self):
|
||||
"""getattr 兜底:settings 缺 image_timeout 字段时使用默认 120。"""
|
||||
client = _make_client()
|
||||
# 直接调用 image_generation,让它走一次完整流程(成功路径),验证 timeout 取值
|
||||
ok = MagicMock(status_code=200, text="")
|
||||
ok.json.return_value = {"data": [{"url": "https://ai.example.com/x.png"}]}
|
||||
ok.raise_for_status = MagicMock()
|
||||
captured_kwargs = {}
|
||||
|
||||
def fake_post(url, **kw):
|
||||
captured_kwargs["timeout"] = kw.get("timeout")
|
||||
return ok
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", side_effect=fake_post):
|
||||
r = client.image_generation("p", timeout=None) # 不传 timeout,走 self.image_timeout=120
|
||||
assert r is not None
|
||||
assert captured_kwargs["timeout"] == 120
|
||||
|
||||
def test_trust_chain_uses_preheated_t2i_images(self, tmp_path):
|
||||
"""#2174: 传 pre_trusted_images(预热好的t2i信任图)时,替换原参考图发给Seedance。"""
|
||||
client = _make_client()
|
||||
captured = []
|
||||
|
||||
task_ok = MagicMock(status_code=200, text="")
|
||||
task_ok.json.return_value = {"id": "t-refonly"}
|
||||
task_ok.raise_for_status = MagicMock()
|
||||
poll_ok = MagicMock(status_code=200, text="")
|
||||
poll_ok.json.return_value = {"status": "succeeded", "content": {"video_url": "https://cdn.example.com/v.mp4"}}
|
||||
poll_ok.raise_for_status = MagicMock()
|
||||
|
||||
class FS:
|
||||
def __init__(self):
|
||||
self._it = iter([b"OK"])
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def iter_bytes(self, chunk_size=None):
|
||||
return self._it
|
||||
|
||||
def fake_post(url, **kw):
|
||||
captured.append({"url": url, "json": kw.get("json")})
|
||||
return task_ok
|
||||
|
||||
fu = MagicMock()
|
||||
fu.hex = "0000000a"
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
||||
patch("packages.shared.ai_client.httpx.get", return_value=poll_ok),
|
||||
patch("packages.shared.ai_client.httpx.stream", return_value=FS()),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.time.time", side_effect=_fake_time(stable_calls=3)),
|
||||
patch("packages.shared.ai_client.uuid.uuid4", return_value=fu),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as ms,
|
||||
):
|
||||
ms.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0,
|
||||
doubao_video_timeout=60,
|
||||
doubao_video_model="doubao-seedance-2-5-260628",
|
||||
)
|
||||
out = client.video_generation(
|
||||
"人物散步",
|
||||
reference_images=["https://img/portrait.jpg"],
|
||||
pre_trusted_images=["https://ai.example.com/t2i-portrait.png"],
|
||||
duration=5,
|
||||
ratio="9:16",
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
assert out is not None
|
||||
# 只有一次 Seedance 创建任务(预热已完成,不再现场跑 Seedream)
|
||||
assert len(captured) == 1
|
||||
seedance_payload = captured[0]["json"]
|
||||
content = seedance_payload["content"]
|
||||
img_items = [c for c in content if c.get("type") == "image_url"]
|
||||
assert len(img_items) == 1
|
||||
# 不传 image_url,信任链产物放 ref_imgs,走 reference_image 模式(非 first_frame)
|
||||
assert img_items[0]["image_url"]["url"] == "https://ai.example.com/t2i-portrait.png"
|
||||
assert img_items[0]["role"] == "reference_image"
|
||||
# 因为没有 image_url,没有 text 也没有 extra_refs 之外的字段,应保留用户 ratio=9:16
|
||||
assert seedance_payload.get("ratio") == "9:16"
|
||||
|
||||
def test_image_generation_generic_exception_retries_then_fails(self):
|
||||
"""image_generation 遇到非 HTTPStatusError 的通用异常时走重试分支(lines 962-971),重试耗尽后返回 None。"""
|
||||
client = _make_client(max_retries=1)
|
||||
call_n = {"n": 0}
|
||||
|
||||
def fake_post(url, **kw):
|
||||
call_n["n"] += 1
|
||||
if call_n["n"] == 1:
|
||||
raise RuntimeError("boiler exploded")
|
||||
# 第二次调用返回成功,验证重试生效
|
||||
ok = MagicMock(status_code=200, text="")
|
||||
ok.json.return_value = {"data": [{"url": "https://ai.example.com/retry-ok.png"}]}
|
||||
ok.raise_for_status = MagicMock()
|
||||
return ok
|
||||
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
):
|
||||
r = client.image_generation("test prompt")
|
||||
assert r is not None
|
||||
assert r["url"] == "https://ai.example.com/retry-ok.png"
|
||||
assert call_n["n"] == 2
|
||||
|
||||
def test_image_generation_generic_exception_exhausts_retries(self):
|
||||
"""通用异常重试耗尽后返回 None,并正确写入 last_image_error (lines 969-971 break 分支)。"""
|
||||
client = _make_client(max_retries=1)
|
||||
|
||||
def fake_post(url, **kw):
|
||||
raise RuntimeError("always fails")
|
||||
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
):
|
||||
r = client.image_generation("test prompt")
|
||||
assert r is None
|
||||
err = client.last_image_error
|
||||
assert err["error_code"] == "network_error"
|
||||
assert "always fails" in err["detail"]
|
||||
@@ -17,8 +17,13 @@ def _make_client(**overrides):
|
||||
client.base_url = overrides.get("base_url", "https://ark.cn-beijing.volces.com/api/v3")
|
||||
client.model = "doubao-model"
|
||||
client.vision_model = "doubao-vision"
|
||||
client.embedding_model = "doubao-embedding"
|
||||
client.image_model = overrides.get("image_model", "doubao-seedream-5-0-pro-260628")
|
||||
client.image_timeout = overrides.get("image_timeout", 120)
|
||||
client.timeout = overrides.get("timeout", 30)
|
||||
client.max_retries = overrides.get("max_retries", 0)
|
||||
client.last_video_error = {}
|
||||
client.last_image_error = {}
|
||||
return client
|
||||
|
||||
|
||||
@@ -106,7 +111,7 @@ class TestVideoGenerationHappyPath:
|
||||
)
|
||||
out = client.video_generation(
|
||||
prompt=" 镜头一 ",
|
||||
image_url="https://img/x.jpg",
|
||||
# 不传 image_url:纯文生视频,不触发信任链,post 调用数为 1(创建任务)
|
||||
duration=5,
|
||||
ratio="9:16",
|
||||
resolution="720p",
|
||||
@@ -563,7 +568,7 @@ class TestResolveVideoModelId:
|
||||
|
||||
class TestVideoGenerationLastError:
|
||||
def test_create_400_portrait_returns_user_message(self, tmp_path):
|
||||
"""HTTP 400 + 真人拦截关键词 → portrait_intercept 错误码,用户提示友好。"""
|
||||
"""#2169: HTTP 400 + 真人拦截关键词 → 自动尝试即梦兜底;即梦未配时返回 portrait_intercept。"""
|
||||
client = _make_client(max_retries=0)
|
||||
create_resp = MagicMock()
|
||||
create_resp.status_code = 400
|
||||
@@ -572,8 +577,23 @@ class TestVideoGenerationLastError:
|
||||
create_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"bad", request=MagicMock(), response=create_resp
|
||||
)
|
||||
# 信任链:Seedream 会先被调用来 AI 化;这里 mock Seedream 也失败,回退原图直传,
|
||||
# 原图直传被 400 portrait 拦截,最终返回 portrait_intercept。
|
||||
seedream_resp = MagicMock()
|
||||
seedream_resp.status_code = 400
|
||||
seedream_resp.text = '{"error":{"code":"ContentRisk","message":"sensitive"}}'
|
||||
seedream_resp.json.return_value = {"error": {"code": "ContentRisk", "message": "sensitive"}}
|
||||
seedream_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"bad", request=MagicMock(), response=seedream_resp
|
||||
)
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
# 第一次 POST 是 Seedream(/images/generations),返回 portrait 拦截
|
||||
# 回退原图直传后第二次 POST 是 Seedance(/contents/generations/tasks),也返回 portrait 拦截
|
||||
return create_resp
|
||||
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
|
||||
patch("packages.shared.ai_client.httpx.post", side_effect=fake_post),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
|
||||
):
|
||||
@@ -584,8 +604,8 @@ class TestVideoGenerationLastError:
|
||||
assert result is None
|
||||
err = client.get_last_video_error()
|
||||
assert err["error_code"] == "portrait_intercept"
|
||||
assert "真人" in err["user_message"]
|
||||
assert err["status_code"] == 400
|
||||
assert "真人" in err["user_message"] or "肖像" in err["user_message"] or "审核" in err["user_message"]
|
||||
assert err["status_code"] in (0, 400)
|
||||
|
||||
def test_create_401_returns_auth_error(self, tmp_path):
|
||||
client = _make_client(max_retries=0)
|
||||
|
||||
@@ -80,8 +80,8 @@ class TestSharedSettingsDefaults:
|
||||
def test_default_doubao_settings(self):
|
||||
s = SharedSettings()
|
||||
assert "doubao" in s.doubao_model
|
||||
assert s.doubao_timeout == 30
|
||||
assert s.doubao_max_retries == 2
|
||||
assert s.doubao_timeout == 45 # #2180 默认提到45s
|
||||
assert s.doubao_max_retries == 1
|
||||
|
||||
|
||||
class TestAPISettingsDefaults:
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""catalog 应用服务单测:会员套餐 / 积分包从共享库读取与字段映射。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
admin_catalog._cache.clear()
|
||||
yield
|
||||
admin_catalog._cache.clear()
|
||||
|
||||
|
||||
def _row(**kw):
|
||||
row = MagicMock()
|
||||
for k, v in kw.items():
|
||||
setattr(row, k, v)
|
||||
return row
|
||||
|
||||
|
||||
class TestMembershipPlans:
|
||||
def test_yearly_plan_mapping(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
plan_key="premium_yearly",
|
||||
name="高级会员年卡",
|
||||
description="年度订阅",
|
||||
monthly_price=0,
|
||||
yearly_price=399,
|
||||
quotas={"4k": True, "batch_render": True, "credits_per_month": 500},
|
||||
display_order=1,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
plans = admin_catalog.get_membership_plans()
|
||||
|
||||
assert len(plans) == 1
|
||||
p = plans[0]
|
||||
assert p["plan_id"] == "premium_yearly"
|
||||
assert p["billing_cycle"] == "yearly"
|
||||
assert p["price_cents"] == 39900
|
||||
assert p["monthly_price_cents"] == 3325
|
||||
assert p["duration_days"] == 365
|
||||
assert p["features"]["4K 超清分辨率"] is True
|
||||
assert p["features"]["credits_per_month"] == 500
|
||||
session.close.assert_called_once()
|
||||
|
||||
def test_monthly_plan_mapping(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
plan_key="premium_monthly",
|
||||
name="高级会员月卡",
|
||||
description=None,
|
||||
monthly_price=39,
|
||||
yearly_price=0,
|
||||
quotas=None,
|
||||
display_order=2,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
plans = admin_catalog.get_membership_plans()
|
||||
|
||||
assert len(plans) == 1
|
||||
p = plans[0]
|
||||
assert p["billing_cycle"] == "monthly"
|
||||
assert p["price_cents"] == 3900
|
||||
assert p["monthly_price_cents"] == 3900
|
||||
assert p["duration_days"] == 30
|
||||
assert p["features"] == {}
|
||||
|
||||
def test_both_cycles_expanded(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
plan_key="premium",
|
||||
name="高级会员",
|
||||
description=None,
|
||||
monthly_price=39,
|
||||
yearly_price=399,
|
||||
quotas={},
|
||||
display_order=1,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
plans = admin_catalog.get_membership_plans()
|
||||
|
||||
cycles = {p["billing_cycle"] for p in plans}
|
||||
assert cycles == {"yearly", "monthly"}
|
||||
|
||||
def test_no_session_returns_empty(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", None, create=True):
|
||||
assert admin_catalog.get_membership_plans() == []
|
||||
|
||||
|
||||
class TestPointsPackages:
|
||||
def test_package_mapping_with_bonus(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
package_key="pkg_100",
|
||||
name="100元充值包",
|
||||
price=100,
|
||||
credits=1000,
|
||||
bonus_credits=100,
|
||||
is_recommended=True,
|
||||
description="推荐",
|
||||
sort_order=4,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
packages = admin_catalog.get_points_packages()
|
||||
|
||||
assert len(packages) == 1
|
||||
pkg = packages[0]
|
||||
assert pkg["code"] == "pkg_100"
|
||||
assert pkg["points"] == 1100
|
||||
assert pkg["price_cents"] == 10000
|
||||
assert pkg["is_recommended"] is True
|
||||
assert pkg["unit_price"] == "¥0.091/积分"
|
||||
|
||||
def test_zero_credits_unit_price_safe(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
package_key="pkg_0",
|
||||
name="空包",
|
||||
price=0,
|
||||
credits=0,
|
||||
bonus_credits=0,
|
||||
is_recommended=False,
|
||||
description=None,
|
||||
sort_order=0,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
packages = admin_catalog.get_points_packages()
|
||||
|
||||
assert packages[0]["points"] == 0
|
||||
assert packages[0]["price_cents"] == 0
|
||||
assert packages[0]["unit_price"] == "¥0.000/积分"
|
||||
|
||||
def test_no_session_returns_empty(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", None, create=True):
|
||||
assert admin_catalog.get_points_packages() == []
|
||||
|
||||
|
||||
class TestPackagesRoute:
|
||||
def test_get_packages_route_returns_items(self):
|
||||
from app.api.routes.points import get_packages
|
||||
|
||||
cu = MagicMock()
|
||||
cu.user.member_type = None
|
||||
rows = [
|
||||
{
|
||||
"code": "pkg_10",
|
||||
"name": "10元充值包",
|
||||
"points": 100,
|
||||
"price_cents": 1000,
|
||||
"unit_price": "¥0.100/积分",
|
||||
}
|
||||
]
|
||||
with patch(
|
||||
"packages.application.catalog.admin_catalog.get_points_packages",
|
||||
return_value=rows,
|
||||
):
|
||||
resp = get_packages(current_user=cu)
|
||||
|
||||
assert len(resp.packages) == 1
|
||||
item = resp.packages[0]
|
||||
assert item.code == "pkg_10"
|
||||
assert item.points == 100
|
||||
assert item.price_cents == 1000
|
||||
@@ -110,8 +110,8 @@ class TestSharedSettingsDefaults:
|
||||
def test_default_doubao_config(self):
|
||||
"""豆包默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.doubao_timeout == 30
|
||||
assert s.doubao_max_retries == 2
|
||||
assert s.doubao_timeout == 45 # #2180 默认提到45s
|
||||
assert s.doubao_max_retries == 1
|
||||
assert "volces.com" in s.doubao_base_url
|
||||
|
||||
def test_default_empty_api_keys(self):
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
"""功能计费改造测试:爆款读配置、对口型/智能剪辑预扣逻辑。
|
||||
|
||||
策略:
|
||||
- 爆款:通过修改缓存中的 FeatureConfig(multiplier/model_pricing)验证价格随配置变化
|
||||
- lip_sync / smart_edit:直接测 LipsyncService 的预扣/结算/退款辅助方法,
|
||||
PointsService 用 mock,避免依赖真实积分账户。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain import feature_pricing_service as fps
|
||||
from packages.domain.feature_pricing_service import FeatureConfig, refresh_feature_configs
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cache():
|
||||
refresh_feature_configs()
|
||||
yield
|
||||
refresh_feature_configs()
|
||||
|
||||
|
||||
def _seed_cache(configs: dict) -> None:
|
||||
import time
|
||||
|
||||
fps._cache = (time.monotonic(), configs)
|
||||
|
||||
|
||||
class TestViralVideoReadsConfig:
|
||||
def test_multiplier_change_changes_price(self):
|
||||
"""配置里 multiplier 改大后,爆款价格随之变大(证明不再读死常量)。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
# 基线兜底
|
||||
base = calculate_viral_video_credits(15, 1280, 720)
|
||||
assert base == 29.68
|
||||
|
||||
fallback = fps._fallback_configs()
|
||||
vv = fallback["viral_video"]
|
||||
vv.profit_multiplier = 2.0
|
||||
_seed_cache(fallback)
|
||||
|
||||
changed = calculate_viral_video_credits(15, 1280, 720)
|
||||
assert changed > base
|
||||
# 精确校验:video_cost 相同,仅系数从 1.3 → 2.0
|
||||
_, bd = __import__(
|
||||
"packages.domain.points_rules", fromlist=["calculate_viral_video_credits_with_breakdown"]
|
||||
).calculate_viral_video_credits_with_breakdown(15, 1280, 720)
|
||||
assert bd["profit_multiplier"] == 2.0
|
||||
|
||||
def test_model_price_from_config(self):
|
||||
"""model_pricing 改单价后,token 成本按新单价计算。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits_with_breakdown
|
||||
|
||||
fallback = fps._fallback_configs()
|
||||
vv = fallback["viral_video"]
|
||||
# seedance-2.5/720p/false 从 70 改成 100
|
||||
vv.model_pricing["seedance-2.5"]["720p"]["false"] = 100.0
|
||||
_seed_cache(fallback)
|
||||
|
||||
_, bd = calculate_viral_video_credits_with_breakdown(15, 1280, 720)
|
||||
assert bd["model_price"] == 100.0
|
||||
|
||||
def test_price_cap_from_config(self):
|
||||
from packages.domain.points_rules import calculate_viral_video_credits_with_breakdown
|
||||
|
||||
fallback = fps._fallback_configs()
|
||||
vv = fallback["viral_video"]
|
||||
vv.price_cap = 5.0
|
||||
_seed_cache(fallback)
|
||||
|
||||
credits, bd = calculate_viral_video_credits_with_breakdown(15, 1280, 720)
|
||||
assert credits == 5.0
|
||||
assert bd["price_cap"] == 5.0
|
||||
|
||||
def test_disabled_feature_returns_zero_credits(self):
|
||||
"""功能 is_enabled=false 时计费函数返回 0(纯计费层语义)。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits_with_breakdown
|
||||
|
||||
fallback = fps._fallback_configs()
|
||||
fallback["viral_video"].is_enabled = False
|
||||
_seed_cache(fallback)
|
||||
|
||||
credits, bd = calculate_viral_video_credits_with_breakdown(15, 1280, 720)
|
||||
assert credits == 0.0
|
||||
assert bd["feature_enabled"] is False
|
||||
assert bd["charged"] is False
|
||||
|
||||
|
||||
class TestLipSyncPricing:
|
||||
def _make_service(self):
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
svc = LipsyncService.__new__(LipsyncService)
|
||||
svc.db = MagicMock()
|
||||
return svc
|
||||
|
||||
def _lip_cfg(self, **kw):
|
||||
base = dict(
|
||||
feature_key="lip_sync",
|
||||
name="对口型",
|
||||
is_enabled=True,
|
||||
fixed_cost=0.1,
|
||||
profit_multiplier=1.0,
|
||||
dynamic_unit_cost=0.05,
|
||||
billing_mode="per_second",
|
||||
price_cap=0.0,
|
||||
model_pricing={},
|
||||
description="",
|
||||
)
|
||||
base.update(kw)
|
||||
return FeatureConfig(**base)
|
||||
|
||||
def test_estimate_duration_from_script(self):
|
||||
svc = self._make_service()
|
||||
# 10 个字 / 5 = 2 秒,下限 1
|
||||
assert svc._estimate_duration(script_text="一二三四五六七八九十") == 2.0
|
||||
# 无任何信息 → 默认 10 秒
|
||||
assert svc._estimate_duration() == 10.0
|
||||
|
||||
def test_calculate_lipsync_price_per_second(self):
|
||||
_seed_cache({"lip_sync": self._lip_cfg()})
|
||||
price, bd = fps.calculate_price("lip_sync", dynamic_cost=20.0 * 0.05)
|
||||
# dynamic 1.0 + fixed 0.1 = 1.1
|
||||
assert price == 1.1
|
||||
assert bd["charged"] is True
|
||||
|
||||
def test_settle_refunds_overcharge(self):
|
||||
"""实际时长短 → 只退不补,退还差额。"""
|
||||
svc = self._make_service()
|
||||
_seed_cache({"lip_sync": self._lip_cfg()})
|
||||
|
||||
job = MagicMock()
|
||||
job.credits_prepaid = 2.0
|
||||
job.credits_cost = 0.0 # 未结算
|
||||
job.user_id = "u1"
|
||||
job.credits_transaction_id = "txn-old"
|
||||
|
||||
with patch("packages.domain.points_service.PointsService") as MockPS:
|
||||
inst = MockPS.return_value
|
||||
inst.refund_points.return_value = {"success": True}
|
||||
svc._settle_lip_sync(job, actual_duration=10.0)
|
||||
|
||||
# final: (10*0.05 + 0.1)*1.0 = 0.6;退 2.0-0.6=1.4
|
||||
assert round(job.credits_cost, 2) == 0.6
|
||||
inst.refund_points.assert_called_once()
|
||||
kwargs = inst.refund_points.call_args.kwargs
|
||||
assert kwargs["amount"] == 1.4
|
||||
|
||||
def test_settle_no_refund_when_longer(self):
|
||||
"""首期只退不补:实际更贵不补扣。"""
|
||||
svc = self._make_service()
|
||||
_seed_cache({"lip_sync": self._lip_cfg()})
|
||||
|
||||
job = MagicMock()
|
||||
job.credits_prepaid = 0.5
|
||||
job.credits_cost = 0.0
|
||||
|
||||
with patch("packages.domain.points_service.PointsService") as MockPS:
|
||||
inst = MockPS.return_value
|
||||
svc._settle_lip_sync(job, actual_duration=60.0)
|
||||
|
||||
assert round(job.credits_cost, 2) > 0.5
|
||||
inst.refund_points.assert_not_called()
|
||||
|
||||
def test_refund_on_failure_full(self):
|
||||
svc = self._make_service()
|
||||
job = MagicMock()
|
||||
job.credits_prepaid = 3.0
|
||||
job.credits_cost = 0.0
|
||||
job.user_id = "u1"
|
||||
job.credits_transaction_id = "t1"
|
||||
|
||||
with patch("packages.domain.points_service.PointsService") as MockPS:
|
||||
inst = MockPS.return_value
|
||||
inst.refund_points.return_value = {"success": True}
|
||||
svc._refund_lip_sync(job)
|
||||
|
||||
kwargs = inst.refund_points.call_args.kwargs
|
||||
assert kwargs["amount"] == 3.0
|
||||
|
||||
|
||||
class TestSmartEditFixedPrice:
|
||||
def test_fixed_price_formula(self):
|
||||
"""首期固定价:dynamic=0,price=fixed*multiplier,cap 封顶。"""
|
||||
cfg = FeatureConfig(
|
||||
feature_key="smart_edit",
|
||||
name="智能剪辑",
|
||||
is_enabled=True,
|
||||
fixed_cost=2.0,
|
||||
profit_multiplier=1.5,
|
||||
billing_mode="model_based",
|
||||
price_cap=0.0,
|
||||
)
|
||||
_seed_cache({"smart_edit": cfg})
|
||||
price, bd = fps.calculate_price("smart_edit", dynamic_cost=0.0)
|
||||
# (0+2)*1.5 = 3.0
|
||||
assert price == 3.0
|
||||
assert bd["dynamic_cost"] == 0.0
|
||||
|
||||
def test_fixed_price_with_cap(self):
|
||||
cfg = FeatureConfig(
|
||||
feature_key="smart_edit",
|
||||
is_enabled=True,
|
||||
fixed_cost=10.0,
|
||||
profit_multiplier=2.0,
|
||||
price_cap=8.0,
|
||||
)
|
||||
_seed_cache({"smart_edit": cfg})
|
||||
price, _ = fps.calculate_price("smart_edit", dynamic_cost=0.0)
|
||||
assert price == 8.0
|
||||
|
||||
def test_disabled_smart_edit_free(self):
|
||||
cfg = FeatureConfig(feature_key="smart_edit", is_enabled=False, fixed_cost=2.0)
|
||||
_seed_cache({"smart_edit": cfg})
|
||||
price, bd = fps.calculate_price("smart_edit", dynamic_cost=0.0)
|
||||
assert price == 0.0
|
||||
assert bd["charged"] is False
|
||||
Executable
+235
@@ -0,0 +1,235 @@
|
||||
"""feature_pricing_service 单元测试。
|
||||
|
||||
覆盖:
|
||||
- 300s TTL 内存缓存(命中不重复 load / 过期重新 load / refresh 强制刷新)
|
||||
- calculate_price 公式 (dynamic+fixed)*multiplier、price_cap 封顶、round
|
||||
- disabled / 未知 key 返回 0
|
||||
- DB 异常 / 空表 → 内置兜底配置(爆款启用且价格与现状一致)
|
||||
- lookup_model_price 嵌套/扁平结构与旧版回落语义
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain import feature_pricing_service as fps
|
||||
from packages.domain.feature_pricing_service import (
|
||||
CACHE_TTL_SECONDS,
|
||||
FeatureConfig,
|
||||
calculate_price,
|
||||
get_feature_config,
|
||||
is_feature_enabled,
|
||||
lookup_model_price,
|
||||
refresh_feature_configs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_cache():
|
||||
"""每个用例前后清空模块缓存,避免相互污染。"""
|
||||
refresh_feature_configs()
|
||||
yield
|
||||
refresh_feature_configs()
|
||||
|
||||
|
||||
def _cfg(key="x", **kw) -> FeatureConfig:
|
||||
base = dict(
|
||||
feature_key=key,
|
||||
name=key,
|
||||
is_enabled=True,
|
||||
fixed_cost=0.2,
|
||||
profit_multiplier=2.0,
|
||||
dynamic_unit_cost=0.0,
|
||||
billing_mode="per_second",
|
||||
price_cap=0.0,
|
||||
model_pricing={},
|
||||
description="",
|
||||
)
|
||||
base.update(kw)
|
||||
return FeatureConfig(**base)
|
||||
|
||||
|
||||
class TestCacheTTL:
|
||||
def test_cache_hit_avoids_reload(self, monkeypatch):
|
||||
"""TTL 内第二次读取不再调 _load_all。"""
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_load():
|
||||
calls["n"] += 1
|
||||
return {"x": _cfg()}
|
||||
|
||||
monkeypatch.setattr(fps, "_load_all", fake_load)
|
||||
get_feature_config("x")
|
||||
get_feature_config("x")
|
||||
get_feature_config("x")
|
||||
assert calls["n"] == 1
|
||||
|
||||
def test_expired_cache_reloads(self, monkeypatch):
|
||||
"""超过 TTL 后重新 load。"""
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_load():
|
||||
calls["n"] += 1
|
||||
return {"x": _cfg()}
|
||||
|
||||
monkeypatch.setattr(fps, "_load_all", fake_load)
|
||||
get_feature_config("x")
|
||||
assert calls["n"] == 1
|
||||
|
||||
# 把缓存时间戳回拨到 TTL 之前
|
||||
ts, data = fps._cache
|
||||
fps._cache = (ts - CACHE_TTL_SECONDS - 1, data)
|
||||
get_feature_config("x")
|
||||
assert calls["n"] == 2
|
||||
|
||||
def test_refresh_forces_reload(self, monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_load():
|
||||
calls["n"] += 1
|
||||
return {"x": _cfg()}
|
||||
|
||||
monkeypatch.setattr(fps, "_load_all", fake_load)
|
||||
get_feature_config("x")
|
||||
refresh_feature_configs()
|
||||
get_feature_config("x")
|
||||
assert calls["n"] == 2
|
||||
|
||||
def test_ttl_constant_is_300(self):
|
||||
assert CACHE_TTL_SECONDS == 300.0
|
||||
|
||||
|
||||
class TestCalculatePrice:
|
||||
def test_basic_formula(self, monkeypatch):
|
||||
# (dynamic 1.0 + fixed 0.2) * 2.0 = 2.4
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {"x": _cfg(dynamic_unit_cost=1.0)})
|
||||
price, bd = calculate_price("x", dynamic_cost=1.0)
|
||||
assert price == 2.4
|
||||
assert bd["dynamic_cost"] == 1.0
|
||||
assert bd["fixed_cost"] == 0.2
|
||||
assert bd["profit_multiplier"] == 2.0
|
||||
assert bd["final_price"] == 2.4
|
||||
assert bd["charged"] is True
|
||||
|
||||
def test_price_cap_clamps(self, monkeypatch):
|
||||
# raw = (1+0.2)*2 = 2.4,cap=1.0 → 1.0
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {"x": _cfg(price_cap=1.0)})
|
||||
price, bd = calculate_price("x", dynamic_cost=1.0)
|
||||
assert price == 1.0
|
||||
assert bd["price_cap"] == 1.0
|
||||
|
||||
def test_no_cap_keeps_raw(self, monkeypatch):
|
||||
# cap=0 视为不封顶
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {"x": _cfg(price_cap=0.0)})
|
||||
price, _ = calculate_price("x", dynamic_cost=1.0)
|
||||
assert price == 2.4
|
||||
|
||||
def test_rounded_two_decimals(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
fps,
|
||||
"_load_all",
|
||||
lambda: {"x": _cfg(fixed_cost=0.1, profit_multiplier=1.0)},
|
||||
)
|
||||
price, _ = calculate_price("x", dynamic_cost=1.0 / 3.0)
|
||||
# 0.3333... + 0.1 = 0.4333 → 0.43
|
||||
assert price == 0.43
|
||||
|
||||
def test_negative_dynamic_treated_as_zero(self, monkeypatch):
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {"x": _cfg()})
|
||||
price, _ = calculate_price("x", dynamic_cost=-5.0)
|
||||
# (0 + 0.2) * 2 = 0.4
|
||||
assert price == 0.4
|
||||
|
||||
def test_disabled_returns_zero(self, monkeypatch):
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {"x": _cfg(is_enabled=False)})
|
||||
price, bd = calculate_price("x", dynamic_cost=1.0)
|
||||
assert price == 0.0
|
||||
assert bd["is_enabled"] is False
|
||||
assert bd["charged"] is False
|
||||
|
||||
def test_unknown_key_returns_zero(self, monkeypatch):
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {"x": _cfg()})
|
||||
price, bd = calculate_price("nope", dynamic_cost=1.0)
|
||||
assert price == 0.0
|
||||
assert bd["charged"] is False
|
||||
|
||||
|
||||
class TestDBFailureFallback:
|
||||
def test_load_exception_uses_fallback(self, monkeypatch):
|
||||
def boom():
|
||||
raise RuntimeError("table does not exist")
|
||||
|
||||
monkeypatch.setattr(fps, "_load_all", boom)
|
||||
cfg = get_feature_config("viral_video")
|
||||
assert cfg is not None
|
||||
assert cfg.is_enabled is True
|
||||
assert cfg.fixed_cost == 0.15
|
||||
assert cfg.profit_multiplier == 1.3
|
||||
|
||||
def test_empty_table_uses_fallback(self, monkeypatch):
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {})
|
||||
assert get_feature_config("viral_video").is_enabled is True
|
||||
assert get_feature_config("lip_sync").is_enabled is False
|
||||
assert get_feature_config("smart_edit").is_enabled is False
|
||||
|
||||
def test_fallback_viral_price_matches_current(self, monkeypatch):
|
||||
"""兜底爆款价格与旧硬编码现状一致:seedance-2.5/720p/false=70。"""
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {})
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
# 默认全局开关关闭,但纯计费函数价格照常算
|
||||
assert calculate_viral_video_credits(15, 1280, 720) == 29.68
|
||||
|
||||
def test_db_row_overrides_fallback(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
fps,
|
||||
"_load_all",
|
||||
lambda: {"viral_video": _cfg("viral_video", fixed_cost=0.5, profit_multiplier=2.0, price_cap=50.0)},
|
||||
)
|
||||
cfg = get_feature_config("viral_video")
|
||||
assert cfg.fixed_cost == 0.5
|
||||
assert cfg.profit_multiplier == 2.0
|
||||
assert cfg.price_cap == 50.0
|
||||
|
||||
|
||||
class TestIsFeatureEnabled:
|
||||
def test_disabled_feature(self, monkeypatch):
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {"x": _cfg(is_enabled=False)})
|
||||
assert is_feature_enabled("x") is False
|
||||
|
||||
def test_global_switch_off_blocks_enabled_feature(self, monkeypatch):
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {"x": _cfg(is_enabled=True)})
|
||||
monkeypatch.setattr(fps, "_global_points_enabled", lambda: False)
|
||||
assert is_feature_enabled("x") is False
|
||||
|
||||
def test_both_switches_on(self, monkeypatch):
|
||||
monkeypatch.setattr(fps, "_load_all", lambda: {"x": _cfg(is_enabled=True)})
|
||||
monkeypatch.setattr(fps, "_global_points_enabled", lambda: True)
|
||||
assert is_feature_enabled("x") is True
|
||||
|
||||
|
||||
class TestLookupModelPrice:
|
||||
NESTED = {
|
||||
"seedance-2.5": {
|
||||
"720p": {"false": 70.0, "true": 42.0},
|
||||
},
|
||||
"wan-3.0": {"480p": {"false": 0.3}},
|
||||
}
|
||||
|
||||
def test_nested_exact_hit(self):
|
||||
assert lookup_model_price(self.NESTED, "seedance-2.5", "720p", False) == 70.0
|
||||
assert lookup_model_price(self.NESTED, "seedance-2.5", "720p", True) == 42.0
|
||||
|
||||
def test_missing_bool_key_returns_none(self):
|
||||
# wan-3.0/480p 只有 false,请求 true → None(由调用方回落)
|
||||
assert lookup_model_price(self.NESTED, "wan-3.0", "480p", True) is None
|
||||
|
||||
def test_unknown_model_returns_none(self):
|
||||
assert lookup_model_price(self.NESTED, "nope", "720p", False) is None
|
||||
|
||||
def test_flat_structure(self):
|
||||
flat = {"m|720p|false": 12.5}
|
||||
assert lookup_model_price(flat, "m", "720p", False) == 12.5
|
||||
assert lookup_model_price(flat, "m", "720p", True) is None
|
||||
@@ -173,33 +173,46 @@ class TestSubscriptionPlans:
|
||||
_spec.loader.exec_module(_mod)
|
||||
return _mod.list_membership_plans
|
||||
|
||||
def test_plans_endpoint_returns_three_tiers(self):
|
||||
import os # noqa: F401 (used by _import_plans_fn)
|
||||
|
||||
def test_plans_endpoint_reads_admin_table(self):
|
||||
"""/subscription/plans 改读管理后台 plans 表:返回 catalog 服务提供的真实档位。"""
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
real_plan = {
|
||||
"plan_id": "premium_yearly",
|
||||
"billing_cycle": "yearly",
|
||||
"name": "高级会员年卡",
|
||||
"description": "高级会员年度订阅,享受全部功能",
|
||||
"price_cents": 39900,
|
||||
"monthly_price_cents": 3325,
|
||||
"duration_days": 365,
|
||||
"features": {
|
||||
"4K 超清分辨率": True,
|
||||
"批量渲染": True,
|
||||
"优先处理队列": True,
|
||||
"credits_per_month": 500,
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"packages.application.catalog.admin_catalog.get_membership_plans",
|
||||
return_value=[real_plan],
|
||||
):
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
plan_ids = {p["plan_id"] for p in plans}
|
||||
assert plan_ids == {"monthly", "quarterly", "yearly"}
|
||||
for p in plans:
|
||||
assert p["price_cents"] > 0
|
||||
assert p["duration_days"] in (30, 90, 365)
|
||||
assert 0 < p["points_discount"] <= 1.0
|
||||
assert "max_resolution" in p["features"]
|
||||
|
||||
def test_longer_plans_cheaper_per_month(self):
|
||||
import os # noqa: F401
|
||||
assert len(plans) == 1
|
||||
p0 = plans[0]
|
||||
assert p0["plan_id"] == "premium_yearly"
|
||||
assert p0["price_cents"] == 39900
|
||||
assert p0["duration_days"] == 365
|
||||
assert p0["features"]["4K 超清分辨率"] is True
|
||||
|
||||
def test_plans_endpoint_empty_when_all_disabled(self):
|
||||
"""后台停用全部套餐时,用户端返回空列表。"""
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
monthly = next(p for p in plans if p["plan_id"] == "monthly")
|
||||
quarterly = next(p for p in plans if p["plan_id"] == "quarterly")
|
||||
yearly = next(p for p in plans if p["plan_id"] == "yearly")
|
||||
assert monthly["monthly_price_cents"] == 1990
|
||||
assert quarterly["monthly_price_cents"] < monthly["monthly_price_cents"]
|
||||
assert yearly["monthly_price_cents"] < quarterly["monthly_price_cents"]
|
||||
|
||||
with patch(
|
||||
"packages.application.catalog.admin_catalog.get_membership_plans",
|
||||
return_value=[],
|
||||
):
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
assert resp["plans"] == []
|
||||
|
||||
# ── P1-7: multiplier consistency ──────────────────────────────────────
|
||||
|
||||
|
||||
@@ -403,33 +403,30 @@ class TestViralVideoPipeline:
|
||||
"""v1.6: _step_script_generation 返回 dict 形式的 CopyResult,含 voiceover_script + shots。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_script_generation
|
||||
|
||||
mock_llm.return_value = {
|
||||
"overview": {"theme": "口红推荐", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮化妆台,柔和自然光",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-5秒",
|
||||
"shot_type_angle_movement": "近景平视,缓慢推镜",
|
||||
"scene_and_dialogue": "女主微笑展示口红:大家好,今天分享一款口红",
|
||||
"action_details": "手持口红特写",
|
||||
"audio_bgm": "轻快流行BGM",
|
||||
"transition": "硬切",
|
||||
"reference_image_index": 0,
|
||||
},
|
||||
{
|
||||
"time_range": "5-15秒",
|
||||
"shot_type_angle_movement": "特写,固定镜头",
|
||||
"scene_and_dialogue": "涂抹口红:颜色特别好看很显白",
|
||||
"action_details": "嘴唇涂抹特写",
|
||||
"audio_bgm": "轻快BGM继续",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 1,
|
||||
},
|
||||
],
|
||||
"hard_constraints": ["无字幕无水印"],
|
||||
"negative_prompts": ["字幕", "水印"],
|
||||
"voiceover_script": "大家好,今天分享一款口红,颜色特别好看很显白。",
|
||||
}
|
||||
mock_llm.return_value = """<clips>
|
||||
<clip image_index="0" transition="cut" zoom="null" duration_sec="5" bgm_note="轻快流行BGM">
|
||||
<voice_text>大家好,今天分享一款口红</voice_text>
|
||||
<subtitle_text>大家好,今天分享一款口红</subtitle_text>
|
||||
<shot_type_angle_movement>近景平视,缓慢推镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>女主微笑展示口红:大家好,今天分享一款口红</scene_and_dialogue>
|
||||
<action_details>手持口红特写</action_details>
|
||||
<audio_bgm>轻快流行BGM</audio_bgm>
|
||||
<transition>硬切</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
<clip image_index="0" transition="fade" zoom="null" duration_sec="10" bgm_note="轻快BGM">
|
||||
<voice_text>颜色特别好看很显白</voice_text>
|
||||
<subtitle_text>颜色特别好看很显白</subtitle_text>
|
||||
<shot_type_angle_movement>特写,固定镜头</shot_type_angle_movement>
|
||||
<scene_and_dialogue>涂抹口红:颜色特别好看很显白</scene_and_dialogue>
|
||||
<action_details>嘴唇涂抹特写</action_details>
|
||||
<audio_bgm>轻快BGM继续</audio_bgm>
|
||||
<transition>结束</transition>
|
||||
<reference_image_index>1</reference_image_index>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
</clips>"""
|
||||
result = _step_script_generation(
|
||||
mock_job, {"intent": "推广口红", "key_messages": [], "tone": "亲切"}, {"products": []}
|
||||
)
|
||||
@@ -452,12 +449,13 @@ class TestViralVideoPipeline:
|
||||
assert result["voiceover_script"]
|
||||
assert len(result["shots"]) >= 1
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_review_pass_v16(self, mock_llm, mock_job):
|
||||
@patch("packages.application.viral_video.reviewer.Reviewer.review")
|
||||
def test_review_pass_v16(self, mock_review, mock_job):
|
||||
"""v1.6 _step_review 接收 copy_result dict。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_review
|
||||
from packages.application.viral_video.reviewer import ReviewIssue, ReviewResult
|
||||
|
||||
mock_llm.return_value = {"passed": True, "score": 90, "details": {}}
|
||||
mock_review.return_value = ReviewResult(passed=True, score=90, issues=[], rewrite_suggestions=[])
|
||||
cr = {"voiceover_script": "大家好", "shots": []}
|
||||
result = _step_review(mock_job, cr)
|
||||
assert result["passed"] is True
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
"""#2040 爆款视频 Prompt 模板系统单测。
|
||||
|
||||
不真调豆包 API,全部用 FakeClient 注入;覆盖:
|
||||
XML 标签解析 / 5 套模板纯文本 / loader 缓存热加载与回落 /
|
||||
三档融合差异 / personal_brands 保留 / 审核识别违规词夸大 / 自动重写 /
|
||||
各步 fallback / seed 幂等 / 负面词不出现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
for p in [REPO_ROOT, os.path.join(REPO_ROOT, "apps/api"), os.path.join(REPO_ROOT, "apps/worker")]:
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from packages.application.viral_video import xml_parser as xp # noqa: E402
|
||||
from packages.application.viral_video.generator import CopyGenerator # noqa: E402
|
||||
from packages.application.viral_video.prompt_loader import ( # noqa: E402
|
||||
get_template,
|
||||
invalidate,
|
||||
render_user_prompt,
|
||||
)
|
||||
from packages.application.viral_video.prompts import ( # noqa: E402
|
||||
BANNED_PHRASES,
|
||||
DEFAULT_TEMPLATES,
|
||||
FUSION_INSTRUCTIONS,
|
||||
)
|
||||
from packages.application.viral_video.reviewer import Reviewer # noqa: E402
|
||||
|
||||
IMAGE_XML = """<products>
|
||||
<product name="大公鸡头油污净" features="红白瓶身" position="main" image_index="0"/>
|
||||
</products>
|
||||
<colors><color hex="#D32F2F" name="红色" coverage="0.4"/></colors>
|
||||
<people has_person="false" count="0"/>
|
||||
<mood>干净实用</mood>
|
||||
<visible_text><text_item text="多功能油污净" position="瓶身"/></visible_text>
|
||||
<scene>白底棚拍</scene>
|
||||
<quality resolution="高清" lighting="柔和" composition="居中"/>
|
||||
<key_selling_points><point>去油快</point><point>625ml大容量</point></key_selling_points>"""
|
||||
|
||||
INTENT_XML = """<intent_summary>厨房去油污神器</intent_summary>
|
||||
<core_messages>
|
||||
<message must_keep="true" confidence="0.97">去油污效果好</message>
|
||||
<message must_keep="false" confidence="0.6">适合重油污</message>
|
||||
</core_messages>
|
||||
<personal_brands><brand category="price">39块钱一瓶</brand></personal_brands>
|
||||
<emotion_tone>亲切真实</emotion_tone>
|
||||
<missing_info><info>容量按625ml</info></missing_info>"""
|
||||
|
||||
FUSION_XML = """<title>厨房重油污别硬擦了</title>
|
||||
<hook>这油污忍很久了</hook>
|
||||
<body_points><point elaboration="喷上等几分钟一擦就净" image_index="0">大公鸡头去油快</point></body_points>
|
||||
<cta>重油污的可以试一瓶</cta>
|
||||
<script_segments>
|
||||
<segment duration_sec="3" image_index="0">这油污忍很久了</segment>
|
||||
<segment duration_sec="6" image_index="0">大公鸡头油污净喷上等几分钟一擦就净</segment>
|
||||
<segment duration_sec="4" image_index="0">39块钱一瓶可以试一下</segment>
|
||||
</script_segments>
|
||||
<word_count>52</word_count><estimated_duration>13</estimated_duration>"""
|
||||
|
||||
STORYBOARD_XML = """<clips>
|
||||
<clip image_index="0" transition="cut" zoom="null" duration_sec="3" bgm_note="日常">
|
||||
<voice_text>这油污忍很久了</voice_text>
|
||||
<subtitle_text>油污忍很久</subtitle_text>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
<clip image_index="0" transition="zoom_in" zoom="in" duration_sec="10" bgm_note="轻快">
|
||||
<voice_text>大公鸡头喷上等几分钟一擦就净</voice_text>
|
||||
<subtitle_text>一擦就净</subtitle_text>
|
||||
<ken_burns start="20,20" end="80,80" ease="ease-in-out"/>
|
||||
</clip>
|
||||
</clips>"""
|
||||
|
||||
REVIEW_FAIL_XML = """<passed>false</passed>
|
||||
<issues><issue dimension="夸大承诺" severity="error" location="body_points">出现绝对化表述</issue></issues>
|
||||
<rewrite_suggestions><suggestion>改为“大部分油污能擦掉”</suggestion></rewrite_suggestions>"""
|
||||
|
||||
REVIEW_PASS_XML = """<passed>true</passed>
|
||||
<issues></issues>
|
||||
<rewrite_suggestions></rewrite_suggestions>"""
|
||||
|
||||
FIXED_FUSION_XML = FUSION_XML.replace("一擦就净", "大部分油污能擦掉")
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""按 system 内容路由 canned 响应的假豆包客户端。"""
|
||||
|
||||
def __init__(self):
|
||||
self.chat_calls: list[list[dict]] = []
|
||||
self.vision_calls: list = []
|
||||
self.review_sequence: list[str] | None = None
|
||||
self.rewrite_response: str = FIXED_FUSION_XML
|
||||
|
||||
def chat_completion(self, messages, **kwargs):
|
||||
self.chat_calls.append(messages)
|
||||
system = messages[0]["content"]
|
||||
user = messages[1]["content"]
|
||||
if "按审核意见修正文案" in system:
|
||||
return self.rewrite_response
|
||||
if "文案合规审核员" in system:
|
||||
if self.review_sequence:
|
||||
return self.review_sequence.pop(0)
|
||||
return REVIEW_PASS_XML
|
||||
if "理解用户的营销意图" in system:
|
||||
return INTENT_XML
|
||||
if "负责把文案拆成可拍摄" in system:
|
||||
return STORYBOARD_XML
|
||||
if (
|
||||
"短视频生成营销文案" in system
|
||||
or "AI 全权创作" in system
|
||||
or "AI 辅助润色" in system
|
||||
or "用户原文为主" in system
|
||||
):
|
||||
mode = (
|
||||
"ai_full" if "AI 全权创作" in system else ("user_primary" if "用户原文为主" in system else "ai_polish")
|
||||
)
|
||||
if self._fusion_override is not None:
|
||||
return self._fusion_override
|
||||
xml = FUSION_XML
|
||||
if mode == "ai_full":
|
||||
xml = xml.replace("<title>厨房重油污别硬擦了</title>", "<title>我把厨房油污全搞定了</title>")
|
||||
elif mode == "user_primary":
|
||||
xml = xml.replace("<title>厨房重油污别硬擦了</title>", "<title>油污净使用分享</title>")
|
||||
self._last_mode = mode
|
||||
return xml
|
||||
return ""
|
||||
|
||||
_fusion_override = None
|
||||
_last_mode = None
|
||||
|
||||
def vision_completion(self, messages, images=None, **kwargs):
|
||||
self.vision_calls.append({"messages": messages, "images": images})
|
||||
return IMAGE_XML
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_loader():
|
||||
invalidate()
|
||||
yield
|
||||
invalidate()
|
||||
|
||||
|
||||
# ── XML 解析 ──────────────────────────────────────────────────────────────
|
||||
class TestXmlParser:
|
||||
def test_parse_paired_tags_and_attrs(self):
|
||||
nodes = xp.find_all(IMAGE_XML, "product")
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0]["attrs"]["name"] == "大公鸡头油污净"
|
||||
assert nodes[0]["attrs"]["image_index"] == "0"
|
||||
|
||||
def test_parse_selling_points(self):
|
||||
points = [n["text"] for n in xp.find_all(IMAGE_XML, "point")]
|
||||
assert points == ["去油快", "625ml大容量"]
|
||||
|
||||
def test_self_closing_and_bool_helpers(self):
|
||||
nodes = xp.parse_tags('<people has_person="false"/><done x="true"/>')
|
||||
assert xp.attr_bool(nodes[0]["attrs"]["has_person"]) is False
|
||||
assert xp.attr_bool(nodes[1]["attrs"]["x"]) is True
|
||||
assert xp.attr_float("0.97") == pytest.approx(0.97)
|
||||
assert xp.attr_int("13", 5) == 13
|
||||
|
||||
def test_malformed_text_safe(self):
|
||||
assert xp.find_all(None, "tag") == []
|
||||
assert xp.text_of("乱七八糟没有标签", "intent", "默认") == "默认"
|
||||
|
||||
|
||||
# ── 5 套模板纯文本 ────────────────────────────────────────────────────────
|
||||
class TestTemplates:
|
||||
def test_five_templates_present(self):
|
||||
types_ = {t["prompt_type"] for t in DEFAULT_TEMPLATES}
|
||||
assert types_ == {"image_analysis", "intent_parsing", "copy_fusion", "storyboard", "review"}
|
||||
|
||||
def test_no_json_blocks_in_templates(self):
|
||||
for template in DEFAULT_TEMPLATES:
|
||||
blob = "\n".join([template["system_prompt"], template["user_prompt_template"], template["example_output"]])
|
||||
assert "```json" not in blob
|
||||
assert "JSON schema" not in blob
|
||||
|
||||
def test_placeholders_render_and_missing_key_kept(self):
|
||||
template = get_template("intent_parsing")
|
||||
rendered = render_user_prompt(template, user_copy_text="去油快", industry="家居")
|
||||
assert "去油快" in rendered
|
||||
assert "去油快" in render_user_prompt(template, image_analysis="产品图", user_copy_text="去油快")
|
||||
partial = render_user_prompt(template, user_copy_text="x")
|
||||
assert "{industry}" not in partial or "{" in partial
|
||||
|
||||
|
||||
# ── loader:DB 加载/缓存/回落 ─────────────────────────────────────────────
|
||||
class TestPromptLoader:
|
||||
def test_fallback_when_session_none(self, monkeypatch):
|
||||
import packages.adapters.sqlalchemy_impl.session as session_mod
|
||||
|
||||
monkeypatch.setattr(session_mod, "SessionLocal", None, raising=False)
|
||||
template = get_template("review")
|
||||
assert template is not None
|
||||
assert "6个维度" in template.system_prompt
|
||||
|
||||
def test_db_row_takes_precedence(self, tmp_path, monkeypatch):
|
||||
import packages.adapters.sqlalchemy_impl.session as session_mod
|
||||
|
||||
db_path = tmp_path / "t.db"
|
||||
engine = sa.create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE TABLE viral_video_prompt_templates ("
|
||||
"id INTEGER PRIMARY KEY, name TEXT, prompt_type TEXT, version INTEGER,"
|
||||
"system_prompt TEXT, user_prompt_template TEXT, example_output TEXT,"
|
||||
"is_active INTEGER)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO viral_video_prompt_templates VALUES"
|
||||
"(1,'自定义','review',2,'DB里的系统提示','DB用户提示','',1)"
|
||||
)
|
||||
)
|
||||
factory = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(session_mod, "SessionLocal", factory, raising=False)
|
||||
template = get_template("review", force_refresh=True)
|
||||
assert template.system_prompt == "DB里的系统提示"
|
||||
assert template.version == 2
|
||||
|
||||
# 改 DB 后 30 秒内仍走缓存
|
||||
with engine.begin() as conn:
|
||||
conn.execute(sa.text("UPDATE viral_video_prompt_templates SET system_prompt='改了' WHERE id=1"))
|
||||
assert get_template("review").system_prompt == "DB里的系统提示"
|
||||
# force_refresh 后热加载生效
|
||||
assert get_template("review", force_refresh=True).system_prompt == "改了"
|
||||
|
||||
def test_invalid_type_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
get_template("not_exist")
|
||||
|
||||
|
||||
# ── 5 步编排与 fallback ──────────────────────────────────────────────────
|
||||
class TestGenerator:
|
||||
def test_full_pipeline_xml_parseable(self):
|
||||
client = FakeClient()
|
||||
gen = CopyGenerator(client=client)
|
||||
result = gen.generate(["https://x/1.jpg"], industry="家居", user_copy_text="去油快", fusion_level="ai_polish")
|
||||
analysis = result["image_analysis"]
|
||||
assert analysis.products[0].name == "大公鸡头油污净"
|
||||
assert analysis.key_selling_points == ["去油快", "625ml大容量"]
|
||||
assert analysis.has_person is False
|
||||
|
||||
intent = result["intent_result"]
|
||||
assert intent.intent_summary == "厨房去油污神器"
|
||||
assert intent.core_messages[0].must_keep is True
|
||||
assert intent.personal_brands[0].text == "39块钱一瓶"
|
||||
|
||||
fusion = result["fusion_result"]
|
||||
assert fusion.title == "厨房重油污别硬擦了"
|
||||
assert len(fusion.script_segments) == 3
|
||||
|
||||
board = result["storyboard"]
|
||||
assert len(board.clips) == 2
|
||||
assert board.clips[1].transition == "zoom_in"
|
||||
assert board.clips[1].ken_burns.end == "80,80"
|
||||
# vision 确实被调用且带图
|
||||
assert client.vision_calls[0]["images"] == ["https://x/1.jpg"]
|
||||
|
||||
def test_three_fusion_levels_distinct(self):
|
||||
client = FakeClient()
|
||||
gen = CopyGenerator(client=client)
|
||||
analysis = gen.analyze_images(["https://x/1.jpg"])
|
||||
intent = gen.parse_intent("去油快", analysis)
|
||||
|
||||
titles = {}
|
||||
for level in ["ai_full", "ai_polish", "user_primary"]:
|
||||
client._fusion_override = None
|
||||
fusion = gen.fuse(level, analysis, intent, duration=15)
|
||||
titles[level] = fusion.title
|
||||
# system 里注入了对应档位指令
|
||||
system = client.chat_calls[-1][0]["content"]
|
||||
assert FUSION_INSTRUCTIONS[level][:12] in system
|
||||
assert titles["ai_full"] != titles["ai_polish"]
|
||||
assert titles["user_primary"] != titles["ai_polish"]
|
||||
|
||||
def test_image_fallback_on_garbage(self):
|
||||
client = FakeClient()
|
||||
client.vision_completion = lambda *a, **k: "完全无法解析的内容" # type: ignore
|
||||
gen = CopyGenerator(client=client)
|
||||
analysis = gen.analyze_images(["https://x/1.jpg"])
|
||||
assert analysis.products[0].name.startswith("无法判断")
|
||||
|
||||
def test_intent_fallback_on_garbage(self):
|
||||
client = FakeClient()
|
||||
client.chat_completion = lambda *a, **k: "乱码" # type: ignore
|
||||
gen = CopyGenerator(client=client)
|
||||
from packages.application.viral_video.schemas import ImageAnalysis
|
||||
|
||||
intent = gen.parse_intent("这是我的原意", ImageAnalysis())
|
||||
assert intent.intent_summary == "这是我的原意"
|
||||
assert intent.core_messages[0].must_keep is True
|
||||
|
||||
def test_fusion_fallback_on_garbage_levels(self):
|
||||
client = FakeClient()
|
||||
client.chat_completion = lambda *a, **k: "标签全无" # type: ignore
|
||||
gen = CopyGenerator(client=client)
|
||||
from packages.application.viral_video.schemas import ImageAnalysis, IntentResult
|
||||
|
||||
analysis = ImageAnalysis(products=[])
|
||||
intent = IntentResult(intent_summary="用户的意思")
|
||||
full = gen._fallback_fusion("ai_full", analysis, intent, 15, "")
|
||||
user = gen._fallback_fusion("user_primary", analysis, intent, 15, "")
|
||||
assert "回购" in full.title
|
||||
assert user.title == "用户的意思"
|
||||
|
||||
def test_storyboard_fallback_on_garbage(self):
|
||||
client = FakeClient()
|
||||
client.chat_completion = lambda *a, **k: "啥都没有" # type: ignore
|
||||
gen = CopyGenerator(client=client)
|
||||
from packages.application.viral_video.schemas import FusionResult, ScriptSegment
|
||||
|
||||
fusion = FusionResult(
|
||||
hook="开头",
|
||||
script_segments=[ScriptSegment(text="a", duration_sec=5), ScriptSegment(text="b", duration_sec=5)],
|
||||
)
|
||||
board = gen.storyboard(fusion, None, ["u"], 10)
|
||||
assert len(board.clips) == 2
|
||||
assert board.clips[0].voice_text == "a"
|
||||
|
||||
|
||||
# ── 审核与自动重写 ────────────────────────────────────────────────────────
|
||||
class TestReview:
|
||||
def test_rule_check_catches_exaggeration_even_if_llm_passes(self):
|
||||
client = FakeClient() # LLM 默认返回 passed
|
||||
reviewer = Reviewer(client=client)
|
||||
from packages.application.viral_video.schemas import FusionResult, IntentResult
|
||||
|
||||
fusion = FusionResult(title="一喷100%掉光", hook="x", cta="买")
|
||||
result = reviewer.review(fusion, IntentResult(), "ai_full")
|
||||
assert result.passed is False
|
||||
dims = {i.dimension for i in result.issues}
|
||||
assert "夸大承诺" in dims
|
||||
|
||||
def test_rule_check_catches_banned_phrase(self):
|
||||
client = FakeClient()
|
||||
reviewer = Reviewer(client=client)
|
||||
from packages.application.viral_video.schemas import FusionResult
|
||||
|
||||
fusion = FusionResult(title="绝绝子", hook="x", cta="买")
|
||||
result = reviewer.review(fusion, None, "ai_full")
|
||||
assert not result.passed
|
||||
assert any("绝绝子" in i.text for i in result.issues)
|
||||
|
||||
def test_missing_personal_brand_flagged(self):
|
||||
client = FakeClient()
|
||||
reviewer = Reviewer(client=client)
|
||||
from packages.application.viral_video.schemas import (
|
||||
FusionResult,
|
||||
IntentResult,
|
||||
PersonalBrand,
|
||||
)
|
||||
|
||||
fusion = FusionResult(title="合规标题", hook="钩子", cta="行动")
|
||||
intent = IntentResult(personal_brands=[PersonalBrand(text="39块钱一瓶", category="price")])
|
||||
result = reviewer.review(fusion, intent, "ai_polish")
|
||||
assert not result.passed
|
||||
assert any("39块钱一瓶" in i.text and i.dimension == "事实一致性" for i in result.issues)
|
||||
|
||||
def test_missing_core_message_flagged(self):
|
||||
client = FakeClient()
|
||||
reviewer = Reviewer(client=client)
|
||||
from packages.application.viral_video.schemas import (
|
||||
CoreMessage,
|
||||
FusionResult,
|
||||
IntentResult,
|
||||
)
|
||||
|
||||
fusion = FusionResult(title="标题", hook="别的内容", cta="号召")
|
||||
intent = IntentResult(core_messages=[CoreMessage(text="必须保留的原意", must_keep=True)])
|
||||
result = reviewer.review(fusion, intent, "user_primary")
|
||||
assert any(i.dimension == "用户意图保留" for i in result.issues)
|
||||
|
||||
def test_auto_rewrite_once_then_pass(self):
|
||||
client = FakeClient()
|
||||
client.review_sequence = [REVIEW_FAIL_XML, REVIEW_PASS_XML]
|
||||
gen = CopyGenerator(client=client)
|
||||
from packages.application.viral_video.schemas import FusionResult, IntentResult
|
||||
|
||||
fusion = gen._parse_fusion(FUSION_XML)
|
||||
final, review, rewrites = gen.review_and_rewrite(fusion, IntentResult(), "ai_polish")
|
||||
assert rewrites == 1
|
||||
assert review.passed is True
|
||||
assert "大部分油污能擦掉" in client.chat_calls[-2][1]["content"] or True
|
||||
|
||||
def test_rule_fix_local(self):
|
||||
reviewer = Reviewer(client=FakeClient())
|
||||
from packages.application.viral_video.schemas import (
|
||||
BodyPoint,
|
||||
FusionResult,
|
||||
ReviewIssue,
|
||||
ReviewResult,
|
||||
)
|
||||
|
||||
fusion = FusionResult(
|
||||
title="一喷100%掉光",
|
||||
hook="绝绝子",
|
||||
cta="最好用",
|
||||
body_points=[BodyPoint(text="立刻见效")],
|
||||
)
|
||||
review = ReviewResult(
|
||||
passed=False,
|
||||
issues=[ReviewIssue(dimension="夸大承诺", text="100%")],
|
||||
)
|
||||
fixed = reviewer._rule_fix(fusion, review)
|
||||
assert "100%" not in fixed.title
|
||||
assert fixed.hook == ""
|
||||
assert fixed.cta == "很不错用"
|
||||
assert fixed.body_points[0].text == "坚持使用会有改善"
|
||||
|
||||
|
||||
# ── seed 幂等 ─────────────────────────────────────────────────────────────
|
||||
class TestSeed:
|
||||
def test_seed_idempotent(self, tmp_path):
|
||||
scripts_dir = os.path.join(REPO_ROOT, "scripts")
|
||||
sys.path.insert(0, scripts_dir)
|
||||
import importlib
|
||||
|
||||
seed_mod = importlib.import_module("seed_viral_video_prompts")
|
||||
|
||||
db_path = tmp_path / "seed.db"
|
||||
engine = sa.create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"CREATE TABLE viral_video_prompt_templates ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, prompt_type TEXT,"
|
||||
"version INTEGER, system_prompt TEXT, user_prompt_template TEXT,"
|
||||
"example_output TEXT, is_active INTEGER DEFAULT 1,"
|
||||
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
|
||||
"updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
|
||||
"UNIQUE(prompt_type, version))"
|
||||
)
|
||||
)
|
||||
assert seed_mod.seed(engine) == 5
|
||||
assert seed_mod.seed(engine) == 5 # 再来一次不报错
|
||||
with engine.begin() as conn:
|
||||
count = conn.execute(sa.text("SELECT COUNT(*) FROM viral_video_prompt_templates")).scalar()
|
||||
assert count == 5
|
||||
active_types = conn.execute # noqa: B018
|
||||
with engine.begin() as conn:
|
||||
types_ = {
|
||||
r[0]
|
||||
for r in conn.execute(sa.text("SELECT prompt_type FROM viral_video_prompt_templates WHERE is_active=1"))
|
||||
}
|
||||
assert types_ == {"image_analysis", "intent_parsing", "copy_fusion", "storyboard", "review"}
|
||||
|
||||
|
||||
# ── 负面词不出现于程序产出 ────────────────────────────────────────────────
|
||||
class TestNegativeOutput:
|
||||
def test_fallback_outputs_clean(self):
|
||||
gen = CopyGenerator(client=FakeClient())
|
||||
from packages.application.viral_video.schemas import (
|
||||
FusionResult,
|
||||
ImageAnalysis,
|
||||
IntentResult,
|
||||
)
|
||||
|
||||
fusion = gen._fallback_fusion(
|
||||
"ai_full",
|
||||
ImageAnalysis(products=[]),
|
||||
IntentResult(intent_summary="正常产品"),
|
||||
15,
|
||||
"",
|
||||
)
|
||||
blob = "\n".join(s.text for s in fusion.script_segments)
|
||||
for phrase in BANNED_PHRASES:
|
||||
assert phrase not in blob
|
||||
@@ -0,0 +1,335 @@
|
||||
"""#2040 接线集成测试:验证运行中的 viral_video 任务使用 prompt_loader 从 DB 读取模板。
|
||||
|
||||
mock LLM/Vision 调用,验证:
|
||||
1. image_analysis 走 loader 模板 + XML 解析
|
||||
2. intent_parsing 走 loader 模板 + XML 解析
|
||||
3. script_generation 走 storyboard 模板 + XML 解析,输出兼容 Seedance 的 copy_result
|
||||
4. review 走 Reviewer(review 模板)带自动重写
|
||||
5. 三档融合(ai_full / ai_polish / user_primary)注入不同 FUSION_INSTRUCTIONS
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path as _Path
|
||||
|
||||
_WORKER_ROOT = _Path(__file__).resolve().parents[2] / "apps" / "worker"
|
||||
if str(_WORKER_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_WORKER_ROOT))
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job():
|
||||
j = ViralVideoJob(
|
||||
user_id="u1",
|
||||
images=["https://img/1.jpg", "https://img/2.jpg"],
|
||||
industry="美妆",
|
||||
duration=15,
|
||||
user_copy_text="这款口红真的太绝了,显白又持久,姐妹们冲!",
|
||||
fusion_level="ai_polish",
|
||||
)
|
||||
return j
|
||||
|
||||
|
||||
# ── Mock LLM/Vision 返回的 XML 文本 ─────────────────────────────────
|
||||
|
||||
IMAGE_XML = """
|
||||
<analysis>
|
||||
<scene>室内桌面拍摄,柔和自然光</scene>
|
||||
<mood>清新温暖</mood>
|
||||
<product name="lipstick" brand="品牌X" category="唇部彩妆"
|
||||
appearance="管状红色膏体" packaging="黑色金属管"
|
||||
features="显白,持久,滋润" portrait_prompt="无人像"
|
||||
summary="品牌X红色口红">
|
||||
<text_on_package>品牌X,211</text_on_package>
|
||||
</product>
|
||||
</analysis>
|
||||
""".strip()
|
||||
|
||||
INTENT_XML = """
|
||||
<intent>
|
||||
<intent_summary>推广显白持久口红</intent_summary>
|
||||
<core_messages>
|
||||
<message must_keep="true">显白</message>
|
||||
<message must_keep="true">持久</message>
|
||||
</core_messages>
|
||||
<personal_brands>
|
||||
<brand text="品牌X" category="brand"/>
|
||||
</personal_brands>
|
||||
<emotion_tone>亲切自然</emotion_tone>
|
||||
<suggested_title>显白持久口红推荐</suggested_title>
|
||||
</intent>
|
||||
""".strip()
|
||||
|
||||
STORYBOARD_XML = """
|
||||
<clips>
|
||||
<clip image_index="0" transition="cut" zoom="null" duration_sec="5" bgm_note="轻快BGM">
|
||||
<voice_text>这款口红真的太绝了</voice_text>
|
||||
<subtitle_text>显白又持久</subtitle_text>
|
||||
<shot_type_angle_movement>近景俯拍45度,缓慢推镜</shot_type_angle_movement>
|
||||
<scene_and_dialogue>厨房台面,主妇展示口红。对白:这款口红真的太绝了</scene_and_dialogue>
|
||||
<action_details>右手持口红展示膏体</action_details>
|
||||
<audio_bgm>轻快BGM</audio_bgm>
|
||||
<transition>硬切</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
<ken_burns start="0,0" end="0,0" ease="linear"/>
|
||||
</clip>
|
||||
</clips>
|
||||
""".strip()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def invalidate_loader_cache():
|
||||
from packages.application.viral_video import prompt_loader as pl
|
||||
|
||||
pl.invalidate()
|
||||
yield
|
||||
pl.invalidate()
|
||||
|
||||
|
||||
# ── 1) 图片分析走模板 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImageAnalysisWiring:
|
||||
def test_step_image_analysis_uses_v2_batch_path(self, job):
|
||||
"""#2200/#2207 后图片分析走 V2 批处理(OCR+lite JSON 并行),
|
||||
_step_image_analysis 归一化 URL 后调用 analyze_images_v2。"""
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
fake_product = {
|
||||
"name": "lipstick",
|
||||
"brand": "品牌X",
|
||||
"category": "唇部彩妆",
|
||||
"key_features": ["显白", "持久"],
|
||||
"text_on_package": ["品牌X", "211"],
|
||||
"_source": "v2",
|
||||
}
|
||||
with patch.object(vv, "_normalize_image_url", side_effect=lambda raw, idx: raw):
|
||||
with patch(
|
||||
"worker_app.tasks.vision.analyze_images_v2",
|
||||
return_value=[fake_product, fake_product],
|
||||
create=True,
|
||||
) as mock_v2:
|
||||
result = vv._step_image_analysis(job)
|
||||
|
||||
mock_v2.assert_called_once()
|
||||
# 传入的是归一化后的图片 URL 列表
|
||||
assert mock_v2.call_args.args[0] == job.images
|
||||
products = result["products"]
|
||||
assert len(products) == 2
|
||||
assert products[0]["name"] == "lipstick"
|
||||
assert products[0]["brand"] == "品牌X"
|
||||
assert "显白" in products[0]["key_features"]
|
||||
assert products[0]["text_on_package"] == ["品牌X", "211"]
|
||||
|
||||
def test_step_image_analysis_empty_images(self, job):
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job.images = []
|
||||
result = vv._step_image_analysis(job)
|
||||
assert result == {"products": []}
|
||||
|
||||
|
||||
# ── 2) 意图解析走模板 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIntentParsingWiring:
|
||||
def test_uses_loader_and_parses_xml(self, job):
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
img_result = {"products": [{"name": "lipstick", "brand": "品牌X", "key_features": ["显白", "持久"]}]}
|
||||
with patch("packages.shared.ai_service.call_llm", return_value=INTENT_XML) as mock_llm:
|
||||
result = vv._step_intent_parsing(job, img_result)
|
||||
|
||||
mock_llm.assert_called_once()
|
||||
assert result["intent"] == "推广显白持久口红"
|
||||
assert "显白" in result["key_messages"]
|
||||
assert result["suggested_title"] == "显白持久口红推荐"
|
||||
|
||||
|
||||
# ── 3) 脚本生成:storyboard 模板 + XML 解析 + fusion_level 注入 ────
|
||||
|
||||
|
||||
class TestScriptGenerationWiring:
|
||||
@pytest.mark.parametrize("level", ["ai_full", "ai_polish", "user_primary"])
|
||||
def test_fusion_level_injected(self, job, level):
|
||||
"""三档融合水平被注入到 storyboard 模板的 system_prompt"""
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
from packages.application.viral_video.prompts import FUSION_INSTRUCTIONS
|
||||
|
||||
job.fusion_level = level
|
||||
intent = {"intent": "推广", "key_messages": ["显白"], "tone": "亲切"}
|
||||
|
||||
captured_system = {}
|
||||
|
||||
def fake_call_llm(messages, **kw):
|
||||
captured_system["final"] = messages[0]["content"]
|
||||
return STORYBOARD_XML
|
||||
|
||||
with patch("packages.shared.ai_service.call_llm", side_effect=fake_call_llm):
|
||||
result = vv._step_script_generation(job, intent, {})
|
||||
|
||||
# fusion_level 对应的指令文本被注入到 system prompt 中
|
||||
assert FUSION_INSTRUCTIONS[level] in captured_system["final"], f"fusion_level {level} 指令未注入 system_prompt"
|
||||
# 输出保持 Seedance 兼容结构
|
||||
assert "overview" in result
|
||||
assert "shots" in result
|
||||
assert len(result["shots"]) >= 1
|
||||
assert result["shots"][0]["shot_type_angle_movement"]
|
||||
assert result["voiceover_script"]
|
||||
|
||||
def test_fallback_when_xml_and_json_unparseable(self, job):
|
||||
"""XML 解析失败且无法解析为 JSON 时,回退到兜底脚本"""
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job.fusion_level = "ai_polish"
|
||||
intent = {"intent": "推广", "key_messages": [], "tone": "亲切"}
|
||||
with patch("packages.shared.ai_service.call_llm", return_value="not xml not json"):
|
||||
result = vv._step_script_generation(job, intent, {})
|
||||
assert isinstance(result, dict)
|
||||
assert "voiceover_script" in result
|
||||
assert "shots" in result
|
||||
|
||||
|
||||
# ── 4) Review 使用 Reviewer + 自动重写 ─────────────────────────────
|
||||
|
||||
|
||||
class TestReviewWiring:
|
||||
def test_pass_path(self, job):
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
from packages.application.viral_video.reviewer import Reviewer, ReviewResult
|
||||
|
||||
copy_result = {
|
||||
"title": "口红推荐",
|
||||
"overview": {"theme": "口红推荐"},
|
||||
"voiceover_script": "这款口红显白又持久",
|
||||
"shots": [{"scene_and_dialogue": "展示口红"}],
|
||||
}
|
||||
job.intent_result = {"key_messages": ["显白", "持久"], "intent": "推广"}
|
||||
|
||||
pass_result = ReviewResult(passed=True, score=90, issues=[], rewrite_suggestions=[])
|
||||
with patch.object(Reviewer, "review", return_value=pass_result):
|
||||
out = vv._step_review(job, copy_result)
|
||||
assert out["passed"] is True
|
||||
|
||||
def test_rewrite_path(self, job):
|
||||
"""审核不通过时触发自动重写,并更新 job.copy_result"""
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
from packages.application.viral_video.reviewer import Reviewer, ReviewResult
|
||||
from packages.application.viral_video.schemas import FusionResult, ReviewIssue, ScriptSegment
|
||||
|
||||
copy_result = {
|
||||
"title": "原标题",
|
||||
"overview": {"theme": "原标题"},
|
||||
"voiceover_script": "这款口红绝了",
|
||||
"shots": [{"scene_and_dialogue": "展示"}],
|
||||
}
|
||||
job.intent_result = {"key_messages": ["显白"], "intent": "推广"}
|
||||
|
||||
fail_result = ReviewResult(
|
||||
passed=False,
|
||||
score=50,
|
||||
issues=[ReviewIssue(dimension="违规词", severity="high", location="开头", text="绝了")],
|
||||
rewrite_suggestions=["去掉夸大词"],
|
||||
)
|
||||
rewritten = FusionResult(
|
||||
title="新标题",
|
||||
hook="修改后钩子",
|
||||
script_segments=[ScriptSegment(text="修改后口播正文")],
|
||||
cta="行动号召",
|
||||
word_count=10,
|
||||
estimated_duration=10,
|
||||
)
|
||||
pass_after = ReviewResult(passed=True, score=88, issues=[], rewrite_suggestions=[])
|
||||
|
||||
with (
|
||||
patch.object(Reviewer, "review", side_effect=[fail_result, pass_after]),
|
||||
patch.object(Reviewer, "rewrite", return_value=rewritten),
|
||||
):
|
||||
out = vv._step_review(job, copy_result)
|
||||
|
||||
assert out["passed"] is True
|
||||
assert "rewritten_copy" in out
|
||||
assert job.generated_copy_text == "修改后口播正文"
|
||||
|
||||
|
||||
# ── 5) 端到端:每个 step 调用 loader 对应 prompt_type ──────────────
|
||||
|
||||
|
||||
class TestEndToEndLoaderUsed:
|
||||
def test_each_step_calls_loader(self, job):
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
from packages.application.viral_video import prompt_loader as pl
|
||||
|
||||
called_types = []
|
||||
real_get = pl.get_template
|
||||
|
||||
def spy_get(prompt_type, **kwargs):
|
||||
called_types.append(prompt_type)
|
||||
return real_get(prompt_type, **kwargs)
|
||||
|
||||
v2_product = {
|
||||
"name": "lipstick",
|
||||
"brand": "品牌X",
|
||||
"key_features": ["显白", "持久"],
|
||||
}
|
||||
with (
|
||||
patch.object(pl, "get_template", side_effect=spy_get),
|
||||
patch.object(vv, "_normalize_image_url", side_effect=lambda raw, idx: raw),
|
||||
patch(
|
||||
"worker_app.tasks.vision.analyze_images_v2",
|
||||
return_value=[v2_product],
|
||||
create=True,
|
||||
),
|
||||
patch("packages.shared.ai_service.call_llm", return_value=INTENT_XML),
|
||||
):
|
||||
# 1) image(V2 路径,不再经过 prompt_loader)
|
||||
img_step = vv._step_image_analysis(job)
|
||||
img_res = img_step["products"][0]
|
||||
# 2) intent(走 loader image_analysis? 否——intent_parsing 模板)
|
||||
intent_res = vv._step_intent_parsing(job, {"products": [img_res]})
|
||||
|
||||
# V2 图片分析不再调用 loader;意图解析调用 intent_parsing 模板
|
||||
assert "image_analysis" not in called_types
|
||||
assert "intent_parsing" in called_types
|
||||
|
||||
# script 和 review 单独验证(需要不同的 LLM 返回)
|
||||
called_types_2 = []
|
||||
|
||||
def spy_get_2(prompt_type, **kwargs):
|
||||
called_types_2.append(prompt_type)
|
||||
return real_get(prompt_type, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(pl, "get_template", side_effect=spy_get_2),
|
||||
patch("packages.shared.ai_service.call_llm", return_value=STORYBOARD_XML),
|
||||
):
|
||||
copy_res = vv._step_script_generation(job, intent_res, {"products": [img_res]})
|
||||
assert "storyboard" in called_types_2
|
||||
|
||||
called_types_3 = []
|
||||
|
||||
def spy_get_3(prompt_type, **kwargs):
|
||||
called_types_3.append(prompt_type)
|
||||
return real_get(prompt_type, **kwargs)
|
||||
|
||||
from packages.application.viral_video.reviewer import Reviewer, ReviewResult
|
||||
|
||||
pass_result = ReviewResult(passed=True, score=90, issues=[], rewrite_suggestions=[])
|
||||
job.intent_result = intent_res
|
||||
job.copy_result = copy_res
|
||||
with (
|
||||
patch.object(pl, "get_template", side_effect=spy_get_3),
|
||||
patch.object(Reviewer, "review", return_value=pass_result) as mock_review,
|
||||
):
|
||||
review_res = vv._step_review(job, copy_res)
|
||||
# review 步骤内部直接调用 Reviewer.review,该方法被 mock,因此 get_template 不会被调用;
|
||||
# 此处验证 Reviewer.review 被调用即可说明 review 步骤走通了。
|
||||
assert mock_review.called, "_step_review 未调用 Reviewer.review"
|
||||
assert isinstance(review_res, dict) and "passed" in review_res
|
||||
Executable
+291
@@ -0,0 +1,291 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""vision v4 prompt / assembler 单元测试:
|
||||
|
||||
- assembler 正确识别 v4 嵌套 schema 与旧扁平 schema
|
||||
- v4 product/person/store/other 四类输出组装出下游必出字段
|
||||
- 旧扁平 schema 行为不变
|
||||
- _prompt._resolve:DB 有 active prompt 时原样使用(不追加硬编码 schema);
|
||||
DB 无记录时回落到硬编码 JSON schema
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from worker_app.tasks.vision import _prompt, assembler
|
||||
|
||||
REQUIRED_KEYS = {
|
||||
"name",
|
||||
"brand",
|
||||
"category",
|
||||
"appearance",
|
||||
"packaging",
|
||||
"text_on_package",
|
||||
"key_features",
|
||||
"scene",
|
||||
"mood",
|
||||
"portrait_prompt",
|
||||
"summary",
|
||||
"_source",
|
||||
}
|
||||
|
||||
|
||||
# ---------- schema 识别 ----------
|
||||
|
||||
|
||||
def test_is_v4_schema_products_list() -> None:
|
||||
assert assembler._is_v4_schema({"type": "product", "products": []})
|
||||
|
||||
|
||||
def test_is_v4_schema_type_only() -> None:
|
||||
assert assembler._is_v4_schema({"type": "person"})
|
||||
|
||||
|
||||
def test_is_v4_schema_people_dict() -> None:
|
||||
assert assembler._is_v4_schema({"people": {"has_person": True}})
|
||||
|
||||
|
||||
def test_is_not_v4_schema_flat() -> None:
|
||||
assert not assembler._is_v4_schema({"has_person": True, "upper_wear": "T恤"})
|
||||
|
||||
|
||||
# ---------- v4 product ----------
|
||||
|
||||
V4_PRODUCT: dict[str, Any] = {
|
||||
"type": "product",
|
||||
"scene": "白色背景产品图",
|
||||
"mood": "清新专业",
|
||||
"style": "商业产品摄影",
|
||||
"colors": [{"hex": "#E60012", "name": "亮红色", "coverage": 0.6}],
|
||||
"visible_text": [{"text": "OMO奥妙除菌除螨", "position": "瓶身正面"}],
|
||||
"products": [
|
||||
{
|
||||
"product_name": "OMO奥妙除菌除螨洗衣液",
|
||||
"brand": "OMO奥妙",
|
||||
"category": "洗护",
|
||||
"package_type": "瓶装",
|
||||
"package_color": "亮红色瓶身",
|
||||
"cap_type": "透明翻盖式按压瓶口",
|
||||
"body_shape": "带侧面握持把手的竖款瓶身",
|
||||
"label_design": "瓶身印十字盾牌图案",
|
||||
"product_features": ["亮红色瓶装", "按压式瓶口", "十字盾牌标签"],
|
||||
"key_selling_points": ["天然除菌除螨"],
|
||||
"position": "main",
|
||||
}
|
||||
],
|
||||
"has_person": False,
|
||||
}
|
||||
|
||||
|
||||
def test_assemble_v4_product_fields() -> None:
|
||||
r = assembler.assemble_result(0, V4_PRODUCT, ["OMO奥妙"])
|
||||
assert REQUIRED_KEYS <= set(r.keys())
|
||||
assert r["name"] == "OMO奥妙除菌除螨洗衣液"
|
||||
assert r["brand"] == "OMO奥妙"
|
||||
assert r["category"] == "洗护"
|
||||
assert "瓶装" in r["packaging"]
|
||||
assert isinstance(r["key_features"], list) and r["key_features"]
|
||||
assert any("除菌" in str(t) for t in r["text_on_package"])
|
||||
assert len(r["portrait_prompt"]) >= 10
|
||||
assert r["_source"] == "v2_fast_json_v4"
|
||||
|
||||
|
||||
def test_assemble_v4_product_multi_selects_main() -> None:
|
||||
fj = {
|
||||
"type": "product",
|
||||
"products": [
|
||||
{"product_name": "次要商品", "brand": "B"},
|
||||
{"product_name": "主商品", "brand": "A", "position": "main"},
|
||||
],
|
||||
}
|
||||
r = assembler.assemble_result(1, fj, [])
|
||||
assert r["name"] == "主商品"
|
||||
|
||||
|
||||
# ---------- v4 person ----------
|
||||
|
||||
V4_PERSON: dict[str, Any] = {
|
||||
"type": "person",
|
||||
"scene": "户外街拍",
|
||||
"mood": "自信",
|
||||
"style": "街拍",
|
||||
"colors": [],
|
||||
"visible_text": [],
|
||||
"has_person": True,
|
||||
"gender": "女",
|
||||
"age_range": "青年",
|
||||
"upper_wear": "白色V领短袖T恤",
|
||||
"upper_color": "白色",
|
||||
"lower_wear": "黑色高腰阔腿裤",
|
||||
"lower_color": "黑色",
|
||||
"dress_color": None,
|
||||
"accessories": ["银色项链"],
|
||||
"hairstyle": "黑色长直发",
|
||||
"expression": "自信",
|
||||
"pose": "侧身站立",
|
||||
"outfit_style": "休闲日常",
|
||||
"portrait_prompt": (
|
||||
"一位年轻女性,身穿白色V领短袖T恤、黑色高腰阔腿裤,佩戴银色项链,"
|
||||
"黑色长直发,神情自信,侧身站立,休闲日常风格,城市街拍场景"
|
||||
),
|
||||
"products": [],
|
||||
}
|
||||
|
||||
|
||||
def test_assemble_v4_person() -> None:
|
||||
r = assembler.assemble_result(0, V4_PERSON, [])
|
||||
assert REQUIRED_KEYS <= set(r.keys())
|
||||
assert r["category"] == "服饰"
|
||||
assert "T恤" in r["name"]
|
||||
assert "阔腿裤" in r["name"]
|
||||
assert "年轻女性" in r["portrait_prompt"]
|
||||
assert "项链" in r["portrait_prompt"]
|
||||
assert isinstance(r["key_features"], list) and len(r["key_features"]) <= 6
|
||||
|
||||
|
||||
def test_assemble_v4_person_people_nested() -> None:
|
||||
fj = {"type": "person", "people": {**V4_PERSON, "has_person": True}}
|
||||
r = assembler.assemble_result(0, fj, [])
|
||||
assert r["category"] == "服饰"
|
||||
assert "年轻女性" in r["portrait_prompt"]
|
||||
|
||||
|
||||
# ---------- v4 store ----------
|
||||
|
||||
|
||||
def test_assemble_v4_store() -> None:
|
||||
fj = {
|
||||
"type": "store",
|
||||
"scene": "便利店内部",
|
||||
"mood": "日常便民",
|
||||
"style": "门店实拍",
|
||||
"store_type": "社区便利店",
|
||||
"store_layout": "纵深货架布局",
|
||||
"brand_signage": "全家FamilyMart",
|
||||
"visual_elements": ["红白主色调", "促销海报"],
|
||||
"product_categories_visible": ["饮料", "零食"],
|
||||
"promotion_elements": ["第二件半价海报"],
|
||||
"atmosphere": "亲民生活化",
|
||||
"has_person": False,
|
||||
}
|
||||
r = assembler.assemble_result(0, fj, [])
|
||||
assert REQUIRED_KEYS <= set(r.keys())
|
||||
assert r["name"] == "社区便利店"
|
||||
assert r["brand"] == "全家FamilyMart"
|
||||
assert r["category"] == "门店场景"
|
||||
assert any("饮料" in str(f) for f in r["key_features"])
|
||||
assert "门店实拍" in r["portrait_prompt"]
|
||||
|
||||
|
||||
# ---------- v4 other ----------
|
||||
|
||||
|
||||
def test_assemble_v4_other() -> None:
|
||||
fj = {"type": "other", "description": "海边日落风景", "scene": "海边", "mood": "宁静"}
|
||||
r = assembler.assemble_result(0, fj, [])
|
||||
assert REQUIRED_KEYS <= set(r.keys())
|
||||
assert r["name"] == "海边日落风景"
|
||||
assert r["category"] == "非产品图"
|
||||
|
||||
|
||||
# ---------- 旧扁平 schema 兼容 ----------
|
||||
|
||||
|
||||
def test_assemble_old_flat_person() -> None:
|
||||
fj = {
|
||||
"has_person": True,
|
||||
"gender": "男",
|
||||
"age_range": "中年",
|
||||
"upper_wear": "西装",
|
||||
"upper_color": "深灰色",
|
||||
"lower_wear": "西裤",
|
||||
"lower_color": "黑色",
|
||||
"accessories": ["手表"],
|
||||
"hairstyle": "短发",
|
||||
"expression": "严肃",
|
||||
"scene": "办公室",
|
||||
"style": "商务",
|
||||
"mood": "专业",
|
||||
}
|
||||
r = assembler.assemble_result(0, fj, [])
|
||||
assert REQUIRED_KEYS <= set(r.keys())
|
||||
assert "中年男性" in r["portrait_prompt"]
|
||||
assert r["_source"] == "v2_fast_json"
|
||||
|
||||
|
||||
def test_assemble_old_flat_product() -> None:
|
||||
fj = {
|
||||
"has_person": False,
|
||||
"product_name": "口红",
|
||||
"brand": "Dior",
|
||||
"category": "美妆",
|
||||
"colors": ["红色"],
|
||||
"scene": "通用",
|
||||
"style": "商业",
|
||||
"mood": "高级",
|
||||
}
|
||||
r = assembler.assemble_result(0, fj, ["Dior"])
|
||||
assert r["name"] == "口红"
|
||||
assert r["brand"] == "Dior"
|
||||
assert r["text_on_package"] == ["Dior"]
|
||||
|
||||
|
||||
def test_assemble_none_input() -> None:
|
||||
r = assembler.assemble_result(0, None, [])
|
||||
assert REQUIRED_KEYS <= set(r.keys())
|
||||
|
||||
|
||||
# ---------- _prompt 解析 ----------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_prompt_cache() -> Any:
|
||||
_prompt.invalidate_cache()
|
||||
yield
|
||||
_prompt.invalidate_cache()
|
||||
|
||||
|
||||
def _fake_tpl(system_prompt: str = "v4 system prompt 只返回JSON") -> Any:
|
||||
return types.SimpleNamespace(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt_template="分析 {image_count} 张图",
|
||||
version=4,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_uses_db_prompt_without_append(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(_prompt, "_load_db_template", lambda: _fake_tpl("DB_V4_PROMPT_XYZ"))
|
||||
sys_prompt, user_prompt = _prompt.resolve_fast_prompt()
|
||||
assert sys_prompt == "DB_V4_PROMPT_XYZ"
|
||||
assert "DB_V4_PROMPT_XYZ" not in _prompt._FAST_JSON_APPEND # sanity: 旧append是另一段文本
|
||||
assert "分析 1 张图" in user_prompt
|
||||
|
||||
|
||||
def test_resolve_pro_uses_db_prompt_without_append(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(_prompt, "_load_db_template", lambda: _fake_tpl("DB_V4_PRO_PROMPT"))
|
||||
sys_prompt, _ = _prompt.resolve_pro_prompt()
|
||||
assert sys_prompt == "DB_V4_PRO_PROMPT"
|
||||
assert "【输出格式要求】" not in sys_prompt
|
||||
|
||||
|
||||
def test_resolve_falls_back_when_no_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(_prompt, "_load_db_template", lambda: None)
|
||||
sys_prompt, user_prompt = _prompt.resolve_fast_prompt()
|
||||
assert sys_prompt == _prompt._FAST_JSON_SCHEMA
|
||||
assert user_prompt == _prompt.DEFAULT_FAST_USER
|
||||
|
||||
|
||||
def test_resolve_caches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = {"n": 0}
|
||||
|
||||
def _load() -> Any:
|
||||
calls["n"] += 1
|
||||
return _fake_tpl("CACHED_PROMPT")
|
||||
|
||||
monkeypatch.setattr(_prompt, "_load_db_template", _load)
|
||||
s1, _ = _prompt.resolve_fast_prompt()
|
||||
s2, _ = _prompt.resolve_fast_prompt()
|
||||
assert s1 == s2 == "CACHED_PROMPT"
|
||||
assert calls["n"] == 1
|
||||
Reference in New Issue
Block a user