Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50e833eeeb | |||
| 79624587c4 | |||
| ee871ac4aa | |||
| 50c8618b87 | |||
| 712464f030 |
+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>
|
||||
|
||||
@@ -1248,7 +1248,7 @@ def _parse_script_markdown(
|
||||
ratio: str,
|
||||
default_theme: str,
|
||||
) -> dict | None:
|
||||
"""中文 markdown 兜底解析(格式C):按"镜头1/镜头一/第1镜"等分割。"""
|
||||
"""中文 markdown 兜底解析(格式C/D):按"镜头1/镜头一/第1镜"或"0-4秒/4-8秒"时间范围分割。"""
|
||||
# 提取 theme
|
||||
theme = default_theme
|
||||
theme_match = _MD_FIELD_RE["theme"].search(raw)
|
||||
@@ -1421,6 +1421,27 @@ def _step_script_generation(
|
||||
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||||
|
||||
template = get_template("storyboard")
|
||||
# P0 观测:模板来自代码默认(DB 加载失败)时打 ERROR,避免静默回落
|
||||
# 判定方式:system_prompt 与 DEFAULT_TEMPLATES storyboard 完全一致
|
||||
try:
|
||||
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES as _DEF_T
|
||||
|
||||
_def_story = next((t for t in _DEF_T if t["prompt_type"] == "storyboard"), None)
|
||||
if (
|
||||
template is not None
|
||||
and _def_story is not None
|
||||
and template.system_prompt == _def_story["system_prompt"]
|
||||
and (template.example_output or "") == (_def_story.get("example_output") or "")
|
||||
):
|
||||
logger.error(
|
||||
"[爆款视频] storyboard 模板使用代码默认 v%s/%s(DB 加载失败或无激活模板),"
|
||||
"检查 DB 连接与 is_active——本次生成质量无保障,job_id=%s",
|
||||
template.version,
|
||||
template.name,
|
||||
job.id,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
marketing_purpose = getattr(job, "marketing_purpose", "") or "未指定"
|
||||
theme_hint = _determine_theme(image_analysis, marketing_purpose)
|
||||
|
||||
|
||||
@@ -166,17 +166,63 @@ 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是配置bug(如v5事故里{duration.1格式写全6项参数}),
|
||||
# 提升到 ERROR 级别,避免被warning海淹没
|
||||
logger.error(
|
||||
"[viral][prompt] 模板格式化有%d个占位符无法渲染(示例:%s),"
|
||||
"已原样保留——请检查system/user_prompt_template中花括号是否为样例文本而非变量",
|
||||
len(errors),
|
||||
errors[0],
|
||||
)
|
||||
_warn_once("safe_format", "format", f"{len(errors)}个占位符异常(首条{errors[0]})")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def render_system_prompt(template: PromptTemplate, **kwargs) -> str:
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1449,3 +1449,73 @@ class TestMarkdownTimeRangeSplit:
|
||||
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