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.
Files changed (59) hide show
  1. package/AGENTS.md +9 -0
  2. package/CHANGELOG.md +43 -0
  3. package/CONTRIBUTING.md +1 -0
  4. package/README.md +10 -3
  5. package/SECURITY.md +4 -0
  6. package/client.js +509 -296
  7. package/docs/ACTIVITY_TUNING.md +50 -0
  8. package/docs/CONTINUOUS_BROWSING.md +35 -0
  9. package/docs/FUN_FEATURE_REFERENCES.md +128 -0
  10. package/docs/LOCAL_COMPAT_AUDIT_2026-11-12.md +58 -0
  11. package/docs/NATURAL_AUTONOMY.md +91 -0
  12. package/docs/PRIVACY.md +7 -0
  13. package/docs/RELEASE_2_8_0.md +27 -0
  14. package/docs/RELEASE_AUDIT.md +16 -2
  15. package/docs/RELEASING.md +15 -0
  16. package/docs/TEST_MATRIX.md +9 -0
  17. package/docs/WORKFLOW_REFERENCES.md +57 -0
  18. package/docs/tool-evidence.json +6 -1
  19. package/index.js +941 -268
  20. package/lib/asr.py +22 -0
  21. package/lib/background-tools.mjs +1 -1
  22. package/lib/browse-preferences.mjs +154 -0
  23. package/lib/browse-progress.mjs +165 -0
  24. package/lib/comment.mjs +29 -46
  25. package/lib/config-policy.mjs +2 -1
  26. package/lib/curiosity.mjs +153 -0
  27. package/lib/favops.py +38 -6
  28. package/lib/learn-review.mjs +13 -3
  29. package/lib/learn-store.mjs +7 -0
  30. package/lib/learn.mjs +234 -69
  31. package/lib/login.mjs +20 -3
  32. package/lib/mcp.mjs +5 -2
  33. package/lib/memory-center.mjs +21 -19
  34. package/lib/mirror.mjs +15 -1
  35. package/lib/mode.mjs +23 -9
  36. package/lib/model-relay-adapter.mjs +1 -1
  37. package/lib/model-relay.mjs +21 -4
  38. package/lib/pace.mjs +15 -7
  39. package/lib/persona.mjs +41 -30
  40. package/lib/pet-session.mjs +9 -3
  41. package/lib/product-status.mjs +38 -2
  42. package/lib/prompts.mjs +38 -37
  43. package/lib/pv-session.mjs +16 -1
  44. package/lib/pv.mjs +18 -3
  45. package/lib/pv.py +168 -30
  46. package/lib/pv_cover.py +11 -1
  47. package/lib/pv_editorial.py +28 -4
  48. package/lib/pv_plan.py +20 -2
  49. package/lib/pv_verify.py +14 -0
  50. package/lib/session-continuation.mjs +47 -0
  51. package/lib/state.mjs +6 -2
  52. package/lib/sticker-automation.mjs +16 -2
  53. package/lib/sticker.mjs +4 -1
  54. package/lib/uvfetch.mjs +38 -12
  55. package/package.json +1 -1
  56. package/presets/deepmoyu.patch.yml +8 -10
  57. package/prompts/duty.md +30 -38
  58. package/vendor/bilibili-mcp/mcp_server.py +204 -47
  59. 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
- """按粗/细挑字体;找不到就退到雅黑,再不行退 PIL 默认。
59
+ """按粗/细挑字体,兼容 Windows、macOS 与 Linux 的中文字体。
60
60
 
61
61
  字体对象很贵(每次 truetype 都要读一遍表),逐帧调用必须缓存。
62
62
  """
63
- for cand in (name, "msyh.ttc", "msyhbd.ttc", "simhei.ttf"):
64
- p = FONT_DIR / cand
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
- try:
85
- f = ImageFont.truetype(str(NOTO_SANS), size)
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.set_variation_by_name(weight)
88
- except Exception:
89
- pass
90
- return f
91
- except Exception:
92
- return F(size, bold=weight in ("Medium", "Bold", "Black"))
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
- return shutil.which("ffmpeg") or "ffmpeg"
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
- await c.save(str(out_mp3))
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
- for ch in text:
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
- parts.append(buf)
1975
- buf = ""
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, words):
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
- total_chars = sum(max(1, len(s)) for s in sents)
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, len(s)) / total_chars
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
- if words:
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
- frame = subtitle(frame,str(shot.get("text","")),max(0,local),duration,shot.get("words"))
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
- head = clamp(tnow / .60, 0, 1)
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
 
@@ -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(len(part) for part in parts)
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 -= len(part)
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, 字数/3.8) + 0.35。"""
261
- return max(MIN_SHOT_SPEECH, len(str(shot.get("text") or "")) / SPEECH_RATE) + SHOT_OVERHEAD
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
- // 所以它们必须跟 mcp_server.py 一样铺过来;不铺 ⇒ 家目录没有、渲染直接报「脚本没铺好」。
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
- const verdict = await judge({ candidate, signal })
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
- if (meta.hash && meta.hash !== hash) return null
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.