dsh-deepmoyu 2.7.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -0
- package/CHANGELOG.md +43 -0
- package/CONTRIBUTING.md +1 -0
- package/README.md +10 -3
- package/SECURITY.md +4 -0
- package/client.js +509 -296
- package/docs/ACTIVITY_TUNING.md +50 -0
- package/docs/CONTINUOUS_BROWSING.md +35 -0
- package/docs/FUN_FEATURE_REFERENCES.md +128 -0
- package/docs/LOCAL_COMPAT_AUDIT_2026-11-12.md +58 -0
- package/docs/NATURAL_AUTONOMY.md +91 -0
- package/docs/PRIVACY.md +7 -0
- package/docs/RELEASE_2_8_0.md +27 -0
- package/docs/RELEASE_AUDIT.md +16 -2
- package/docs/RELEASING.md +15 -0
- package/docs/TEST_MATRIX.md +9 -0
- package/docs/WORKFLOW_REFERENCES.md +57 -0
- package/docs/tool-evidence.json +6 -1
- package/index.js +941 -268
- package/lib/asr.py +22 -0
- package/lib/background-tools.mjs +1 -1
- package/lib/browse-preferences.mjs +154 -0
- package/lib/browse-progress.mjs +165 -0
- package/lib/comment.mjs +29 -46
- package/lib/config-policy.mjs +2 -1
- package/lib/curiosity.mjs +153 -0
- package/lib/favops.py +38 -6
- package/lib/learn-review.mjs +13 -3
- package/lib/learn-store.mjs +7 -0
- package/lib/learn.mjs +234 -69
- package/lib/login.mjs +20 -3
- package/lib/mcp.mjs +5 -2
- package/lib/memory-center.mjs +21 -19
- package/lib/mirror.mjs +15 -1
- package/lib/mode.mjs +23 -9
- package/lib/model-relay-adapter.mjs +1 -1
- package/lib/model-relay.mjs +21 -4
- package/lib/pace.mjs +15 -7
- package/lib/persona.mjs +41 -30
- package/lib/pet-session.mjs +9 -3
- package/lib/product-status.mjs +38 -2
- package/lib/prompts.mjs +38 -37
- package/lib/pv-session.mjs +16 -1
- package/lib/pv.mjs +18 -3
- package/lib/pv.py +168 -30
- package/lib/pv_cover.py +11 -1
- package/lib/pv_editorial.py +28 -4
- package/lib/pv_plan.py +20 -2
- package/lib/pv_verify.py +14 -0
- package/lib/session-continuation.mjs +47 -0
- package/lib/state.mjs +6 -2
- package/lib/sticker-automation.mjs +16 -2
- package/lib/sticker.mjs +4 -1
- package/lib/uvfetch.mjs +38 -12
- package/package.json +1 -1
- package/presets/deepmoyu.patch.yml +8 -10
- package/prompts/duty.md +30 -38
- package/vendor/bilibili-mcp/mcp_server.py +204 -47
- package/vendor/bilibili-mcp/runtime_guard.py +164 -31
package/lib/pv.py
CHANGED
|
@@ -56,18 +56,27 @@ FONT_DIR = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
|
|
56
56
|
|
|
57
57
|
@lru_cache(maxsize=96)
|
|
58
58
|
def _font(name: str, size: int) -> ImageFont.FreeTypeFont:
|
|
59
|
-
"""
|
|
59
|
+
"""按粗/细挑字体,兼容 Windows、macOS 与 Linux 的中文字体。
|
|
60
60
|
|
|
61
61
|
字体对象很贵(每次 truetype 都要读一遍表),逐帧调用必须缓存。
|
|
62
62
|
"""
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
directories = [FONT_DIR, Path.home() / "Library/Fonts",
|
|
64
|
+
Path("/System/Library/Fonts"), Path("/System/Library/Fonts/Supplemental"),
|
|
65
|
+
Path("/Library/Fonts"), Path("/usr/share/fonts/opentype/noto"),
|
|
66
|
+
Path("/usr/share/fonts/truetype/noto"), Path.home() / ".local/share/fonts"]
|
|
67
|
+
if os.environ.get("DEEPMOYU_FONT_DIR"):
|
|
68
|
+
directories.insert(0, Path(os.environ["DEEPMOYU_FONT_DIR"]))
|
|
69
|
+
bold = "bd" in name.lower() or "bold" in name.lower()
|
|
70
|
+
names = (name, "msyhbd.ttc" if bold else "msyh.ttc", "msyhbd.ttc", "simhei.ttf",
|
|
71
|
+
"NotoSansSC-VF.ttf", "NotoSansCJK-Bold.ttc" if bold else "NotoSansCJK-Regular.ttc",
|
|
72
|
+
"PingFang.ttc", "STHeiti Medium.ttc", "Arial Unicode.ttf")
|
|
73
|
+
for p in (directory / cand for directory in directories for cand in names):
|
|
65
74
|
if p.exists():
|
|
66
75
|
try:
|
|
67
76
|
return ImageFont.truetype(str(p), size)
|
|
68
77
|
except Exception:
|
|
69
78
|
continue
|
|
70
|
-
return ImageFont.load_default()
|
|
79
|
+
return ImageFont.load_default(size=size)
|
|
71
80
|
|
|
72
81
|
|
|
73
82
|
def F(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
|
@@ -81,15 +90,20 @@ NOTO_SERIF = FONT_DIR / "NotoSerifSC-VF.ttf"
|
|
|
81
90
|
@lru_cache(maxsize=96)
|
|
82
91
|
def NF(size: int, weight: str = "Medium") -> ImageFont.FreeTypeFont:
|
|
83
92
|
"""思源黑体(可变字重)。屏幕小字比雅黑清楚,字幕正文用它。"""
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
candidates = [NOTO_SANS]
|
|
94
|
+
if os.environ.get("DEEPMOYU_FONT_DIR"):
|
|
95
|
+
candidates.insert(0, Path(os.environ["DEEPMOYU_FONT_DIR"]) / "NotoSansSC-VF.ttf")
|
|
96
|
+
for candidate in candidates:
|
|
86
97
|
try:
|
|
87
|
-
f.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
f = ImageFont.truetype(str(candidate), size)
|
|
99
|
+
try:
|
|
100
|
+
f.set_variation_by_name(weight)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return f
|
|
104
|
+
except OSError:
|
|
105
|
+
continue
|
|
106
|
+
return F(size, bold=weight in ("Medium", "Bold", "Black"))
|
|
93
107
|
|
|
94
108
|
|
|
95
109
|
def clamp(v, a, b):
|
|
@@ -720,7 +734,14 @@ def ffmpeg_exe():
|
|
|
720
734
|
import imageio_ffmpeg
|
|
721
735
|
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
722
736
|
except Exception:
|
|
723
|
-
|
|
737
|
+
pass
|
|
738
|
+
found = shutil.which("ffmpeg")
|
|
739
|
+
if not found:
|
|
740
|
+
# P3-35: 裸 FileNotFoundError 排不了障 —— 报错带安装指引
|
|
741
|
+
raise RuntimeError(
|
|
742
|
+
"找不到 ffmpeg —— imageio_ffmpeg 会随渲染依赖自动安装(uv/pip 装依赖时带上),"
|
|
743
|
+
"或手动安装 ffmpeg 并加入 PATH 后重试")
|
|
744
|
+
return found
|
|
724
745
|
|
|
725
746
|
|
|
726
747
|
def gsv_batch(texts, out_dir: Path, voice: dict, log=print):
|
|
@@ -763,6 +784,20 @@ def gsv_batch(texts, out_dir: Path, voice: dict, log=print):
|
|
|
763
784
|
"--top-p", str(voice.get("gsvTopP", 0.9)),
|
|
764
785
|
"--temp", str(voice.get("gsvTemp", 0.75)),
|
|
765
786
|
"--repetition-penalty", str(voice.get("gsvRepPen", 1.3))]
|
|
787
|
+
# P2-11: gsvPython 漏配时会落到渲染 venv(sys.executable)—— 它没有 torch/soundfile,
|
|
788
|
+
# 子进程必死在 _patch_torchaudio 的 ModuleNotFoundError 上且报错不指向根因。
|
|
789
|
+
# 启动前先用同一个解释器跑一次 import 预检(首次冷盘导入也可能要十几秒,给 120s)。
|
|
790
|
+
try:
|
|
791
|
+
probe = subprocess.run([py, "-c", "import torch, soundfile"], capture_output=True, timeout=120)
|
|
792
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
793
|
+
raise RuntimeError(
|
|
794
|
+
f"gsvPython({py})预检失败({type(exc).__name__})——在设置页把 gsvPython "
|
|
795
|
+
"指到 GPT-SoVITS 自带的 python,或换一个能用的解释器") from exc
|
|
796
|
+
if probe.returncode != 0:
|
|
797
|
+
tail = (probe.stderr or b"").decode("utf-8", "ignore").strip().splitlines()
|
|
798
|
+
raise RuntimeError(
|
|
799
|
+
f"gsvPython({py})缺少 torch/soundfile({(tail[-1] if tail else 'import 失败')})——"
|
|
800
|
+
"在设置页把 gsvPython 指到 GPT-SoVITS 自带的 python,或在其环境里 pip install torch soundfile")
|
|
766
801
|
log(f"🎙 GPT-SoVITS 批处理:{len(texts)} 句(加载模型要 30~50 秒)")
|
|
767
802
|
t0 = time.time()
|
|
768
803
|
# ⚠️ 必须给子进程钉死 UTF-8:它会往管道里打中文和 emoji,Windows 默认 GBK 会炸
|
|
@@ -798,7 +833,9 @@ def tts(text: str, out_mp3: Path, voice="zh-CN-XiaoyiNeural", rate="+8%", pitch=
|
|
|
798
833
|
|
|
799
834
|
async def go():
|
|
800
835
|
c = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch)
|
|
801
|
-
|
|
836
|
+
# P2-14: 主合成路径此前没有任何超时 —— websocket 半开时 save() 永久挂起,
|
|
837
|
+
# prepare_audio 停摆到 JS 强杀。120s 墙钟;TimeoutError 落到下面的 SAPI 兜底。
|
|
838
|
+
await asyncio.wait_for(c.save(str(out_mp3)), timeout=120)
|
|
802
839
|
|
|
803
840
|
asyncio.run(go())
|
|
804
841
|
# ⭐ 2026-10-26:阈值 1200→600 —— "嗯。"这种极短句的 mp3 可能不足 1200 字节,
|
|
@@ -903,6 +940,71 @@ def squeeze_silence(pcm, rate, max_gap=0.42, keep=0.30, th=450.0, edge=0.12):
|
|
|
903
940
|
return out
|
|
904
941
|
|
|
905
942
|
|
|
943
|
+
def weight_len(s) -> float:
|
|
944
|
+
"""P3-27: 加权字符数 —— CJK=1、数字=0.5、拉丁字母=0.35、其他=1。
|
|
945
|
+
|
|
946
|
+
等权字符数会让「数字/英文读得快」的句子字幕切得慢(探针 F:英文估 3 倍偏差)。
|
|
947
|
+
"""
|
|
948
|
+
total = 0.0
|
|
949
|
+
for ch in str(s or ""):
|
|
950
|
+
if "\u4e00" <= ch <= "\u9fff":
|
|
951
|
+
total += 1
|
|
952
|
+
elif ch.isascii() and ch.isdigit():
|
|
953
|
+
total += 0.5
|
|
954
|
+
elif ch.isascii() and ch.isalpha():
|
|
955
|
+
total += 0.35
|
|
956
|
+
else:
|
|
957
|
+
total += 1
|
|
958
|
+
return total
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def spoken_chars(text) -> float:
|
|
962
|
+
"""P1-5: 有效字数(去标点/空白):CJK 记 1,连续英文/数字串整段记 0.5。
|
|
963
|
+
|
|
964
|
+
与 bili-video/build.py 的 0.32s/字守卫同一套「字数观」——标点不占口播时长。
|
|
965
|
+
"""
|
|
966
|
+
total, run = 0.0, 0
|
|
967
|
+
for ch in str(text or ""):
|
|
968
|
+
if ch.isascii() and ch.isalnum():
|
|
969
|
+
run += 1 # 连续英文/数字串攒着,断开时整段记 0.5
|
|
970
|
+
continue
|
|
971
|
+
if run:
|
|
972
|
+
total += 0.5
|
|
973
|
+
run = 0
|
|
974
|
+
if "\u4e00" <= ch <= "\u9fff":
|
|
975
|
+
total += 1
|
|
976
|
+
if run:
|
|
977
|
+
total += 0.5
|
|
978
|
+
return total
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
# P1-5 上限的短句开销余量(秒):「嗯。」这类一两个字的句子有起振/收音固定开销,
|
|
982
|
+
# 纯 0.32s/字会误杀 —— build.py 只跑长句所以没这问题,插件的单句可以很短。
|
|
983
|
+
GSV_TAKE_MIN_RATE = 0.15 # s/有效字,低于即截断(真机 GSV 正常 0.20~0.25)
|
|
984
|
+
GSV_TAKE_MAX_RATE = 0.32 # s/字(含标点),超过即复读/拖长(与 bili-video/build.py:85-95 同口径)
|
|
985
|
+
GSV_TAKE_OVERHEAD = 1.0 # 上限的固定余量
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def gsv_take_guard(index: int, text: str, dur: float) -> None:
|
|
989
|
+
"""P1-5: GSV 句级时长守卫 —— 截断/复读 take 拦在渲染前,别烧几十分钟渲废片。
|
|
990
|
+
|
|
991
|
+
真机实测(bili-work 各 timeline.json):正常 GSV ≈0.20~0.25s/有效字;
|
|
992
|
+
探针实测的截断形态是 15 字句只出 0.8s(0.053s/字),复读形态是时长成倍超上限。
|
|
993
|
+
"""
|
|
994
|
+
text = str(text or "")
|
|
995
|
+
eff = spoken_chars(text)
|
|
996
|
+
raw = max(1, len(text.strip()))
|
|
997
|
+
if eff > 0 and dur < eff * GSV_TAKE_MIN_RATE:
|
|
998
|
+
raise RuntimeError(
|
|
999
|
+
f"第 {index+1} 句 GSV 音频异常偏短(疑似截断):{dur:.2f}s / {eff:g} 有效字"
|
|
1000
|
+
f"= {dur / eff:.2f}s/字(下限 {GSV_TAKE_MIN_RATE}s/字)——句首:「{text[:12]}」")
|
|
1001
|
+
if dur > raw * GSV_TAKE_MAX_RATE + GSV_TAKE_OVERHEAD:
|
|
1002
|
+
raise RuntimeError(
|
|
1003
|
+
f"第 {index+1} 句 GSV 音频异常偏长(疑似复读):{dur:.2f}s / {raw} 字"
|
|
1004
|
+
f"= {dur / raw:.2f}s/字(上限 {GSV_TAKE_MAX_RATE}s/字,同 bili-video/build.py)"
|
|
1005
|
+
f"——句首:「{text[:12]}」")
|
|
1006
|
+
|
|
1007
|
+
|
|
906
1008
|
def envelope(pcm, rate, fps):
|
|
907
1009
|
"""把波形压成每帧一个 0~1 的响度包络(口型用)。"""
|
|
908
1010
|
step = rate / fps
|
|
@@ -1962,17 +2064,40 @@ def split_sentences(text, maxlen=18):
|
|
|
1962
2064
|
|
|
1963
2065
|
用户说「字幕对不上」⇒ 根因是字幕显示的是摘要、语音念的是全文。
|
|
1964
2066
|
现在一句话一个镜头、字幕就是这句原话,字幕自然对得上。
|
|
2067
|
+
|
|
2068
|
+
⭐ P3-26(2026-11-12 审计):连续省略号(……)合并成一个边界,不再逐字切出
|
|
2069
|
+
孤立「…」碎句;句末标点后紧跟闭引号(」』"')时不当断句 —— 引号里的
|
|
2070
|
+
「你好。」是引语的一部分,句子续到真正的结尾(探针 A 实测)。
|
|
1965
2071
|
"""
|
|
1966
2072
|
text = str(text or "").strip()
|
|
1967
2073
|
if not text:
|
|
1968
2074
|
return []
|
|
2075
|
+
closing = "」』\"'"
|
|
1969
2076
|
# 先按句末标点切
|
|
1970
2077
|
parts, buf = [], ""
|
|
1971
|
-
|
|
2078
|
+
i, n = 0, len(text)
|
|
2079
|
+
while i < n:
|
|
2080
|
+
ch = text[i]
|
|
1972
2081
|
buf += ch
|
|
2082
|
+
if ch == "…":
|
|
2083
|
+
# 连续省略号(……)并成一个边界单位
|
|
2084
|
+
while i + 1 < n and text[i + 1] == "…":
|
|
2085
|
+
i += 1
|
|
2086
|
+
buf += "…"
|
|
1973
2087
|
if ch in "。!?…;!?;":
|
|
1974
|
-
|
|
1975
|
-
|
|
2088
|
+
j = i + 1
|
|
2089
|
+
while j < n and text[j] in closing:
|
|
2090
|
+
j += 1
|
|
2091
|
+
if j > i + 1 and j < n:
|
|
2092
|
+
# 句末标点后紧跟闭引号且后面还有正文 ⇒ 引语内标点,不断句,引号吸进本句
|
|
2093
|
+
buf += text[i + 1:j]
|
|
2094
|
+
i = j
|
|
2095
|
+
continue
|
|
2096
|
+
# 有实词才落句:开头就是省略号(buf 只剩标点)时不切出孤立「…」碎句
|
|
2097
|
+
if buf.strip(closing + "。!?…;!?;,,、:: \u3000\t"):
|
|
2098
|
+
parts.append(buf)
|
|
2099
|
+
buf = ""
|
|
2100
|
+
i += 1
|
|
1976
2101
|
if buf.strip():
|
|
1977
2102
|
parts.append(buf)
|
|
1978
2103
|
out = []
|
|
@@ -1996,11 +2121,11 @@ def split_sentences(text, maxlen=18):
|
|
|
1996
2121
|
return out
|
|
1997
2122
|
|
|
1998
2123
|
|
|
1999
|
-
def subtitle(img, text, t, dur
|
|
2124
|
+
def subtitle(img, text, t, dur):
|
|
2000
2125
|
"""底部字幕:只占左栏内容列(SUB_W),永不盖到右侧的角色。
|
|
2001
2126
|
|
|
2002
2127
|
⚠️ 「字幕对不上」的根因:以前字幕显示的是整段摘要,语音念的是全文。
|
|
2003
|
-
|
|
2128
|
+
现在按句切分、按加权字数分配时长,字幕只显示「此刻正在念的那一句」。
|
|
2004
2129
|
浅色主题 ⇒ 白色圆角条 + 深墨字,念过的字变浅。
|
|
2005
2130
|
"""
|
|
2006
2131
|
if not text:
|
|
@@ -2009,10 +2134,11 @@ def subtitle(img, text, t, dur, words):
|
|
|
2009
2134
|
# —— 选出当前这句 ——
|
|
2010
2135
|
sents = split_sentences(text, maxlen=22) or [text]
|
|
2011
2136
|
if len(sents) > 1 and dur > 0:
|
|
2012
|
-
|
|
2137
|
+
# P3-27: 按 weight_len 分摊 —— 数字/英文读得快,等权字符数会把它们切慢
|
|
2138
|
+
total_chars = sum(max(1.0, weight_len(s)) for s in sents)
|
|
2013
2139
|
acc, cur = 0.0, sents[-1]
|
|
2014
2140
|
for s in sents:
|
|
2015
|
-
span = dur * max(1,
|
|
2141
|
+
span = dur * max(1.0, weight_len(s)) / total_chars
|
|
2016
2142
|
if t < acc + span:
|
|
2017
2143
|
cur = s
|
|
2018
2144
|
break
|
|
@@ -2033,13 +2159,7 @@ def subtitle(img, text, t, dur, words):
|
|
|
2033
2159
|
outline=(150, 190, 226, 175), width=2)
|
|
2034
2160
|
paste_rgba(img, box, (x0, y0 - 14))
|
|
2035
2161
|
done_chars = len(cur)
|
|
2036
|
-
|
|
2037
|
-
done_chars = 0
|
|
2038
|
-
for wd in words:
|
|
2039
|
-
if t * 10_000_000 >= wd["offset"]:
|
|
2040
|
-
done_chars = min(len(cur), done_chars + len(wd["text"]))
|
|
2041
|
-
else:
|
|
2042
|
-
break
|
|
2162
|
+
# 2026-11-12 审计移除:words 逐字卡拉OK高亮是无生产者的死路径,且 100ns ticks 单位无文档
|
|
2043
2163
|
idx = 0
|
|
2044
2164
|
for i, ln in enumerate(lines):
|
|
2045
2165
|
yy = y0 + i * lh
|
|
@@ -2232,6 +2352,12 @@ def prepare_audio(shots, base_voice, work, fps):
|
|
|
2232
2352
|
pass
|
|
2233
2353
|
pcm,rate = to_wav(source,wav_path)
|
|
2234
2354
|
if engine == "gsv":
|
|
2355
|
+
# P2-10: 全静音/近静音的 take 要拦在合成后 —— 以前一路渲完整片才在 verify
|
|
2356
|
+
# 报「缺少有效对白或口型帧」,不指向根因句,白烧几十分钟。
|
|
2357
|
+
_rms = math.sqrt(sum(v * v for v in pcm) / max(1, len(pcm))) if pcm else 0.0
|
|
2358
|
+
if _rms < 100.0: # 16bit 满幅 32767;正常语音 RMS 在千级
|
|
2359
|
+
raise RuntimeError(
|
|
2360
|
+
f"第 {index+1} 句 GSV 输出为静音(RMS {_rms:.0f} < 100)——句首:「{text[:12]}」")
|
|
2235
2361
|
# ⭐ 2026-10-26:把 squeeze_silence 真正接上 —— 这个函数当初就是为
|
|
2236
2362
|
# 「GPT-SoVITS 分句边界偶发几秒死寂」写的,但一直没有任何调用点。
|
|
2237
2363
|
# 压缩后**写回 wav_path**:timeline 的时长和最终混音(重读 wav/xx.wav)
|
|
@@ -2242,6 +2368,8 @@ def prepare_audio(shots, base_voice, work, fps):
|
|
|
2242
2368
|
w.setsampwidth(2)
|
|
2243
2369
|
w.setframerate(rate)
|
|
2244
2370
|
w.writeframes(pcm.tobytes())
|
|
2371
|
+
# P1-5: 压完静音再按最终时长判 —— squeeze 能救回的(句中长死寂)不误杀
|
|
2372
|
+
gsv_take_guard(index, text, len(pcm) / rate)
|
|
2245
2373
|
dur = len(pcm) / rate
|
|
2246
2374
|
if dur <= .02:
|
|
2247
2375
|
raise RuntimeError(f"第 {index+1} 句音频为空")
|
|
@@ -2293,6 +2421,12 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
|
|
|
2293
2421
|
raise ValueError("fps must be 1..60; scale must be 0.1..2")
|
|
2294
2422
|
t_start = time.time()
|
|
2295
2423
|
shots = plan["shots"]
|
|
2424
|
+
# P2-13: 含空文本镜头的 plan 会把整片渲完才在 verify 必挂 —— 开头就拦,报错指向第几镜
|
|
2425
|
+
for _i, _shot in enumerate(shots):
|
|
2426
|
+
if not str((_shot or {}).get("text") or "").strip():
|
|
2427
|
+
raise ValueError(
|
|
2428
|
+
f"第 {_i+1} 镜(id={(_shot or {}).get('id', '?')})没有台词"
|
|
2429
|
+
"——纯视觉镜头暂不支持,给它一句旁白或删掉")
|
|
2296
2430
|
# Editorial v2 and dialogue plans use the 1080p stage. Legacy cards remain 720p.
|
|
2297
2431
|
editorial_plan = plan.get("schema") == "deepmoyu-editorial-v2"
|
|
2298
2432
|
editorial_renderer = None
|
|
@@ -2381,7 +2515,8 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
|
|
|
2381
2515
|
elif shot.get("kind") == "dialogue":
|
|
2382
2516
|
frame = dialogue_subtitle(frame,shot,active,max(0,local))
|
|
2383
2517
|
else:
|
|
2384
|
-
|
|
2518
|
+
# 2026-11-12 审计移除:words 字段读取随卡拉OK死路径一并删掉(无生产者)
|
|
2519
|
+
frame = subtitle(frame,str(shot.get("text","")),max(0,local),duration)
|
|
2385
2520
|
if frame.size != (NAT_W,NAT_H):
|
|
2386
2521
|
frame = frame.resize((NAT_W,NAT_H),Image.Resampling.LANCZOS)
|
|
2387
2522
|
if (W2,H2) != (NAT_W,NAT_H):
|
|
@@ -2481,7 +2616,10 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
|
|
|
2481
2616
|
frame = Image.blend(prev, frame, amount)
|
|
2482
2617
|
# 片头片尾各 600ms 淡入淡出(像一段真正剪过的短片,而不是突然开始)
|
|
2483
2618
|
if dialogue_plan:
|
|
2484
|
-
|
|
2619
|
+
# P3-34: 淡入 0.60s 会盖过 0.18s lead 的开头语音(开头两个字有声音没画面)
|
|
2620
|
+
# —— 淡入时长不得超过 lead;lead=0 时直接不做淡入(避免除零)。
|
|
2621
|
+
fade_in = min(.60, lead) if lead > 0 else 0.0
|
|
2622
|
+
head = 1.0 if fade_in <= 0 else clamp(tnow / fade_in, 0, 1)
|
|
2485
2623
|
tailf = clamp((total - tnow) / .60, 0, 1)
|
|
2486
2624
|
alpha = min(head, tailf)
|
|
2487
2625
|
if alpha < 1:
|
package/lib/pv_cover.py
CHANGED
|
@@ -16,11 +16,20 @@ import argparse
|
|
|
16
16
|
import json
|
|
17
17
|
import os
|
|
18
18
|
import re
|
|
19
|
+
import sys
|
|
19
20
|
from pathlib import Path
|
|
20
21
|
from typing import Any
|
|
21
22
|
|
|
22
23
|
from PIL import Image, ImageDraw, ImageFilter
|
|
23
24
|
|
|
25
|
+
# P3-31: Windows 管道默认 GBK —— 中文日志会乱码甚至把 print 打挂,先钉 UTF-8
|
|
26
|
+
#(写法照 lib/pv.py:41-46)
|
|
27
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
28
|
+
try:
|
|
29
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
24
33
|
try: # Loaded as a top-level module by the CLI and tests.
|
|
25
34
|
from pv import F, NF, Fish, Portrait, bg_ocean
|
|
26
35
|
except ImportError: # pragma: no cover - package import fallback
|
|
@@ -436,7 +445,8 @@ def main() -> int:
|
|
|
436
445
|
if drawn != title:
|
|
437
446
|
print(f" {ratio} 排不下全文,封面用:{drawn}", flush=True)
|
|
438
447
|
return 0
|
|
439
|
-
except (OSError, ValueError, TypeError, json.JSONDecodeError, RuntimeError) as exc:
|
|
448
|
+
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError, RuntimeError) as exc:
|
|
449
|
+
# P3-29: KeyError 补进兜底 —— plan 带未知 theme 时给一行人话,不再裸 traceback
|
|
440
450
|
parser.exit(1, f"封面生成失败:{exc}\n")
|
|
441
451
|
|
|
442
452
|
|
package/lib/pv_editorial.py
CHANGED
|
@@ -16,9 +16,10 @@ from PIL import Image, ImageDraw, ImageFont
|
|
|
16
16
|
try:
|
|
17
17
|
# pv imports this module lazily during build, so the shared portrait driver
|
|
18
18
|
# is available without creating a second character implementation.
|
|
19
|
-
from pv import Portrait, Fish, DialoguePortrait
|
|
19
|
+
from pv import Portrait, Fish, DialoguePortrait, _font as shared_font
|
|
20
20
|
except ImportError: # pragma: no cover - standalone renderer smoke tests
|
|
21
21
|
Portrait = Fish = DialoguePortrait = None
|
|
22
|
+
shared_font = None
|
|
22
23
|
|
|
23
24
|
WIDTH, HEIGHT = 1920, 1080
|
|
24
25
|
# Keep the three persisted names, but make them bright editorial directions rather
|
|
@@ -33,6 +34,8 @@ THEMES = {
|
|
|
33
34
|
|
|
34
35
|
@lru_cache(maxsize=128)
|
|
35
36
|
def font(size, serif=False):
|
|
37
|
+
if shared_font is not None:
|
|
38
|
+
return shared_font("NotoSerifSC-VF.ttf" if serif else "NotoSansSC-VF.ttf", size)
|
|
36
39
|
directory = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
|
37
40
|
names = ("NotoSerifSC-VF.ttf", "simhei.ttf", "msyhbd.ttc") if serif else ("NotoSansSC-VF.ttf", "msyhbd.ttc", "simhei.ttf")
|
|
38
41
|
candidates = [directory / name for name in names]
|
|
@@ -42,7 +45,7 @@ def font(size, serif=False):
|
|
|
42
45
|
return ImageFont.truetype(str(candidate), size)
|
|
43
46
|
except OSError:
|
|
44
47
|
pass
|
|
45
|
-
return ImageFont.load_default()
|
|
48
|
+
return ImageFont.load_default(size=size)
|
|
46
49
|
|
|
47
50
|
|
|
48
51
|
def ease(value):
|
|
@@ -173,14 +176,32 @@ def caption_parts(text, limit=23):
|
|
|
173
176
|
return parts
|
|
174
177
|
|
|
175
178
|
|
|
179
|
+
def weight_len(s) -> float:
|
|
180
|
+
"""P3-27: 加权字符数(CJK=1、数字=0.5、拉丁字母=0.35、其他=1)—— 与 lib/pv.py 同款。
|
|
181
|
+
|
|
182
|
+
等权字符数会让「数字/英文读得快」的短语字幕切得慢(探针 F:英文估 3 倍偏差)。
|
|
183
|
+
"""
|
|
184
|
+
total = 0.0
|
|
185
|
+
for ch in str(s or ""):
|
|
186
|
+
if "\u4e00" <= ch <= "\u9fff":
|
|
187
|
+
total += 1
|
|
188
|
+
elif ch.isascii() and ch.isdigit():
|
|
189
|
+
total += 0.5
|
|
190
|
+
elif ch.isascii() and ch.isalpha():
|
|
191
|
+
total += 0.35
|
|
192
|
+
else:
|
|
193
|
+
total += 1
|
|
194
|
+
return total
|
|
195
|
+
|
|
196
|
+
|
|
176
197
|
def active_caption(text, t, duration):
|
|
177
198
|
"""Phrase timing is proportional within the measured utterance, not ASR."""
|
|
178
199
|
if not text or not 0 <= t < duration:
|
|
179
200
|
return ""
|
|
180
201
|
parts = caption_parts(text)
|
|
181
|
-
position = t / max(.01, duration) * sum(
|
|
202
|
+
position = t / max(.01, duration) * sum(max(1.0, weight_len(part)) for part in parts)
|
|
182
203
|
for part in parts:
|
|
183
|
-
position -=
|
|
204
|
+
position -= max(1.0, weight_len(part))
|
|
184
205
|
if position < 0:
|
|
185
206
|
return part
|
|
186
207
|
return parts[-1]
|
|
@@ -232,6 +253,9 @@ def place_host(image, draw, state, t, colors, compact=False):
|
|
|
232
253
|
|
|
233
254
|
def render(shot, t, duration, state):
|
|
234
255
|
theme = state.get("theme", "paper")
|
|
256
|
+
if theme not in THEMES:
|
|
257
|
+
# P3-29: 未知 theme 直接 KeyError 裸崩(探针 I)—— 回退 paper,渲染继续而不是渲到一半炸
|
|
258
|
+
theme = "paper"
|
|
235
259
|
colors = THEMES[theme]
|
|
236
260
|
image = backdrop(theme).copy().convert("RGBA")
|
|
237
261
|
draw = ImageDraw.Draw(image)
|
package/lib/pv_plan.py
CHANGED
|
@@ -256,9 +256,27 @@ CLOSING_LINES = ("所以,这碗白米饭终于能吃了?", # ⚠️ 收
|
|
|
256
256
|
"等本鱼把笔记放好。饭要趁热,脑袋也要开工。") # auto_plan 各出现一次,改时两处同步
|
|
257
257
|
|
|
258
258
|
|
|
259
|
+
def weight_len(s) -> float:
|
|
260
|
+
"""P3-27: 加权字符数(CJK=1、数字=0.5、拉丁字母=0.35、其他=1)—— 与 lib/pv.py 同款。
|
|
261
|
+
|
|
262
|
+
等权 len() 会给英文/数字多的镜头多算时长(它们读得快),规划预算随之虚高。
|
|
263
|
+
"""
|
|
264
|
+
total = 0.0
|
|
265
|
+
for ch in str(s or ""):
|
|
266
|
+
if "\u4e00" <= ch <= "\u9fff":
|
|
267
|
+
total += 1
|
|
268
|
+
elif ch.isascii() and ch.isdigit():
|
|
269
|
+
total += 0.5
|
|
270
|
+
elif ch.isascii() and ch.isalpha():
|
|
271
|
+
total += 0.35
|
|
272
|
+
else:
|
|
273
|
+
total += 1
|
|
274
|
+
return total
|
|
275
|
+
|
|
276
|
+
|
|
259
277
|
def est_dur(shot: dict) -> float:
|
|
260
|
-
"""单镜时长估算:max(2.0,
|
|
261
|
-
return max(MIN_SHOT_SPEECH,
|
|
278
|
+
"""单镜时长估算:max(2.0, 加权字数/4.4) + 0.35。"""
|
|
279
|
+
return max(MIN_SHOT_SPEECH, weight_len(str(shot.get("text") or "")) / SPEECH_RATE) + SHOT_OVERHEAD
|
|
262
280
|
|
|
263
281
|
|
|
264
282
|
def plan_seconds(shots: list) -> float:
|
package/lib/pv_verify.py
CHANGED
|
@@ -11,11 +11,19 @@ import json
|
|
|
11
11
|
import math
|
|
12
12
|
import subprocess
|
|
13
13
|
import sys
|
|
14
|
+
import time
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
import imageio_ffmpeg
|
|
17
18
|
from PIL import Image
|
|
18
19
|
|
|
20
|
+
# P3-31: Windows 管道默认 GBK —— 错误 JSON 里的中文会乱码,先钉 UTF-8(写法照 lib/pv.py:41-46)
|
|
21
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
22
|
+
try:
|
|
23
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
|
|
19
27
|
|
|
20
28
|
def _probe(video: Path) -> tuple[dict, int]:
|
|
21
29
|
# imageio-ffmpeg bundles ffmpeg, not ffprobe. Decode without frame duplication
|
|
@@ -24,10 +32,16 @@ def _probe(video: Path) -> tuple[dict, int]:
|
|
|
24
32
|
reader = imageio_ffmpeg.read_frames(
|
|
25
33
|
str(video), pix_fmt="rgb24", input_params=["-xerror"], output_params=["-vsync", "0"]
|
|
26
34
|
)
|
|
35
|
+
started = time.monotonic()
|
|
27
36
|
try:
|
|
28
37
|
metadata = next(reader)
|
|
29
38
|
for _ in reader:
|
|
30
39
|
frame_count += 1
|
|
40
|
+
if frame_count % 500 == 0 and time.monotonic() - started > 90.0:
|
|
41
|
+
# P3-30: 全帧解码没有看门狗会挂到 JS 任务级强杀(对照 _decode_frames 的 120s)
|
|
42
|
+
raise TimeoutError # 哨兵:下面的分支原样转成带上下文的 TimeoutError
|
|
43
|
+
except TimeoutError:
|
|
44
|
+
raise TimeoutError(f"视频解码超时(>90s,已解 {frame_count} 帧):{video.name}") from None
|
|
31
45
|
except Exception as exc:
|
|
32
46
|
raise ValueError(f"视频无法完整解码:{str(exc)[-300:]}") from exc
|
|
33
47
|
finally:
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Pure decision for resuming a truncated owned browsing turn; never resumes a host goal itself. */
|
|
2
|
+
export const MAX_CONSECUTIVE_TRUNCATIONS = 2
|
|
3
|
+
const finite = value => typeof value === 'number' && Number.isFinite(value)
|
|
4
|
+
const count = value => Number.isSafeInteger(value) && value >= 0 ? value : 0
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `madeProgress` means an observed successful browsing tool result, never model narration.
|
|
8
|
+
* The caller captures a real turn identity and rechecks current ownership/intervention before resume().
|
|
9
|
+
* Budget values are cumulative for this run; unknown usage cannot authorize extra budgeted work.
|
|
10
|
+
*/
|
|
11
|
+
export function sessionContinuationDecision({ goal, run, selected, endReason, turnId,
|
|
12
|
+
madeProgress = false, intervened = false, state, now = Date.now() } = {}) {
|
|
13
|
+
const previous = {
|
|
14
|
+
lastTurn: typeof state?.lastTurn === 'string' ? state.lastTurn : '',
|
|
15
|
+
consecutiveTruncations: count(state?.consecutiveTruncations),
|
|
16
|
+
}
|
|
17
|
+
const turn = typeof turnId === 'string' && turnId ? turnId
|
|
18
|
+
: Number.isSafeInteger(turnId) && turnId >= 0 ? String(turnId) : ''
|
|
19
|
+
const result = (reason, next = previous, recover = false) => ({ recover, reason, state: next })
|
|
20
|
+
if (!turn) return result('turn-unknown')
|
|
21
|
+
if (previous.lastTurn === turn) return result('already-handled')
|
|
22
|
+
const consecutive = endReason === 'completed' && madeProgress === true ? 0 : previous.consecutiveTruncations
|
|
23
|
+
const next = { lastTurn: turn, consecutiveTruncations: consecutive + (endReason === 'max-tokens' ? 1 : 0) }
|
|
24
|
+
if (endReason !== 'max-tokens') return result(endReason === 'completed' ? 'completed' : 'turn-not-truncated', next)
|
|
25
|
+
if (selected !== true) return result('mode-exit', next)
|
|
26
|
+
if (!goal || !run || typeof run.goalId !== 'string' || !run.goalId || goal.id !== run.goalId) return result('goal-not-owned', next)
|
|
27
|
+
if (run.stoppedAt || intervened === true) return result('interrupted', next)
|
|
28
|
+
if (madeProgress !== true) return result('no-progress', next)
|
|
29
|
+
if (goal.phase !== 'active' || goal.activation !== 'disarmed') return result('goal-not-disarmed', next)
|
|
30
|
+
if (!Number.isSafeInteger(goal.roundsStarted) || !Number.isSafeInteger(goal.maxGoalRounds)
|
|
31
|
+
|| goal.roundsStarted < 0 || goal.maxGoalRounds < 1 || goal.roundsStarted >= goal.maxGoalRounds) return result('round-limit', next)
|
|
32
|
+
if (!finite(now) || (run.deadlineAt && (!finite(run.deadlineAt) || now >= run.deadlineAt))) return result('time-limit', next)
|
|
33
|
+
for (const [budgetKey, usageKey, reason] of [
|
|
34
|
+
['tokenBudget', 'tokensUsed', 'token-budget'],
|
|
35
|
+
['totalTokenBudget', 'totalTokensUsed', 'total-budget'],
|
|
36
|
+
]) {
|
|
37
|
+
const budget = run[budgetKey]
|
|
38
|
+
if (budget !== undefined && (!finite(budget) || budget < 0)) return result('budget-invalid', next)
|
|
39
|
+
if (budget > 0) {
|
|
40
|
+
const used = run[usageKey]
|
|
41
|
+
if (!finite(used) || used < 0) return result('usage-unknown', next)
|
|
42
|
+
if (used >= budget) return result(reason, next)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (next.consecutiveTruncations > MAX_CONSECUTIVE_TRUNCATIONS) return result('truncation-limit', next)
|
|
46
|
+
return result('max-tokens', next, true)
|
|
47
|
+
}
|
package/lib/state.mjs
CHANGED
|
@@ -79,11 +79,15 @@ export function ensureHomeFiles(pluginDir, stateDir, log = () => {}) {
|
|
|
79
79
|
[join(pluginDir, 'vendor', 'bilibili-mcp', 'runtime_guard.py'), join(stateDir, 'runtime_guard.py'), true],
|
|
80
80
|
[join(pluginDir, 'lib', 'asr.py'), join(stateDir, 'asr.py'), true],
|
|
81
81
|
[join(pluginDir, 'lib', 'favops.py'), join(stateDir, 'favops.py'), true],
|
|
82
|
-
// 🎬 「昨日冲浪报告」视频(2026-10-22):pv.mjs
|
|
83
|
-
//
|
|
82
|
+
// 🎬 「昨日冲浪报告」视频(2026-10-22):pv.mjs 优先起插件 lib 下的脚本,家目录副本是
|
|
83
|
+
// pluginDir/lib 不可用时的回退 —— 回退形态也要能独立跑全。审计 P3-21(2026-11-12)
|
|
84
|
+
// 补齐 pv_verify.py / pv_editorial.py:pv.py 渲染中段 `import pv_editorial`,verify 阶段
|
|
85
|
+
// 独立跑 pv_verify.py —— 这两个以前没铺,回退部署会在 plan/cover 都跑完之后才崩。
|
|
84
86
|
[join(pluginDir, 'lib', 'pv.py'), join(stateDir, 'pv.py'), true],
|
|
85
87
|
[join(pluginDir, 'lib', 'pv_plan.py'), join(stateDir, 'pv_plan.py'), true],
|
|
86
88
|
[join(pluginDir, 'lib', 'pv_cover.py'), join(stateDir, 'pv_cover.py'), true],
|
|
89
|
+
[join(pluginDir, 'lib', 'pv_verify.py'), join(stateDir, 'pv_verify.py'), true],
|
|
90
|
+
[join(pluginDir, 'lib', 'pv_editorial.py'), join(stateDir, 'pv_editorial.py'), true],
|
|
87
91
|
// 🎙 真·本鱼音色(2026-10-22):pv.py 用 subprocess 调它跑 GPT-SoVITS 批量合成,
|
|
88
92
|
// 同样必须在家目录里 —— 不然设置页填了音色也会报「找不到 pv_gsv.py」。
|
|
89
93
|
[join(pluginDir, 'lib', 'pv_gsv.py'), join(stateDir, 'pv_gsv.py'), true],
|
|
@@ -84,7 +84,18 @@ export function createStickerAutomation({ stateDir, getConfig, now = Date.now, r
|
|
|
84
84
|
if (veto) return { suitable: false, score: 0, description: '', tags: [], reason: veto, source: 'lexicon' }
|
|
85
85
|
if (!enabled || typeof judge !== 'function') return { suitable: true, score: null, description: '', tags: [], reason: '', source: 'off' }
|
|
86
86
|
try {
|
|
87
|
-
|
|
87
|
+
/* 审计 P2-7(2026-11-12):20s 超时全靠 judge 内部把 signal 传播给宿主 llm——宿主
|
|
88
|
+
* 不理会 AbortSignal 时 judge 永不 settle,drain 的 active 永不释放,后续 offer 全部
|
|
89
|
+
* 静默拒绝,自动收集停摆到重启。这里加一道**独立墙钟**兜底(judge 自己的 20s + 1s 余量):
|
|
90
|
+
* 超时按 fail-open 记 'error',队列照常流动——与文件头的 fail-open 约定一致。 */
|
|
91
|
+
const verdict = await Promise.race([
|
|
92
|
+
Promise.resolve(judge({ candidate, signal })),
|
|
93
|
+
new Promise((resolve) => {
|
|
94
|
+
const timer = setTimeout(() => resolve(null), 21_000)
|
|
95
|
+
timer.unref?.()
|
|
96
|
+
}),
|
|
97
|
+
])
|
|
98
|
+
if (verdict === null) return { suitable: true, score: null, description: '', tags: [], reason: '判图 21 秒无响应(宿主线路挂起),本轮 fail-open 跳过', source: 'timeout' }
|
|
88
99
|
if (!verdict || typeof verdict !== 'object') return { suitable: true, score: null, description: '', tags: [], reason: '', source: 'off' }
|
|
89
100
|
return { suitable: verdict.suitable !== false, score: Number.isFinite(Number(verdict.score)) ? Number(verdict.score) : null,
|
|
90
101
|
description: String(verdict.description || '').slice(0, 60), tags: Array.isArray(verdict.tags) ? verdict.tags.slice(0, 4).map(String) : [],
|
|
@@ -99,8 +110,11 @@ export function createStickerAutomation({ stateDir, getConfig, now = Date.now, r
|
|
|
99
110
|
}
|
|
100
111
|
const schedule = () => {
|
|
101
112
|
if (disposed || active || immediate || !queue.length) return
|
|
113
|
+
/* 审计 P3-12(2026-11-12):unref 的 immediate 在事件循环只靠长 timer 空转时会被推迟。
|
|
114
|
+
* 修复 = **去掉 unref**:ref 的 setImmediate 在同一轮 check 相位就执行(不引入 timer 相位的
|
|
115
|
+
* 延迟,测试时序不变),并且会把事件循环拽醒——空闲进程里排队项不再被无限期搁置。
|
|
116
|
+
* stop() 的 clearImmediate 取消语义不变。 */
|
|
102
117
|
immediate = setImmediate(() => { immediate = null; void drain() })
|
|
103
|
-
immediate.unref?.()
|
|
104
118
|
}
|
|
105
119
|
const drain = async () => {
|
|
106
120
|
if (active || disposed) return
|
package/lib/sticker.mjs
CHANGED
|
@@ -177,7 +177,10 @@ export function snapshotStickerForSend(stateDir, input) {
|
|
|
177
177
|
const bytes = readFileSync(info.file)
|
|
178
178
|
const hash = sha256(bytes)
|
|
179
179
|
// A stale ledger hash fails closed; never send bytes that changed after review.
|
|
180
|
-
|
|
180
|
+
// 审计 P3-5(2026-11-12):缺 hash 的历史条目以前 fail-open(同名换字节发现不了)——
|
|
181
|
+
// 现在 fail-closed,与「发送绑定已批准字节快照」一致。恢复路径:重新 collect + 过审
|
|
182
|
+
//(decideSticker 落库时必写 hash)。
|
|
183
|
+
if (meta.hash !== hash) return null
|
|
181
184
|
return { name, source: 'collected', bytes: bytes.length, hash, ...makeSendSnapshot(bytes, info.fmt) }
|
|
182
185
|
}
|
|
183
186
|
// Builtins are read-only and always approved.
|