Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cedb9b4837 | |||
| 0f1d027c73 | |||
| 006e58ae54 | |||
| 7f825b7e55 | |||
| 410d686579 | |||
| 4879029c46 | |||
| 6cfab71d6e | |||
| 96786c4f85 | |||
| bf4b11c49d | |||
| 5b6079964e | |||
| 0e668fda8c | |||
| b4b6b32211 | |||
| 50e833eeeb | |||
| 79624587c4 | |||
| ee871ac4aa | |||
| 50c8618b87 | |||
| 712464f030 | |||
| c4ca302242 | |||
| 500203db5d | |||
| 2a9089edb0 | |||
| 6a3fbba813 |
@@ -623,6 +623,12 @@ class LipsyncService:
|
||||
# 预合成模式:script_text 可空(因为 timings 已自带句子文本),但仍建议传
|
||||
if not isinstance(sentence_timings, list) or len(sentence_timings) == 0:
|
||||
raise MediaKitError("预合成模式 sentence_timings 不能为空", code="InvalidInput")
|
||||
# 文案为空时从 sentence_timings 拼接兜底(Ditto 情绪分析依赖 script_text;
|
||||
# 前端 #1875 起预合成 payload 不带 script_text,导致 job.script_text 落库为空)
|
||||
if not (script_text or "").strip():
|
||||
script_text = "".join(
|
||||
str(item.get("text") or "") for item in sentence_timings if isinstance(item, dict)
|
||||
).strip()
|
||||
|
||||
# 0.5 lip_sync 计费预扣(全局 points 开关 + 功能开关均开才扣)
|
||||
prepaid_credits = 0.0
|
||||
|
||||
+47
-7
@@ -4,17 +4,57 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- 标题字体(#2001 / #font-selection 修复):Google Fonts CDN 引入中文字体,保证优设标题黑/抖音美好体/阿里普惠体等fallback可用 -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@400;700;900&family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&family=ZCOOL+QingKe+HuangYou&family=Ma+Shan+Zheng&family=Long+Cang&family=Liu+Jian+Mao+Cao&family=Zhi+Mang+Xing&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- 标题字体(#2001 / #font-selection 修复):Google Fonts 中文字体完全延迟加载
|
||||
关键设计:所有 Google Fonts 子资源(CSS + 字体文件)必须在 window load 之后才发起请求。
|
||||
- 原因:fonts.googleapis.com/gstatic.com 在境内访问间歇性挂起,若在关键路径内
|
||||
(<link rel=stylesheet>/preload/preconnect 等任何会被计入文档子资源完成的形式),
|
||||
会拖死 window.load 事件,导致 Playwright page.goto 默认 waitUntil:'load' 90s 超时,
|
||||
同时真实用户首屏也可能因等字体而白屏。
|
||||
- 实现:head 里完全不出现 Google 域的 <link>,首屏用系统字体兜底
|
||||
(font-family fallback 链:Noto Sans SC → PingFang SC → Microsoft YaHei → sans-serif);
|
||||
页面 load 后通过 requestIdleCallback 动态注入 <link rel=stylesheet>,字体异步加载生效。
|
||||
- noscript 降级:JS 禁用时仍然阻塞加载(可接受,这类用户极少)。
|
||||
- 参考:运维 2026-10-14 E2E 失败根因分析,#2300 preload/media=print 方案不治本,
|
||||
preload 与 media=print 样式表仍计入子资源完成,会阻塞 load 事件。 -->
|
||||
<title>小虾 SaaS - 自动化视频剪辑平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- Google Fonts 延迟注入:必须在 main.tsx 之后,用独立 <script>(非 module)在页面解析
|
||||
完成时注册 load 回调;window.load 触发后再 requestIdleCallback 插 <link>,
|
||||
确保字体请求不进入首屏关键路径,也不阻塞 Playwright load 等待 -->
|
||||
<script>
|
||||
;(function () {
|
||||
var FONT_HREF =
|
||||
"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@400;700;900&family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&family=ZCOOL+QingKe+HuangYou&family=Ma+Shan+Zheng&family=Long+Cang&family=Liu+Jian+Mao+Cao&family=Zhi+Mang+Xing&display=swap"
|
||||
function inject() {
|
||||
if (document.getElementById("__gfonts_link")) return
|
||||
var l = document.createElement("link")
|
||||
l.id = "__gfonts_link"
|
||||
l.rel = "stylesheet"
|
||||
l.href = FONT_HREF
|
||||
l.setAttribute("data-lazy", "1")
|
||||
document.head.appendChild(l)
|
||||
}
|
||||
function schedule() {
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(inject, { timeout: 2000 })
|
||||
} else {
|
||||
setTimeout(inject, 500)
|
||||
}
|
||||
}
|
||||
if (document.readyState === "complete") {
|
||||
schedule()
|
||||
} else {
|
||||
window.addEventListener("load", schedule, { once: true })
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<noscript
|
||||
><link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@400;700;900&family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&family=ZCOOL+QingKe+HuangYou&family=Ma+Shan+Zheng&family=Long+Cang&family=Liu+Jian+Mao+Cao&family=Zhi+Mang+Xing&display=swap"
|
||||
/></noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -255,6 +255,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
audio_url: state.ttsPreview.audioUrl!,
|
||||
audio_duration: state.ttsPreview.duration,
|
||||
sentence_timings: state.ttsPreview.sentenceTimings,
|
||||
// 预合成也必须带文案:后端落库 job.script_text,Ditto 情绪分析依赖它
|
||||
script_text: state.scriptText,
|
||||
enable_video_loop: true,
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -108,7 +108,7 @@ export const FALLBACK_VIDEO_MODELS: ViralVideoModel[] = [
|
||||
display_name: "Wan 3.0 — 通义万相全能",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p", "1080p"],
|
||||
max_duration: 15,
|
||||
max_duration: 30,
|
||||
},
|
||||
]
|
||||
export const RESOLUTION_ORDER = ["480p", "720p", "1080p", "4k"]
|
||||
|
||||
@@ -79,7 +79,7 @@ export const StoryboardCard: React.FC<StoryboardCardProps> = ({
|
||||
<div className="vv-copy-box vv-copy-loading">
|
||||
<VVSpinner />
|
||||
<span className="vv-copy-loading-text">{stageMsg}</span>
|
||||
<span className="vv-copy-loading-hint">预计 10-30 秒,请稍候</span>
|
||||
<span className="vv-copy-loading-hint">预计 5-15 秒,请稍候</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,14 +30,20 @@ _warned: dict[str, float] = {}
|
||||
_warn_lock = threading.Lock()
|
||||
|
||||
|
||||
def _warn_once(key: str, template_type: str, e: BaseException | str | None = None) -> None:
|
||||
def _warn_once(key: str, context: str, detail: BaseException | str | None = None) -> None:
|
||||
"""冷却告警通用方法:5 分钟内同 key 只打一次,避免日志刷屏。
|
||||
|
||||
- key: 告警分组键(去重用)
|
||||
- context: 本条告警的简短场景描述(直接拼进日志,不要再写死 'fallback to default' 字样)
|
||||
- detail: 具体异常或描述
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with _warn_lock:
|
||||
last = _warned.get(key, 0.0)
|
||||
if now - last < _WARN_COOLDOWN_SECONDS:
|
||||
return
|
||||
_warned[key] = now
|
||||
logger.warning("viral_video prompt template %s fallback to default: %s", template_type, e)
|
||||
logger.warning("viral_video prompt %s: %s", context, detail)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -85,10 +91,10 @@ def _get_session():
|
||||
|
||||
url = str(get_shared_settings().database_url)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once("session_config", "db_config", e)
|
||||
_warn_once("session_config", "db config failed (will fallback)", e)
|
||||
return None
|
||||
if not url:
|
||||
_warn_once("session_url", "db_url", "database_url empty")
|
||||
_warn_once("session_url", "db_url empty (will fallback)", "database_url empty")
|
||||
return None
|
||||
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
|
||||
if url.startswith("postgresql://") and "psycopg" not in url:
|
||||
@@ -128,14 +134,14 @@ def _load_from_db(prompt_type: str) -> Optional[PromptTemplate]:
|
||||
is_active=bool(row[6]),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
|
||||
_warn_once(f"load_db:{prompt_type}", prompt_type, e)
|
||||
_warn_once(f"load_db:{prompt_type}", f"db load failed for {prompt_type} (will fallback to code default)", e)
|
||||
return None
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once(f"session_close:{prompt_type}", prompt_type, e)
|
||||
_warn_once(f"session_close:{prompt_type}", f"session close failed for {prompt_type}", e)
|
||||
|
||||
|
||||
def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[PromptTemplate]:
|
||||
@@ -151,7 +157,11 @@ def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[P
|
||||
|
||||
template = _load_from_db(prompt_type) or _fallback(prompt_type)
|
||||
if template is None:
|
||||
_warn_once(f"no_template:{prompt_type}", prompt_type, "no template in db or code defaults")
|
||||
_warn_once(
|
||||
f"no_template:{prompt_type}",
|
||||
f"NO TEMPLATE for {prompt_type} (neither db nor code defaults)",
|
||||
"no template in db or code defaults",
|
||||
)
|
||||
if template is not None:
|
||||
with _lock:
|
||||
_cache[prompt_type] = (now, template)
|
||||
@@ -166,17 +176,75 @@ def invalidate() -> None:
|
||||
_warned.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 as e: # noqa: BLE001
|
||||
_warn_once("safe_format", "format", e)
|
||||
return text
|
||||
"""安全格式化模板:任何占位符异常(缺key/属性访问失败/格式符非法/JSON花括号样例)
|
||||
都原样保留,不抛异常、不静默回落默认模板,只打一条warning告警。
|
||||
|
||||
不用 str.format_map(_SafeDict):_SafeDict 只能防 KeyError,无法防 AttributeError
|
||||
(如 {duration.1格式写全6项参数} 访问 str.1格式... 会抛 AttributeError),
|
||||
也无法防非法 format_spec(如 JSON 样例 {"key":"val"} 里冒号被当格式符)。
|
||||
"""
|
||||
import string as _string
|
||||
|
||||
out: list[str] = []
|
||||
i, n = 0, len(text)
|
||||
errors: list[str] = []
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if c == "{":
|
||||
if i + 1 < n and text[i + 1] == "{":
|
||||
out.append("{")
|
||||
i += 2
|
||||
continue
|
||||
j = i + 1
|
||||
depth = 1
|
||||
while j < n and depth > 0:
|
||||
if text[j] == "{":
|
||||
depth += 1
|
||||
elif text[j] == "}":
|
||||
depth -= 1
|
||||
j += 1
|
||||
if depth == 0:
|
||||
inner = text[i + 1 : j - 1]
|
||||
try:
|
||||
val = _string.Formatter().vformat("{" + inner + "}", (), kwargs)
|
||||
out.append(val)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 非法占位符原样保留,避免模板里的样例JSON/说明文本炸format
|
||||
errors.append(f"{inner!r}: {type(e).__name__}")
|
||||
out.append(text[i:j])
|
||||
i = j
|
||||
continue
|
||||
if c == "}":
|
||||
if i + 1 < n and text[i + 1] == "}":
|
||||
out.append("}")
|
||||
i += 2
|
||||
continue
|
||||
out.append(c)
|
||||
i += 1
|
||||
if errors:
|
||||
# 模板里花括号炸format分两种:
|
||||
# 1. 配置bug(如v5事故{duration.1格式写全6项参数}非法属性访问)→ 需要排查
|
||||
# 2. 样例文本花括号(如{大远景/远景/全景}给LLM看的景别选项、JSON示例、XML说明)→ 原样保留属正常
|
||||
# 注意:本函数只原样保留,不会让template回落到DEFAULT_TEMPLATES;
|
||||
# 真正的回落到代码默认模板只在DB查询失败/无激活模板时发生(见_load_from_db/get_template),
|
||||
# 那里会打独立ERROR日志并带job_id(见viral_video.py中DEFAULT_TEMPLATES检测块)。
|
||||
# #2310: 降级为WARNING,因为大部分是DB模板中的样例花括号正常保留,不是bug;
|
||||
# 仅在错误数异常多(>10)或包含明显属性访问错误(.xxx/[xxx])时提级到WARNING加关注
|
||||
_has_real_bug = any("." in e or "[" in e for e in errors[:5])
|
||||
_log_fn = logger.warning if _has_real_bug else logger.debug
|
||||
_log_fn(
|
||||
"[viral][prompt] 模板格式化有%d个花括号原样保留(示例:%s)——"
|
||||
"样例文本(景别选项/JSON示例)可忽略;若应为变量请检查占位符拼写",
|
||||
len(errors),
|
||||
errors[0],
|
||||
)
|
||||
_warn_once(
|
||||
"safe_format",
|
||||
f"format-keep({len(errors)}placeholders)",
|
||||
f"{len(errors)}个占位符原样保留(首条{errors[0]}),未回落默认模板",
|
||||
)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def render_system_prompt(template: PromptTemplate, **kwargs) -> str:
|
||||
|
||||
@@ -113,7 +113,9 @@ _STORYBOARD_SYSTEM = (
|
||||
<clip index="1">
|
||||
<time_range>0-3秒</time_range>
|
||||
<voiceover>这一镜的口播台词</voiceover>
|
||||
<visual>具体、有画面感的镜头描述(主体/动作/镜头运动/景别/光线)</visual>
|
||||
<shot>景别、机位高度、焦段、光圈、运镜(6项摄影参数,简短,如『中近景平视,50mm f/1.8,缓慢推镜』)</shot>
|
||||
<visual>画面主体、场景、光线、氛围(只写画面内容,不含景别/运镜参数,如『暖光木色门店,店员微笑递茶,背景虚化』)</visual>
|
||||
<action_details>人物动作、表情、物品操作细节(真人出镜镜头必填,纯产品/空镜填空线『-』)</action_details>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
@@ -126,7 +128,7 @@ _STORYBOARD_SYSTEM = (
|
||||
|
||||
## 写作要求
|
||||
1. 口播台词:像真人面对镜头说话,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。
|
||||
2. 画面描述:写清"观众会看到什么",有动作、有镜头运动、有景别和光线,具体可拍;不堆砌形容词,不写无法实现的画面。
|
||||
2. <shot> 只写6项摄影参数(景别/机位高度/焦段/光圈/运镜),简短不超过20字;<visual> 写观众会看到的画面主体/场景/光线/氛围;<action_details> 写人物动作/表情/物品操作细节(真人出镜必填)。三字段各司其职,不要混写。
|
||||
3. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案,可用小标题与短句组织;不要做字段列表,不要出现"镜头一/台词:"这类制作说明。
|
||||
4. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。
|
||||
5. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。
|
||||
@@ -167,19 +169,25 @@ _STORYBOARD_EXAMPLE = """<script>
|
||||
<clip index="1">
|
||||
<time_range>0-4秒</time_range>
|
||||
<voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>
|
||||
<visual>中近景,暖光下一位妈妈略显疲惫地看向镜中,镜头缓缓推近</visual>
|
||||
<shot>中近景平视,50mm f/2.0,缓慢推镜</shot>
|
||||
<visual>暖光浴室镜前,妈妈略显疲惫地看向镜中自己,镜灯打在侧脸</visual>
|
||||
<action_details>妈妈手扶洗手台,微微低头再抬眼看镜,眼眶微红</action_details>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>4-10秒</time_range>
|
||||
<voiceover>推开门,暖光和一杯热茶先接住了你。</voiceover>
|
||||
<visual>中景,门店前台暖光木色装修,店员微笑递上茶水,镜头平移扫过环境</visual>
|
||||
<shot>中景平视,35mm f/2.8,镜头缓慢平移</shot>
|
||||
<visual>门店前台暖光木色装修,茶水冒热气,背景柔和虚化</visual>
|
||||
<action_details>店员微笑双手递上茶杯,妈妈伸手接过,指尖轻触杯沿</action_details>
|
||||
<reference_image_index>1</reference_image_index>
|
||||
</clip>
|
||||
<clip index="3">
|
||||
<time_range>10-15秒</time_range>
|
||||
<voiceover>来御众堂躺一会儿,整个人都松了。</voiceover>
|
||||
<visual>特写,妈妈闭眼放松做护理,暖光打在侧脸,最后镜头拉远露出品牌LOGO</visual>
|
||||
<shot>近景缓慢拉远至中景,85mm f/1.8,匀速后拉</shot>
|
||||
<visual>护理床暖光打在妈妈侧脸,最后画面露出御众堂品牌LOGO</visual>
|
||||
<action_details>妈妈闭眼放松,嘴角微微上扬,肩膀缓缓下沉</action_details>
|
||||
<reference_image_index>2</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
|
||||
@@ -123,7 +123,7 @@ class Reviewer:
|
||||
messages,
|
||||
temperature=0.2,
|
||||
max_tokens=1024,
|
||||
timeout=25,
|
||||
timeout=15, # #2307: 25s过长,超时降级放行
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
@@ -202,9 +202,9 @@ class SharedSettings(BaseSettings):
|
||||
default=120,
|
||||
validation_alias=AliasChoices("DITTO_REQUEST_TIMEOUT", "ditto_request_timeout"),
|
||||
)
|
||||
# Ditto 句间过渡帧数(平滑表情/口型切换)
|
||||
# Ditto 句间过渡帧数(平滑表情/口型切换);默认 6 与 GPU 端 clamp 最优值对齐(表情切换更灵敏)
|
||||
ditto_blend_frames: int = Field(
|
||||
default=12,
|
||||
default=6,
|
||||
validation_alias=AliasChoices("DITTO_BLEND_FRAMES", "ditto_blend_frames"),
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ class PresetVoice:
|
||||
description: 音色描述
|
||||
gender: 性别(male/female)
|
||||
language: 语言代码(如 zh-CN)
|
||||
preview_url: 预览音频 URL(可选)
|
||||
preview_url: 预览音频 URL(可选,前端试听用,短文本)
|
||||
sample_audio_url: 音色样本音频 URL(#2311,reference_audio用,8秒固定文案,部署预生成)
|
||||
tags: 标签列表
|
||||
"""
|
||||
|
||||
@@ -31,6 +32,9 @@ class PresetVoice:
|
||||
gender: str
|
||||
language: str = "zh-CN"
|
||||
preview_url: str = ""
|
||||
sample_audio_url: str = (
|
||||
"" # #2311: 用于reference_audio的8秒静态音色样本公网URL(部署时由seed_preset_voice_samples.py预生成)
|
||||
)
|
||||
tags: list[str] | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -42,6 +46,7 @@ class PresetVoice:
|
||||
"gender": self.gender,
|
||||
"language": self.language,
|
||||
"preview_url": self.preview_url,
|
||||
"sample_audio_url": self.sample_audio_url,
|
||||
"tags": self.tags or [],
|
||||
}
|
||||
|
||||
@@ -56,6 +61,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/a8fb29a2-7d44-9f16-9dab-f17fa806cf3d.mp3?Expires=1785716123&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=c74kFX%2BcDpmuBny2dPBkvak01oY%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longxiaochun_v3_sample.mp3",
|
||||
tags=["知性", "女声", "助手"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -65,6 +71,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/d065a6da-de0d-9ad2-b64a-00aaf9aa3dea.mp3?Expires=1785716203&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=9hRh7xbmS8eDdiBDDFUatvad1jg%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longxiaoxia_v3_sample.mp3",
|
||||
tags=["沉稳", "女声", "播报"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -74,6 +81,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="male",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/b139d925-4669-979d-89db-2dc16945b577.mp3?Expires=1785730686&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=X2p%2FA7Dm%2BTNfeFilW%2FQO9VoYPLQ%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longsanshu_v3_sample.mp3",
|
||||
tags=["沉稳", "男声", "有声书"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -83,6 +91,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/1ea86db3-4dc9-927e-9b76-c83d89f6788c.mp3?Expires=1785716207&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=Vg0ckgSgPzgNQ%2FNdeJzrE%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longyue_v3_sample.mp3",
|
||||
tags=["温暖", "女声", "广告"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -92,6 +101,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="male",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/6b47a5ce-ae21-995a-ac8b-5e55ed04ec45.mp3?Expires=1785716209&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=zm6WGmthpwTjf8PzgNQ%2FNdeJzrE%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longshu_v3_sample.mp3",
|
||||
tags=["沉稳", "男声", "教育"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -101,6 +111,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/13bf9f6a-72a9-949c-a896-ec3e6b46338b.mp3?Expires=1785730692&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=pxrP2rYmOumc4dw9TD8TZf%2B27gk%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longyingjing_v3_sample.mp3",
|
||||
tags=["冷静", "女声", "纪录片"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -110,6 +121,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="male",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/5e69f0c1-45fa-92ca-ae5c-38272f231efb.mp3?Expires=1785730692&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=uREIiIUFrN7CpJ7avXKWCo04A6I%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longshuo_v3_sample.mp3",
|
||||
tags=["干练", "男声", "科技"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -119,6 +131,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/a38e4c05-25bf-9640-92d6-0b4a14555cdb.mp3?Expires=1785716215&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=WtbsqqfR4A6OhpL%2B%2Fe0lh7YBMWM%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longtian_v3_sample.mp3",
|
||||
tags=["活泼", "女声", "短视频"],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -286,6 +286,11 @@ class DoubaoClient:
|
||||
if not self.is_available:
|
||||
return None
|
||||
|
||||
# #2304 追加修复:每次调用必须重置 finish_reason 标记,避免跨调用泄漏导致误判截断(Bug:
|
||||
# attempt1 收到 finish=length 后 last_finish_reason='length',attempt2 开头 chunk 处理后
|
||||
# 立即读到这个陈旧值误判截断、丢弃好内容)。
|
||||
self.last_finish_reason = ""
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
@@ -302,6 +307,11 @@ class DoubaoClient:
|
||||
if self.extra_params:
|
||||
payload.update(self.extra_params)
|
||||
if kwargs:
|
||||
# #2303 追加修复:豆包 ark 端点不认 enable_thinking 布尔参数,
|
||||
# 必须用 thinking={"type":"disabled"} 才会真正关闭 reasoning(A/B实测TTFT从100s+→2s)。
|
||||
# 调用方传 enable_thinking=False 时,统一翻译为标准字段。
|
||||
if kwargs.pop("enable_thinking", None) is False:
|
||||
payload["thinking"] = {"type": "disabled"}
|
||||
payload.update(kwargs)
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
@@ -405,10 +415,21 @@ class DoubaoClient:
|
||||
if self.extra_params:
|
||||
payload.update(self.extra_params)
|
||||
if kwargs:
|
||||
# 同同步路径:enable_thinking=False 翻译为 thinking={"type":"disabled"}
|
||||
_thinking_disabled = kwargs.pop("enable_thinking", None) is False
|
||||
payload.update(kwargs)
|
||||
if _thinking_disabled:
|
||||
payload["thinking"] = {"type": "disabled"}
|
||||
|
||||
# 每次调用 reset finish_reason 标记(同 chat_completion,避免跨 attempt 泄漏误判截断)
|
||||
self.last_finish_reason = ""
|
||||
|
||||
_t0 = time.time()
|
||||
for attempt in range(self.max_retries + 1):
|
||||
# #2303 追加修复:流式路径 SDK 内重试对 read timeout 翻倍耗时(单 attempt 外层120s +
|
||||
# SDK 重试120s*2 ≈ 360-420s),把两个 attempt 总预算吃光。外层 _step_script_generation
|
||||
# 已实现 fast→fast→pro 三级attempt兜底,这里禁 SDK 内重试,超时/错误直接抛给外层。
|
||||
_stream_max_retries = 0
|
||||
for attempt in range(_stream_max_retries + 1):
|
||||
try:
|
||||
_req_timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
if timeout:
|
||||
@@ -439,6 +460,8 @@ class DoubaoClient:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content_piece = delta.get("content") or ""
|
||||
# reasoning_content 由豆包 seed-2-1-pro 思考模式返回,
|
||||
# 对模板填充式生成无用(会占满 deadline),调用方可传 enable_thinking=False 关闭
|
||||
if content_piece:
|
||||
yield content_piece
|
||||
finish_reason = choices[0].get("finish_reason")
|
||||
@@ -453,19 +476,13 @@ class DoubaoClient:
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包流式API失败,%.1fs后重试 (%d/%d, elapsed=%.1fs): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
time.time() - _t0,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.error("豆包流式API最终失败: elapsed=%.1fs err=%s", time.time() - _t0, e)
|
||||
# 流式路径禁 SDK 内重试(外层 attempt 机制兜底,避免单 attempt 被 SDK 重试拖到 300s+)
|
||||
err_msg = str(e).lower()
|
||||
is_timeout = any(k in err_msg for k in ("timeout", "timed out", "deadline", "超时", "read timeout"))
|
||||
if is_timeout:
|
||||
logger.warning("[doubao] 流式 read timeout elapsed=%.1fs,交外层attempt处理", time.time() - _t0)
|
||||
else:
|
||||
logger.error("豆包流式API失败: elapsed=%.1fs err=%s", time.time() - _t0, e)
|
||||
return
|
||||
|
||||
def vision_completion(
|
||||
@@ -536,8 +553,12 @@ class DoubaoClient:
|
||||
if self.extra_params:
|
||||
payload.update(self.extra_params)
|
||||
if kwargs:
|
||||
if kwargs.pop("enable_thinking", None) is False:
|
||||
payload["thinking"] = {"type": "disabled"}
|
||||
payload.update(kwargs)
|
||||
|
||||
self.last_finish_reason = "" # reset,避免跨调用泄漏
|
||||
|
||||
req_timeout = self._resolve_timeout(timeout or self.timeout)
|
||||
last_error: Optional[Exception] = None
|
||||
_t0 = time.time()
|
||||
|
||||
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""#2311 部署脚本:为所有预置音色生成 8 秒静态音色样本并上传 OSS。
|
||||
|
||||
用途:
|
||||
部署 release 时执行,为 8 个 CosyVoice 预置音色各合成一段 8s 固定样本 mp3,
|
||||
上传到 OSS 永久路径 static/voice-samples/preset/{voice_id}_sample.mp3(公共读),
|
||||
并回填 packages/domain/preset_voices.py 中 PresetVoice.sample_audio_url 字段。
|
||||
|
||||
出片流程通过 PresetVoice.sample_audio_url 直接读取,零 TTS、零冷启动。
|
||||
|
||||
用法(在 API/Worker 容器内执行):
|
||||
cd /path/to/project && python scripts/seed_preset_voice_samples.py
|
||||
|
||||
可选参数:
|
||||
--dry-run 只打印要做的操作,不实际上传/回填
|
||||
--force 强制重新生成(即使 OSS 已有文件也覆盖)
|
||||
--no-public 上传后不设公共读 ACL(默认公共读)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import oss2 # type: ignore
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 固定 8 秒样本文案(覆盖中文四声、开合齐撮、常见声母韵母)
|
||||
VOICE_SAMPLE_SCRIPT_ZH = "你好,很高兴认识你。今天天气真不错,我们一起来看看吧。"
|
||||
VOICE_SAMPLE_SCRIPT_EN = "Hi there, nice to meet you. The weather is great today, let's take a look together."
|
||||
|
||||
# OSS 路径前缀
|
||||
OSS_PREFIX = "static/voice-samples/preset/"
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
"""定位项目根目录(兼容容器/软链/不同 cwd 场景)。"""
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
candidate = script_dir.parent
|
||||
if (candidate / "packages" / "domain" / "preset_voices.py").exists():
|
||||
return candidate
|
||||
cur = Path.cwd().resolve()
|
||||
for _ in range(8):
|
||||
if (cur / "packages" / "domain" / "preset_voices.py").exists():
|
||||
return cur
|
||||
if cur.parent == cur:
|
||||
break
|
||||
cur = cur.parent
|
||||
raise RuntimeError("无法定位项目根目录,请在项目根目录或 scripts/ 下执行本脚本")
|
||||
|
||||
|
||||
PROJECT_ROOT = _project_root()
|
||||
PRESET_VOICES_PY = PROJECT_ROOT / "packages" / "domain" / "preset_voices.py"
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
def _get_cosyvoice_service():
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
|
||||
return CosyVoiceService()
|
||||
|
||||
|
||||
def _get_oss_bucket():
|
||||
endpoint = os.environ.get("OSS_ENDPOINT", "")
|
||||
ak = os.environ.get("OSS_ACCESS_KEY_ID", "")
|
||||
sk = os.environ.get("OSS_ACCESS_KEY_SECRET", "")
|
||||
bucket_name = os.environ.get("OSS_BUCKET_NAME", "")
|
||||
|
||||
if not (endpoint and ak and sk and bucket_name):
|
||||
try:
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
s = get_shared_settings()
|
||||
endpoint = endpoint or getattr(s, "oss_endpoint", "")
|
||||
ak = ak or getattr(s, "oss_access_key_id", "")
|
||||
sk = sk or getattr(s, "oss_access_key_secret", "")
|
||||
bucket_name = bucket_name or getattr(s, "oss_bucket_name", "")
|
||||
except Exception as e:
|
||||
logger.warning("从settings读取OSS配置失败: %s", e)
|
||||
|
||||
if not (endpoint and ak and sk and bucket_name):
|
||||
logger.error("缺少 OSS 凭证,请设置 OSS_ENDPOINT/OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET/OSS_BUCKET_NAME")
|
||||
sys.exit(3)
|
||||
|
||||
ep_host = endpoint.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
auth = oss2.Auth(ak, sk)
|
||||
bucket = oss2.Bucket(auth, f"https://{ep_host}", bucket_name)
|
||||
public_base = f"https://{bucket_name}.{ep_host}"
|
||||
return bucket, public_base
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="#2311 预生成预置音色静态样本")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--force", action="store_true")
|
||||
ap.add_argument("--no-public", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
from packages.domain.preset_voices import PRESET_VOICES
|
||||
|
||||
logger.info("项目根目录: %s", PROJECT_ROOT)
|
||||
logger.info("预置音色总数: %d", len(PRESET_VOICES))
|
||||
cosy = _get_cosyvoice_service()
|
||||
bucket, public_base = _get_oss_bucket()
|
||||
|
||||
voice_to_url: dict[str, str] = {}
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="preset_voice_sample_"))
|
||||
|
||||
for voice in PRESET_VOICES:
|
||||
voice_id = voice.voice_id
|
||||
lang = voice.language or "zh-CN"
|
||||
script = VOICE_SAMPLE_SCRIPT_EN if lang.lower().startswith("en") else VOICE_SAMPLE_SCRIPT_ZH
|
||||
oss_key = f"{OSS_PREFIX}{voice_id}_sample.mp3"
|
||||
public_url = f"{public_base}/{oss_key}"
|
||||
|
||||
exists = False
|
||||
try:
|
||||
exists = bucket.object_exists(oss_key)
|
||||
except Exception as e:
|
||||
logger.warning("[%s] 检查OSS存在失败: %s", voice_id, e)
|
||||
|
||||
if exists and not args.force:
|
||||
logger.info("[%s] OSS已有样本,跳过: %s", voice_id, public_url)
|
||||
voice_to_url[voice_id] = public_url
|
||||
continue
|
||||
|
||||
if args.dry_run:
|
||||
logger.info("[DRY][%s] 将合成「%s...」→ oss://%s", voice_id, script[:20], oss_key)
|
||||
voice_to_url[voice_id] = public_url
|
||||
continue
|
||||
|
||||
logger.info("[%s] 合成中...", voice_id)
|
||||
try:
|
||||
result = cosy.synthesize_speech(text=script, voice_id=voice_id, format="mp3")
|
||||
except Exception as e:
|
||||
logger.error("[%s] TTS合成失败: %s", voice_id, e, exc_info=True)
|
||||
continue
|
||||
|
||||
local_mp3 = tmpdir / f"{voice_id}_sample.mp3"
|
||||
try:
|
||||
urllib.request.urlretrieve(result.audio_url, str(local_mp3))
|
||||
size = local_mp3.stat().st_size
|
||||
if size < 4096:
|
||||
logger.error("[%s] 文件太小(%d),跳过", voice_id, size)
|
||||
continue
|
||||
logger.info("[%s] 下载完成: %d bytes", voice_id, size)
|
||||
except Exception as e:
|
||||
logger.error("[%s] 下载合成音频失败: %s", voice_id, e, exc_info=True)
|
||||
continue
|
||||
|
||||
logger.info("[%s] 上传 oss://%s", voice_id, oss_key)
|
||||
try:
|
||||
headers = {"Content-Type": "audio/mpeg"}
|
||||
bucket.put_object_from_file(oss_key, str(local_mp3), headers=headers)
|
||||
if not args.no_public:
|
||||
bucket.put_object_acl(oss_key, oss2.OBJECT_ACL_PUBLIC_READ)
|
||||
logger.info("[%s] 上传完成 → %s", voice_id, public_url)
|
||||
voice_to_url[voice_id] = public_url
|
||||
except Exception as e:
|
||||
logger.error("[%s] OSS上传失败: %s", voice_id, e, exc_info=True)
|
||||
continue
|
||||
|
||||
if not voice_to_url:
|
||||
logger.warning("没有成功生成任何样本")
|
||||
return 1
|
||||
|
||||
if args.dry_run:
|
||||
logger.info("[DRY] 将回填 %d 个 sample_audio_url 到 %s", len(voice_to_url), PRESET_VOICES_PY)
|
||||
for vid, url in voice_to_url.items():
|
||||
logger.info(" %s → %s", vid, url)
|
||||
return 0
|
||||
|
||||
text = PRESET_VOICES_PY.read_text(encoding="utf-8")
|
||||
orig = text
|
||||
for voice_id, url in voice_to_url.items():
|
||||
block_pat = re.compile(
|
||||
r'(PresetVoice\([^)]*?voice_id="' + re.escape(voice_id) + r'"[^)]*?sample_audio_url=)"[^"]*"',
|
||||
re.DOTALL,
|
||||
)
|
||||
new_text, n = block_pat.subn(rf'\1"{url}"', text, count=1)
|
||||
if n == 0:
|
||||
logger.warning("[%s] 回填sample_audio_url失败(正则未匹配),请手动添加", voice_id)
|
||||
continue
|
||||
text = new_text
|
||||
|
||||
if text != orig:
|
||||
PRESET_VOICES_PY.write_text(text, encoding="utf-8")
|
||||
logger.info("✅ 已回填 %d 条 sample_audio_url 到 %s", len(voice_to_url), PRESET_VOICES_PY)
|
||||
logger.info("下一步:git diff packages/domain/preset_voices.py 确认后 commit & push")
|
||||
else:
|
||||
logger.info("preset_voices.py 未变化(可能全部已回填过)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -486,6 +486,87 @@ class TestLipsyncServiceUnit:
|
||||
assert call_kwargs.kwargs["audio_url"] == "https://example.com/direct-audio.mp3"
|
||||
assert job.audio_url == "https://example.com/direct-audio.mp3"
|
||||
|
||||
def test_create_job_presynth_backfills_script_text(self, mock_mediakit, mock_cosyvoice):
|
||||
"""预合成模式 script_text 为空时,从 sentence_timings 拼接兜底.
|
||||
|
||||
背景:前端 #1875 起预合成 payload 不带 script_text,导致 job.script_text
|
||||
落库为空,Ditto 任务 script_len=0、情绪分析被跳过。
|
||||
"""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(
|
||||
mock_db,
|
||||
client=mock_mediakit,
|
||||
cosyvoice_service=mock_cosyvoice,
|
||||
voice_clone_repo=MagicMock(),
|
||||
)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
timings = [
|
||||
{"index": 0, "text": "大家好,", "start_time": 0.0, "end_time": 1.0},
|
||||
{"index": 1, "text": "欢迎来到直播间。", "start_time": 1.0, "end_time": 2.5},
|
||||
]
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
audio_duration=2.5,
|
||||
sentence_timings=timings,
|
||||
script_text="", # 前端预合成 payload 不带文案
|
||||
)
|
||||
|
||||
assert job.script_text == "大家好,欢迎来到直播间。"
|
||||
|
||||
def test_create_job_presynth_keeps_supplied_script_text(self, mock_mediakit, mock_cosyvoice):
|
||||
"""预合成模式显式传了 script_text 时,保留原文不做拼接."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(
|
||||
mock_db,
|
||||
client=mock_mediakit,
|
||||
cosyvoice_service=mock_cosyvoice,
|
||||
voice_clone_repo=MagicMock(),
|
||||
)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
timings = [
|
||||
{"index": 0, "text": "分句一", "start_time": 0.0, "end_time": 1.0},
|
||||
]
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
audio_duration=1.0,
|
||||
sentence_timings=timings,
|
||||
script_text="原始文案,含标点。",
|
||||
)
|
||||
|
||||
assert job.script_text == "原始文案,含标点。"
|
||||
|
||||
def test_create_job_presynth_empty_timings_falls_back_to_direct(self, mock_mediakit, mock_cosyvoice):
|
||||
"""sentence_timings 空列表时 is_pre_synth=False,退化为直接音频模式(语义固化)."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(
|
||||
mock_db,
|
||||
client=mock_mediakit,
|
||||
cosyvoice_service=mock_cosyvoice,
|
||||
voice_clone_repo=MagicMock(),
|
||||
)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
sentence_timings=[],
|
||||
script_text="",
|
||||
)
|
||||
|
||||
# 直接音频模式:无 timings 可拼,script_text 保持空(不报错)
|
||||
assert job.script_text == ""
|
||||
mock_mediakit.submit_lipsync.assert_called_once()
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""v3: 音色解析与错误码在 service 层处理,路由层做 HTTP 状态码映射."""
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""#2308 parser字段映射单测:
|
||||
1. 新schema XML(<shot>+<visual>+<action_details>)字段正确映射
|
||||
2. 旧schema XML(<visual>混写)能拆分出cam/visual/action
|
||||
3. markdown中文分镜字段正确提取
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_job():
|
||||
job = MagicMock()
|
||||
job.duration = 15
|
||||
job.video_ratio = "9:16"
|
||||
job.marketing_purpose = "品牌推广"
|
||||
job.image_analysis = {"images": []}
|
||||
job.images = []
|
||||
job.user_copy_text = ""
|
||||
job.fusion_level = "ai_polish"
|
||||
return job
|
||||
|
||||
|
||||
def _import_wv(monkeypatch):
|
||||
for mod in ["worker_app.celery_app", "worker_app.db"]:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
import celery
|
||||
|
||||
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
|
||||
from worker_app.tasks import viral_video as wv
|
||||
|
||||
return wv
|
||||
|
||||
|
||||
# === 1. 新schema(v3.1+):独立 <shot>/<visual>/<action_details> ===
|
||||
NEW_SCHEMA_XML = """<script>
|
||||
<copy_display_markdown><![CDATA[来御众堂放松一下]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-4秒</time_range>
|
||||
<voiceover>生完娃,是不是连照镜子都没勇气了?</voiceover>
|
||||
<shot>中近景平视,50mm f/2.0,缓慢推镜</shot>
|
||||
<visual>暖光浴室镜前,妈妈略显疲惫地看向镜中自己,镜灯打在侧脸</visual>
|
||||
<action_details>妈妈手扶洗手台,微微低头再抬眼看镜,眼眶微红</action_details>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>4-10秒</time_range>
|
||||
<voiceover>推开门,暖光和一杯热茶先接住了你。</voiceover>
|
||||
<shot>中景平视,35mm f/2.8,缓慢平移</shot>
|
||||
<visual>门店前台暖光木色装修,茶水冒热气,背景柔和虚化</visual>
|
||||
<action_details>店员微笑双手递上茶杯,妈妈伸手接过,指尖轻触杯沿</action_details>
|
||||
<reference_image_index>1</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>生完娃,是不是连照镜子都没勇气了?推开门,暖光和一杯热茶先接住了你。</voiceover_script>
|
||||
<theme>产后妈妈走进御众堂</theme>
|
||||
</script>"""
|
||||
|
||||
|
||||
def test_new_schema_xml_field_mapping(monkeypatch, mock_job):
|
||||
wv = _import_wv(monkeypatch)
|
||||
r = wv._parse_script(NEW_SCHEMA_XML, mock_job)
|
||||
assert r is not None
|
||||
assert len(r["shots"]) == 2
|
||||
s0 = r["shots"][0]
|
||||
# shot_type 应是纯摄影参数
|
||||
assert "缓慢推镜" in s0["shot_type_angle_movement"]
|
||||
assert "妈妈" not in s0["shot_type_angle_movement"]
|
||||
# scene_and_dialogue 只放对白
|
||||
assert s0["scene_and_dialogue"] == "生完娃,是不是连照镜子都没勇气了?"
|
||||
# action_details 有内容
|
||||
assert "手扶洗手台" in s0["action_details"]
|
||||
# visual 字段有画面
|
||||
assert "暖光" in s0["visual"]
|
||||
assert "推镜" not in s0["visual"]
|
||||
|
||||
|
||||
# === 2. 旧schema(无<shot>/<action_details>标签,<visual>混写):兼容拆分 ===
|
||||
OLD_SCHEMA_XML = """<script>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-4秒</time_range>
|
||||
<voiceover>生完娃,是不是连照镜子都没勇气了?</voiceover>
|
||||
<visual>中近景平视,暖光下妈妈略显疲惫地看向镜中,镜头缓缓推近,妈妈手扶洗手台微微低头</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>生完娃,是不是连照镜子都没勇气了?</voiceover_script>
|
||||
<theme>测试</theme>
|
||||
</script>"""
|
||||
|
||||
|
||||
def test_old_schema_xml_splits_visual(monkeypatch, mock_job):
|
||||
wv = _import_wv(monkeypatch)
|
||||
r = wv._parse_script(OLD_SCHEMA_XML, mock_job)
|
||||
assert r is not None
|
||||
s0 = r["shots"][0]
|
||||
# 摄影参数段应含景别/运镜关键词
|
||||
assert "中近景" in s0["shot_type_angle_movement"] or "推" in s0["shot_type_angle_movement"]
|
||||
# action_details 应含动作动词
|
||||
assert s0["action_details"], "action_details不应为空(旧schema兼容应从<visual>拆出动作)"
|
||||
assert "手扶" in s0["action_details"] or "低头" in s0["action_details"]
|
||||
# scene_and_dialogue 只放对白,不重复画面
|
||||
assert s0["scene_and_dialogue"] == "生完娃,是不是连照镜子都没勇气了?"
|
||||
|
||||
|
||||
# === 3. markdown 中文分镜字段 ===
|
||||
MARKDOWN_INPUT = """## 视频主题:御众堂探店
|
||||
|
||||
### 镜头1
|
||||
- 时间范围:0-4秒
|
||||
- 景别/角度与运镜:中近景平视,缓慢推镜
|
||||
- 画面:暖光下妈妈看向镜中,镜灯打在侧脸
|
||||
- 动作与真人细节:妈妈手扶洗手台,微微低头
|
||||
- 口播:"生完娃,是不是连照镜子都没勇气了?"
|
||||
|
||||
### 镜头2
|
||||
- 时间范围:4-10秒
|
||||
- 景别:中景平视
|
||||
- 运镜:缓慢平移
|
||||
- 画面:门店前台暖光木色装修
|
||||
- 动作:店员递茶,妈妈伸手接过
|
||||
- 口播:"推开门,暖光和一杯热茶先接住了你"
|
||||
"""
|
||||
|
||||
|
||||
def test_markdown_field_extraction(monkeypatch, mock_job):
|
||||
wv = _import_wv(monkeypatch)
|
||||
r = wv._parse_script(MARKDOWN_INPUT, mock_job)
|
||||
assert r is not None
|
||||
assert len(r["shots"]) == 2
|
||||
s0 = r["shots"][0]
|
||||
assert "中近景" in s0["shot_type_angle_movement"]
|
||||
assert "妈妈手扶洗手台" in s0["action_details"]
|
||||
assert s0["scene_and_dialogue"] == "生完娃,是不是连照镜子都没勇气了?"
|
||||
|
||||
|
||||
# === 4. _split_visual_field 单元测试 ===
|
||||
def test_split_visual_field(monkeypatch):
|
||||
wv = _import_wv(monkeypatch)
|
||||
# 典型旧schema混写:"中近景平视,暖光下一位妈妈略显疲惫地看向镜中,镜头缓缓推近,妈妈手扶洗手台微微低头"
|
||||
cam, vis, act = wv._split_visual_field(
|
||||
"中近景平视,暖光下妈妈略显疲惫地看向镜中,镜头缓缓推近,妈妈手扶洗手台微微低头"
|
||||
)
|
||||
assert cam and "中近景" in cam
|
||||
# 动作/表情段应被拆到 act(含"妈妈"主语+动词)
|
||||
assert act and ("手扶" in act or "疲惫" in act or "看向" in act)
|
||||
# 画面描述允许为空(当动作段本身承载了主要画面信息时),或含"暖光"
|
||||
assert (not vis) or ("暖光" in vis) or ("妈妈" in vis)
|
||||
|
||||
|
||||
def test_split_visual_short(monkeypatch):
|
||||
wv = _import_wv(monkeypatch)
|
||||
cam, vis, act = wv._split_visual_field("特写固定镜头")
|
||||
# 切不开时整体归camera/visual,action为空
|
||||
assert "特写" in cam
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -1254,3 +1254,268 @@ class TestV4ValidationDeadlineFixes:
|
||||
assert len(out) <= 25
|
||||
assert out.endswith("。")
|
||||
assert "推荐一款好产品" in out
|
||||
|
||||
|
||||
class TestStreamFinishReasonLength:
|
||||
"""流式finish_reason=length截断检测,确保截断raw被丢弃走下一attempt(P0 2026-10-14)"""
|
||||
|
||||
def _make_client(self, chunks, finish_after=None, finish_reason="stop"):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
client = MagicMock()
|
||||
client.is_available = True
|
||||
client.model = "test-model"
|
||||
client.last_finish_reason = ""
|
||||
|
||||
def _stream_iter(*a, **kw):
|
||||
for i, ch in enumerate(chunks):
|
||||
yield ch
|
||||
if finish_after is not None and i + 1 >= finish_after:
|
||||
client.last_finish_reason = finish_reason
|
||||
return
|
||||
|
||||
client.chat_completion_stream = MagicMock(side_effect=lambda *a, **kw: _stream_iter())
|
||||
client.chat_completion = MagicMock(return_value=None)
|
||||
return client
|
||||
|
||||
def test_length_truncation_returns_none(self):
|
||||
"""流式被max_tokens截断时应返回None,不降级同步,让外层重试"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
partial = '<script><copy_display_markdown>文案</copy_display_markdown><clips><clip index="1"><time_range>0-5秒</time_range>'
|
||||
client = self._make_client([partial], finish_after=1, finish_reason="length")
|
||||
on_delta = MagicMock()
|
||||
raw = vv._call_llm_once(
|
||||
client,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
temperature=0.8,
|
||||
max_tokens=2500,
|
||||
timeout=60,
|
||||
on_delta=on_delta,
|
||||
)
|
||||
assert raw is None, "截断时应返回None让外层重试"
|
||||
client.chat_completion.assert_not_called()
|
||||
|
||||
def test_normal_stop_returns_raw(self):
|
||||
"""正常finish_reason=stop时应返回完整raw"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
full = "<script><copy_display_markdown>文案</copy_display_markdown><clips></clips></script>"
|
||||
client = self._make_client([full], finish_after=1, finish_reason="stop")
|
||||
on_delta = MagicMock()
|
||||
raw = vv._call_llm_once(
|
||||
client,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
temperature=0.8,
|
||||
max_tokens=2500,
|
||||
timeout=60,
|
||||
on_delta=on_delta,
|
||||
)
|
||||
assert raw == full
|
||||
|
||||
def test_noop_lines_removed(self):
|
||||
"""确认死代码行已删除"""
|
||||
import inspect
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
src = inspect.getsource(vv._step_script_generation)
|
||||
assert 'llm_messages[0]["content"]' not in src
|
||||
assert 'llm_messages[-1]["content"]' not in src
|
||||
|
||||
|
||||
class TestMarkdownTimeRangeSplit:
|
||||
"""markdown 解析器支持按"0-4秒/4-8秒"时间范围开头分镜(v6 中文格式)"""
|
||||
|
||||
def test_time_range_split_produces_correct_shots(self):
|
||||
"""纯中文 markdown 用「0-4秒」「4-8秒」分段,应正确切分镜头"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=20, marketing_purpose="产品展示")
|
||||
raw = (
|
||||
"视频主题: 御众堂产后修复\n"
|
||||
"0-4秒\n"
|
||||
"画面: 门店招牌特写,阳光照射\n"
|
||||
"景别: 近景平拍,缓慢推进\n"
|
||||
'口播: "宝妈们别划走"\n'
|
||||
"BGM: 轻快钢琴\n"
|
||||
"4-8秒\n"
|
||||
"画面: 店内环境,技师微笑迎客\n"
|
||||
"景别: 中景平视,固定镜头\n"
|
||||
'口播: "御众堂产后修复,专业呵护"\n'
|
||||
"BGM: 轻快钢琴\n"
|
||||
"8-14秒\n"
|
||||
"画面: 修复仪器操作特写\n"
|
||||
"景别: 特写俯拍,缓慢横移\n"
|
||||
'口播: "先进设备,科学恢复"\n'
|
||||
"转场: 硬切\n"
|
||||
"14-20秒\n"
|
||||
"画面: 宝妈满意笑容+门店地址字幕\n"
|
||||
"景别: 中景平视,缓慢拉远\n"
|
||||
'口播: "点击下方预约,到店体验"\n'
|
||||
)
|
||||
result = vv._parse_script_markdown(raw, job, 20, "9:16", "产后修复")
|
||||
assert result is not None, "时间范围分块应解析成功"
|
||||
assert len(result["shots"]) == 4, f"应切分4个镜头,实际{len(result['shots'])}"
|
||||
assert result["shots"][0]["time_range"] == "0-4秒"
|
||||
assert result["shots"][1]["time_range"] == "4-8秒"
|
||||
assert result["shots"][2]["time_range"] == "8-14秒"
|
||||
assert result["shots"][3]["time_range"] == "14-20秒"
|
||||
assert "宝妈们别划走" in result["shots"][0]["voiceover"]
|
||||
assert "御众堂产后修复" in result["shots"][1]["voiceover"]
|
||||
assert "先进设备" in result["shots"][2]["voiceover"]
|
||||
assert "点击下方预约" in result["shots"][3]["voiceover"]
|
||||
assert result["title"] == "御众堂产后修复"
|
||||
full_vo = result["voiceover_script"]
|
||||
assert "宝妈们别划走" in full_vo
|
||||
assert "点击下方预约" in full_vo
|
||||
|
||||
def test_time_range_with_dash_and_tilde(self):
|
||||
"""支持「0-4秒」「4~8秒」「12到20秒」多种连接符"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=12)
|
||||
raw = (
|
||||
'0~4秒\n画面: 产品开箱\n口播: "快看这个"\n'
|
||||
'4-8秒\n画面: 产品细节\n口播: "质感满分"\n'
|
||||
'8到12秒\n画面: 使用效果\n口播: "赶紧下单"\n'
|
||||
)
|
||||
result = vv._parse_script_markdown(raw, job, 12, "9:16", "开箱")
|
||||
assert result is not None
|
||||
assert len(result["shots"]) == 3
|
||||
assert result["shots"][0]["time_range"] == "0-4秒"
|
||||
assert result["shots"][1]["time_range"] == "4-8秒"
|
||||
assert result["shots"][2]["time_range"] == "8-12秒"
|
||||
|
||||
def test_shot_number_marker_takes_priority_over_time(self):
|
||||
"""同时有「镜头1」和「0-4秒」时,优先按镜头编号分割(不重复切分)"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=8)
|
||||
raw = '镜头1 0-4秒\n画面: 开场\n口播: "开场话"\n' '镜头2 4-8秒\n画面: 主体\n口播: "主体话"\n'
|
||||
result = vv._parse_script_markdown(raw, job, 8, "9:16", "测试")
|
||||
assert result is not None
|
||||
assert len(result["shots"]) == 2
|
||||
assert result["shots"][0]["time_range"] == "0-4秒"
|
||||
|
||||
def test_no_markers_returns_none(self):
|
||||
"""没有任何分镜标记时返回None"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=10)
|
||||
raw = "今天给大家推荐一个好物,真的非常好用,价格实惠。"
|
||||
result = vv._parse_script_markdown(raw, job, 10, "9:16", "推荐")
|
||||
assert result is None
|
||||
|
||||
def test_parse_script_routes_to_time_markdown(self):
|
||||
"""_parse_script 调度:无XML时自动走时间范围markdown解析"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=8)
|
||||
raw = '0-4秒\n画面: 开场\n口播: "你好"\n' '4-8秒\n画面: 结束\n口播: "再见"\n'
|
||||
result = vv._parse_script(raw, job)
|
||||
assert result is not None
|
||||
assert len(result["shots"]) == 2
|
||||
assert "你好" in result["voiceover_script"]
|
||||
|
||||
|
||||
class TestSafeFormatHardening:
|
||||
"""_safe_format 必须能扛住模板里任意花括号样例,不抛异常、不返回半截内容"""
|
||||
|
||||
def test_missing_key_preserves_placeholder(self):
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("时长{duration}秒,{missing}保留", {"duration": "20"})
|
||||
assert out == "时长20秒,{missing}保留"
|
||||
|
||||
def test_attribute_access_on_string_preserves_placeholder(self):
|
||||
"""复现v5事故:{duration.1格式写全6项参数}访问str的非法属性不应抛异常"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format(
|
||||
"总时长:{duration.1格式写全6项参数,第二个镜头起末尾加转场}秒",
|
||||
{"duration": "20"},
|
||||
)
|
||||
assert "总时长:{duration.1格式写全6项参数,第二个镜头起末尾加转场}秒" == out
|
||||
|
||||
def test_json_braces_in_example_preserved(self):
|
||||
"""模板里给LLM示例JSON时,{"key":"val"}不应被当format_spec解析"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format(
|
||||
'输出JSON示例:{"key": "value", "n": 1},时长{duration}秒',
|
||||
{"duration": "20"},
|
||||
)
|
||||
assert '{"key": "value", "n": 1}' in out
|
||||
assert "时长20秒" in out
|
||||
|
||||
def test_chinese_example_braces_preserved(self):
|
||||
"""中文花括号示例如{示例}原样保留"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("规则:不要输出{示例}这类占位符,时长{duration}", {"duration": "20"})
|
||||
assert "{示例}" in out
|
||||
assert "时长20" in out
|
||||
|
||||
def test_escaped_braces_unescaped(self):
|
||||
"""{{escaped}} 正常输出 {escaped}"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("这是{{escaped}},时长{duration}", {"duration": "20"})
|
||||
assert out == "这是{escaped},时长20"
|
||||
|
||||
def test_legitimate_attribute_access_works(self):
|
||||
"""合法对象属性访问仍正常工作"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
class Obj:
|
||||
brand = "御众堂"
|
||||
|
||||
out = _safe_format("品牌:{obj.brand}", {"obj": Obj()})
|
||||
assert out == "品牌:御众堂"
|
||||
|
||||
def test_invalid_format_spec_preserves_placeholder(self):
|
||||
"""{val:invalid} 这种坏格式符原样保留"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("坏格式{val:invalid_chinese}结束", {"val": "x"})
|
||||
assert "{val:invalid_chinese}" in out
|
||||
|
||||
def test_normal_format_spec_still_works(self):
|
||||
"""合法格式符 {n:>3} 仍正常工作"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("数字{n:>3}结束", {"n": 5})
|
||||
assert "5" in out
|
||||
|
||||
Reference in New Issue
Block a user