Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e92004fca3 | |||
| b1846207cc | |||
| 451ae99242 |
+1
-1
@@ -1 +1 @@
|
||||
trigger staging deploy for 3fa1fbf0 (MOCK_STORYBOARD fix) at 2026-10-12 13:40:16
|
||||
trigger staging deploy for bec75a07 (viral-video error isolation) at 2026-10-11 19:39:29
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
- ditto_emotion_enabled / ditto_emotion_model / ditto_emotion_temperature
|
||||
- ditto_emotion_prompt / ditto_blend_frames / doubao_api_key
|
||||
注意:doubao_api_key 属于密钥,GET 只返回脱敏占位,绝不返回明文。
|
||||
ditto_emotion_model 为自由文本(支持火山方舟 ep- 接入点 ID),
|
||||
推荐选项 model_options 从 system_config 的 ditto_emotion_model_options(JSON 数组)读取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -31,12 +35,6 @@ router = APIRouter(
|
||||
dependencies=[Depends(_verify_internal_api_key)],
|
||||
)
|
||||
|
||||
MODEL_OPTIONS = [
|
||||
"doubao-seed-2-1-lite-250915",
|
||||
"doubao-seed-2-1-pro-250915",
|
||||
"deepseek-v3",
|
||||
]
|
||||
|
||||
# key → (类型, 分类)
|
||||
_WHITELIST: dict[str, str] = {
|
||||
"ditto_emotion_enabled": SETTING_TYPE_BOOL,
|
||||
@@ -69,6 +67,23 @@ def _default_value(key: str) -> Any:
|
||||
return getattr(_settings(), key)
|
||||
|
||||
|
||||
def _model_options_from_db(service) -> list[str]:
|
||||
"""模型推荐选项:从 system_config 读 ditto_emotion_model_options(JSON 数组字符串)。
|
||||
|
||||
未配置 / 非法 JSON / 非数组 JSON 均返回空数组;数组元素统一转字符串。
|
||||
"""
|
||||
raw = service.get_config("ditto_emotion_model_options", "") or ""
|
||||
if not str(raw).strip():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [str(x) for x in data if isinstance(x, (str, int, float))]
|
||||
|
||||
|
||||
def _build_config_item(key: str) -> dict[str, Any]:
|
||||
service = get_system_config_service()
|
||||
if key in _SECRET_KEYS:
|
||||
@@ -90,7 +105,7 @@ def _build_config_item(key: str) -> dict[str, Any]:
|
||||
}
|
||||
item["value"] = service.get_config(key, _default_value(key))
|
||||
if key == "ditto_emotion_model":
|
||||
item["model_options"] = list(MODEL_OPTIONS)
|
||||
item["model_options"] = _model_options_from_db(service)
|
||||
return item
|
||||
|
||||
|
||||
@@ -126,8 +141,13 @@ def _validate_value(key: str, value: Any) -> Any:
|
||||
raise ValueError(f"{key} 必须是字符串")
|
||||
if key == "ditto_emotion_prompt" and value.strip() and "{文案}" not in value:
|
||||
raise ValueError("提示词必须包含 {文案} 占位符")
|
||||
if key == "ditto_emotion_model" and value not in MODEL_OPTIONS:
|
||||
raise ValueError(f"模型必须是以下之一:{', '.join(MODEL_OPTIONS)}")
|
||||
if key == "ditto_emotion_model":
|
||||
if not value.strip():
|
||||
raise ValueError(f"{key} 不能为空字符串")
|
||||
if len(value) > 200:
|
||||
raise ValueError(f"{key} 长度不能超过 200 字符")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
||||
raise ValueError(f"{key} 只能包含字母、数字、连字符和下划线")
|
||||
if key == "doubao_api_key" and not value.strip():
|
||||
raise ValueError(f"{key} 不能为空字符串")
|
||||
return value
|
||||
|
||||
@@ -344,16 +344,6 @@ interface Storyboard {
|
||||
}
|
||||
|
||||
/** 兼容旧 copy_result(final_copy/title/scenes)→ 新 Storyboard 结构 */
|
||||
function durationForShots(shots: ShotScript[] | undefined): number {
|
||||
if (!shots || shots.length === 0) return 15
|
||||
let maxEnd = 0
|
||||
for (const s of shots) {
|
||||
const m = (s.time_range || "").match(/(\d+)-(\d+)秒?/)
|
||||
if (m) maxEnd = Math.max(maxEnd, parseInt(m[2], 10))
|
||||
}
|
||||
return maxEnd > 0 ? maxEnd : 15
|
||||
}
|
||||
|
||||
function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard | null {
|
||||
if (!cr) return null
|
||||
// 新结构:含 shots/overview 字段直接用
|
||||
@@ -1553,10 +1543,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="vv-sb-doc vv-streaming-doc">
|
||||
<pre className="vv-streaming-pre">
|
||||
{task.scriptStreamText
|
||||
.replace(/<\/?[a-zA-Z_][\w:-]*(?:\s[^>]*)?>/g, "")
|
||||
.replace(/\s{3,}/g, "\n\n")
|
||||
.trim()}
|
||||
{task.scriptStreamText}
|
||||
<span className="vv-streaming-caret" aria-hidden="true" />
|
||||
</pre>
|
||||
</div>
|
||||
@@ -1566,46 +1553,16 @@ const ViralVideoPage: React.FC = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// P0修复:真实分镜数据渲染。移除MOCK_STORYBOARD硬编码兜底——旧逻辑isNewStructure判定过严
|
||||
// (shots≥2 + scene_and_lighting非空 + duration非空)时才用真实数据,否则回退到写死的"实木餐桌"
|
||||
// 导致任何字段缺失时用户看到与上传图片完全无关的MOCK卡片。改为:只要有shots或voiceover就用真实数据,
|
||||
// 缺失字段用默认值补齐,永远不回退到MOCK(MOCK仅在开发预览时使用,生产绝不能出现)。
|
||||
// 后端新结构尚未就绪时,用 MOCK_STORYBOARD 做预览展示;新结构到了之后优先用真实数据
|
||||
const sb: Storyboard | null = (() => {
|
||||
if (!task.storyboard) return null
|
||||
const s = task.storyboard
|
||||
// 有任何真实内容(shots≥1 或 有口播文案)就渲染真实数据
|
||||
const hasRealContent =
|
||||
(Array.isArray(s.shots) && s.shots.length >= 1) ||
|
||||
(s.voiceover_script && s.voiceover_script.trim().length > 0)
|
||||
if (!hasRealContent) return null
|
||||
// 补齐可能缺失的字段,避免渲染崩溃
|
||||
return {
|
||||
...s,
|
||||
overview: {
|
||||
theme: s.overview?.theme || "AI 分镜脚本",
|
||||
total_duration: s.overview?.total_duration || `${durationForShots(s.shots)}s`,
|
||||
aspect_ratio: s.overview?.aspect_ratio || "9:16",
|
||||
},
|
||||
scene_and_lighting: s.scene_and_lighting || "基于产品场景的真实画面",
|
||||
shots:
|
||||
s.shots && s.shots.length > 0
|
||||
? s.shots
|
||||
: [
|
||||
{
|
||||
time_range: "0-15s",
|
||||
shot_type_angle_movement: "",
|
||||
scene_and_dialogue: s.voiceover_script || "",
|
||||
action_details: "",
|
||||
audio_bgm: "轻快BGM",
|
||||
transition: "",
|
||||
reference_image_index: 0,
|
||||
},
|
||||
],
|
||||
hard_constraints: Array.isArray(s.hard_constraints) ? s.hard_constraints : [],
|
||||
negative_prompts: Array.isArray(s.negative_prompts) ? s.negative_prompts : [],
|
||||
voiceover_script: s.voiceover_script || "",
|
||||
copy_display_markdown: s.copy_display_markdown || "",
|
||||
}
|
||||
const isNewStructure =
|
||||
task.storyboard.shots.length >= 2 &&
|
||||
!!task.storyboard.scene_and_lighting &&
|
||||
task.storyboard.overview.total_duration.length > 0
|
||||
if (isNewStructure) return task.storyboard
|
||||
// 后端还在旧结构:兜底用 mock 展示分镜卡片效果
|
||||
return MOCK_STORYBOARD
|
||||
})()
|
||||
if (
|
||||
!sb &&
|
||||
|
||||
@@ -729,52 +729,21 @@ def _replace_henjin_everywhere(obj: Any) -> Any:
|
||||
|
||||
|
||||
def _fallback_script(job: ViralVideoJob) -> dict:
|
||||
"""脚本生成失败时的极简兜底脚本。基于image_analysis注入真实商品/门店信息,避免生成脱离用户输入的通用文案。"""
|
||||
"""脚本生成失败时的极简兜底脚本。"""
|
||||
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||||
ratio = getattr(job, "video_ratio", None) or "9:16"
|
||||
image_analysis = normalize_image_analysis(getattr(job, "image_analysis", None))
|
||||
default_theme = _determine_theme(image_analysis, getattr(job, "marketing_purpose", "") or "")
|
||||
base = _empty_copy_result(dur, ratio, theme=default_theme)
|
||||
|
||||
# 从 VLM 结果中提取真实商品/门店名称,避免兜底文案完全脱离用户输入
|
||||
images = image_analysis.get("images", []) if isinstance(image_analysis, dict) else []
|
||||
_voiceover_map = {
|
||||
"store": "带你探店!今天来到这家店,环境真的很不错,服务也很到位,推荐大家来体验一下。",
|
||||
"person": "哈喽,今天给大家分享我的日常,简单舒适又好看,你们觉得怎么样?",
|
||||
"scene": "带大家感受一下这个空间,氛围感拉满,真的很适合打卡体验。",
|
||||
"product": "你好,给大家分享一款我最近在用的好物,真的很不错,推荐你们也试试。",
|
||||
}
|
||||
images = image_analysis.get("images", [])
|
||||
_dominant = (images[0].get("type") if images else "product") or "product"
|
||||
_names = []
|
||||
_brands = []
|
||||
for img in images[:3]:
|
||||
n = (img.get("name") or "").strip()
|
||||
b = (img.get("brand") or "").strip()
|
||||
if n and n != "未识别" and n not in _names:
|
||||
_names.append(n)
|
||||
if b and b not in _brands:
|
||||
_brands.append(b)
|
||||
_name_str = _names[0] if _names else ""
|
||||
_brand_str = _brands[0] if _brands else ""
|
||||
_mp = (getattr(job, "marketing_purpose", "") or "").strip()
|
||||
|
||||
# 基于真实识别结果构建口播,不再用"这家店""一款好物"等通用表述
|
||||
if job.user_copy_text:
|
||||
voiceover = job.user_copy_text
|
||||
elif _dominant == "store" and _name_str:
|
||||
voiceover = (
|
||||
f"今天带大家来{_name_str}看看,{_brand_str + ',' if _brand_str else ''}环境和服务都很赞,推荐来体验!"
|
||||
)
|
||||
elif _dominant == "store":
|
||||
voiceover = "今天带大家探店,这家店的环境和服务都很不错,推荐大家来体验一下。"
|
||||
elif _dominant == "product" and _name_str:
|
||||
voiceover = f"给大家分享{_brand_str + '家的' if _brand_str else ''}{_name_str},真心不错,推荐你们试试。"
|
||||
elif _dominant == "product":
|
||||
voiceover = "给大家分享一款我最近在用的好物,真的很不错,推荐你们也试试。"
|
||||
elif _dominant == "person":
|
||||
voiceover = "哈喽,今天给大家分享我的日常,简单舒适又好看,你们觉得怎么样?"
|
||||
elif _dominant == "scene" and _name_str:
|
||||
voiceover = f"带大家感受一下{_name_str},氛围感拉满,真的很适合打卡体验。"
|
||||
else:
|
||||
voiceover = "带大家感受一下这个空间,氛围感拉满,真的很适合打卡体验。"
|
||||
# 口播超长时截断
|
||||
_max_v = dur * 4
|
||||
if len(voiceover) > _max_v:
|
||||
voiceover = voiceover[:_max_v].rstrip(",、。, ") + "。"
|
||||
voiceover = job.user_copy_text or _voiceover_map.get(_dominant, _voiceover_map["product"])
|
||||
base["shots"] = [
|
||||
{
|
||||
"time_range": f"0-{dur}秒",
|
||||
@@ -1009,9 +978,9 @@ def _step_script_generation(
|
||||
return None
|
||||
# Bug2 fix: 口播字数 + 镜头数量 + 时间轴后校验
|
||||
_dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||||
_max_chars = _dur * 4 # 中文口播正常4字/秒,3字太紧凑LLM多次超限触发兜底
|
||||
_max_chars = _dur * 3 # 每秒最多3字
|
||||
_voiceover_chars = len(voiceover.strip())
|
||||
_min_chars = max(10, int(_dur * 2.5))
|
||||
_min_chars = max(10, int(_dur * 2.2))
|
||||
if _voiceover_chars > _max_chars:
|
||||
logger.warning(
|
||||
"[爆款视频] 口播超长 label=%s voiceover_chars=%d max=%d dur=%ds,将尝试压缩",
|
||||
@@ -1165,9 +1134,9 @@ def _step_script_generation(
|
||||
if is_fallback:
|
||||
return None
|
||||
_dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||||
_max_chars = _dur * 4 # 中文口播正常4字/秒
|
||||
_max_chars = _dur * 3
|
||||
_voiceover_chars = len(voiceover.strip())
|
||||
_min_chars = max(10, int(_dur * 2.5))
|
||||
_min_chars = max(10, int(_dur * 2.2))
|
||||
if _voiceover_chars > _max_chars:
|
||||
logger.warning(
|
||||
"[爆款视频] 口播超长(流式) label=%s voiceover_chars=%d max=%d", label, _voiceover_chars, _max_chars
|
||||
@@ -1199,8 +1168,8 @@ def _step_script_generation(
|
||||
deadline = time.time() + 180
|
||||
# Bug2 fix: 构建字数约束提示,注入到 user prompt
|
||||
_dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||||
_max_chars = _dur * 4 # 中文口播正常4字/秒
|
||||
_min_chars = max(10, int(_dur * 2.5))
|
||||
_max_chars = _dur * 3
|
||||
_min_chars = max(10, _dur * 2)
|
||||
_char_hint = f"口播总字数严格控制在 {_min_chars}~{_max_chars} 字({_dur}秒视频),超长会导致配音失败"
|
||||
user = user + "\n\n" + _char_hint
|
||||
|
||||
@@ -1755,7 +1724,7 @@ def _finalize_fallback_script(job: ViralVideoJob) -> dict:
|
||||
if not _validate_shot_timeline(shots, dur):
|
||||
fb["shots"] = _redistribute_timeline(shots, dur)
|
||||
voice = fb.get("voiceover_script", "") or ""
|
||||
max_chars = dur * 4
|
||||
max_chars = dur * 3
|
||||
if len(voice) > max_chars:
|
||||
voice = _truncate_voiceover(voice, max_chars)
|
||||
fb["voiceover_script"] = voice
|
||||
|
||||
@@ -276,11 +276,23 @@ class TestAdminRouteValidation:
|
||||
# 允许留空(使用默认)
|
||||
assert route._validate_value("ditto_emotion_prompt", "") == ""
|
||||
|
||||
def test_validate_model_options(self):
|
||||
def test_validate_model_free_text(self):
|
||||
route = self._route()
|
||||
# 空 / 超长 / 非法字符拒绝
|
||||
with pytest.raises(ValueError):
|
||||
route._validate_value("ditto_emotion_model", "gpt-4")
|
||||
assert route._validate_value("ditto_emotion_model", "deepseek-v3") == "deepseek-v3"
|
||||
route._validate_value("ditto_emotion_model", " ")
|
||||
with pytest.raises(ValueError):
|
||||
route._validate_value("ditto_emotion_model", "a" * 201)
|
||||
with pytest.raises(ValueError):
|
||||
route._validate_value("ditto_emotion_model", "模型.带特殊字符")
|
||||
with pytest.raises(ValueError):
|
||||
route._validate_value("ditto_emotion_model", "has space")
|
||||
# ep- 接入点 ID、裸模型名、下划线均放行
|
||||
assert route._validate_value("ditto_emotion_model", "ep-20260721114705-b568m") == "ep-20260721114705-b568m"
|
||||
assert (
|
||||
route._validate_value("ditto_emotion_model", "doubao-seed-2-1-pro-250915") == "doubao-seed-2-1-pro-250915"
|
||||
)
|
||||
assert route._validate_value("ditto_emotion_model", "deepseek_v3") == "deepseek_v3"
|
||||
|
||||
def test_validate_bool(self):
|
||||
route = self._route()
|
||||
@@ -375,3 +387,42 @@ class TestAdminRouteSecretMasking:
|
||||
assert "sk-new-key" not in str(result)
|
||||
# 落库值必须是明文(服务要用),只有响应脱敏
|
||||
assert svc.set_config.call_args[0][1] == "sk-new-key"
|
||||
|
||||
|
||||
class TestAdminRouteModelOptions:
|
||||
"""model_options 从 DB 的 ditto_emotion_model_options(JSON 数组)读取."""
|
||||
|
||||
def _route(self):
|
||||
from apps.api.app.api.routes.admin import ditto_emotion as route
|
||||
|
||||
return route
|
||||
|
||||
def _mock_service(self, values):
|
||||
svc = MagicMock()
|
||||
svc.get_config = lambda key, default=None: values.get(key, default)
|
||||
return svc
|
||||
|
||||
def _build(self, monkeypatch, values):
|
||||
route = self._route()
|
||||
monkeypatch.setattr(route, "get_system_config_service", lambda: self._mock_service(values))
|
||||
return route._build_config_item("ditto_emotion_model")
|
||||
|
||||
def test_options_from_db_json(self, monkeypatch):
|
||||
item = self._build(monkeypatch, {"ditto_emotion_model_options": '["ep-aaa", "ep-bbb"]'})
|
||||
assert item["model_options"] == ["ep-aaa", "ep-bbb"]
|
||||
|
||||
def test_options_empty_when_unconfigured(self, monkeypatch):
|
||||
item = self._build(monkeypatch, {})
|
||||
assert item["model_options"] == []
|
||||
|
||||
def test_options_invalid_json(self, monkeypatch):
|
||||
item = self._build(monkeypatch, {"ditto_emotion_model_options": "{not-json"})
|
||||
assert item["model_options"] == []
|
||||
|
||||
def test_options_non_array_json(self, monkeypatch):
|
||||
item = self._build(monkeypatch, {"ditto_emotion_model_options": '{"a": 1}'})
|
||||
assert item["model_options"] == []
|
||||
|
||||
def test_options_elements_stringified(self, monkeypatch):
|
||||
item = self._build(monkeypatch, {"ditto_emotion_model_options": '["ep-aaa", 123, null, true]'})
|
||||
assert item["model_options"] == ["ep-aaa", "123", "True"]
|
||||
|
||||
Reference in New Issue
Block a user