dsh-deepmoyu 2.3.0 → 2.4.2

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 (90) hide show
  1. package/AGENTS.md +93 -153
  2. package/CHANGELOG.md +36 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +143 -81
  7. package/SECURITY.md +21 -0
  8. package/assets/screenshots/memory.png +0 -0
  9. package/assets/screenshots/menu.png +0 -0
  10. package/assets/screenshots/workspace.png +0 -0
  11. package/client.js +1404 -441
  12. package/cordis.patch.yml +1 -1
  13. package/docs/ASSETS.md +25 -0
  14. package/docs/DEPLOYMENT.md +102 -0
  15. package/docs/PRIVACY.md +32 -0
  16. package/docs/RELEASE_AUDIT.md +70 -0
  17. package/docs/RELEASING.md +94 -0
  18. package/docs/TEST_MATRIX.md +77 -0
  19. package/docs/assets-permissions.json +20 -0
  20. package/docs/tool-evidence.json +196 -0
  21. package/index.js +1095 -285
  22. package/lib/asr.py +41 -35
  23. package/lib/background-output.mjs +64 -17
  24. package/lib/background-tools.mjs +1 -0
  25. package/lib/companion.mjs +43 -0
  26. package/lib/config-policy.mjs +20 -0
  27. package/lib/favops.py +9 -2
  28. package/lib/ids.mjs +41 -0
  29. package/lib/login-session.mjs +34 -0
  30. package/lib/login.mjs +5 -4
  31. package/lib/mcp.mjs +3 -2
  32. package/lib/memory-center.mjs +74 -0
  33. package/lib/mode.mjs +6 -7
  34. package/lib/model-relay.mjs +5 -4
  35. package/lib/open-directory.mjs +93 -0
  36. package/lib/pace.mjs +92 -12
  37. package/lib/persona.mjs +46 -0
  38. package/lib/product-status.mjs +123 -0
  39. package/lib/prompts.mjs +40 -5
  40. package/lib/pv-production.mjs +280 -0
  41. package/lib/pv-session.mjs +316 -0
  42. package/lib/pv.mjs +398 -306
  43. package/lib/pv.py +84 -22
  44. package/lib/pv_cover.py +108 -26
  45. package/lib/pv_editorial.py +336 -0
  46. package/lib/pv_verify.py +267 -0
  47. package/lib/python-env.mjs +61 -0
  48. package/lib/reply.mjs +10 -3
  49. package/lib/run-settings.mjs +6 -0
  50. package/lib/sticker-automation.mjs +5 -4
  51. package/lib/sticker.mjs +135 -25
  52. package/lib/tool-scope.mjs +65 -0
  53. package/lib/update.mjs +265 -0
  54. package/lib/uvfetch.mjs +165 -153
  55. package/lib/verify.mjs +111 -127
  56. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  57. package/package.json +23 -12
  58. package/presets/deepmoyu.patch.yml +1 -1
  59. package/prompts/duty.md +4 -3
  60. package/screenshots.json +5 -0
  61. package/selfcheck.mjs +21 -6
  62. package/vendor/bilibili-mcp/requirements.in +9 -0
  63. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  64. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  65. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  66. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  67. package/tests/automation-routes.test.mjs +0 -183
  68. package/tests/automation.test.mjs +0 -79
  69. package/tests/background-tools.test.mjs +0 -182
  70. package/tests/background-window.test.mjs +0 -75
  71. package/tests/dup-cross-channel.test.mjs +0 -158
  72. package/tests/manual-browsing.test.mjs +0 -671
  73. package/tests/mcp-restart.test.mjs +0 -131
  74. package/tests/memory-chain.test.mjs +0 -413
  75. package/tests/model-relay.test.mjs +0 -116
  76. package/tests/persona.test.mjs +0 -67
  77. package/tests/pet-run-routes.test.mjs +0 -127
  78. package/tests/pet-run-settings.test.mjs +0 -38
  79. package/tests/pet-session.test.mjs +0 -125
  80. package/tests/pv-stage1.test.mjs +0 -203
  81. package/tests/sticker-routes.test.mjs +0 -141
  82. package/tests/sticker-upload-routes.test.mjs +0 -97
  83. package/tests/sticker-upload.test.mjs +0 -122
  84. package/tests/sticker.test.mjs +0 -169
  85. package/tests/test_guard_cross_channel.py +0 -141
  86. package/tests/test_pv_cover.py +0 -95
  87. package/tests/test_pv_stage1.py +0 -250
  88. package/tests/test_reply_pictures.py +0 -255
  89. package/tests/token-ledger.test.mjs +0 -78
  90. package/tests/verify_pv_sample.py +0 -91
@@ -0,0 +1,336 @@
1
+ """Deterministic 1080p motion editorial: kinetic short-form studio direction.
2
+
3
+ No network, external media, or generated code is accepted by this renderer.
4
+ Speech remains the source of timing; all visual text is separately authored.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import os
10
+ import random
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ from PIL import Image, ImageDraw, ImageFont
15
+
16
+ try:
17
+ # pv imports this module lazily during build, so the shared portrait driver
18
+ # is available without creating a second character implementation.
19
+ from pv import Portrait, Fish, DialoguePortrait
20
+ except ImportError: # pragma: no cover - standalone renderer smoke tests
21
+ Portrait = Fish = DialoguePortrait = None
22
+
23
+ WIDTH, HEIGHT = 1920, 1080
24
+ # Keep the three persisted names, but make them bright editorial directions rather
25
+ # than three flat color swatches. Each palette has a light canvas, a strong ink
26
+ # color and a warm/cool accent pair so a frame stays inviting on a phone screen.
27
+ THEMES = {
28
+ "paper": {"bg": "#fff8f3", "ink": "#17243b", "muted": "#6d7482", "accent": "#ed6a57", "accent2": "#4f69b5", "panel": "#fffdfb", "panel2": "#ffe4db", "contrast": "#fff0e9"},
29
+ "midnight": {"bg": "#f2f5ff", "ink": "#192440", "muted": "#68748d", "accent": "#5b63d4", "accent2": "#ed7a6d", "panel": "#ffffff", "panel2": "#e5eaff", "contrast": "#ffefeb"},
30
+ "signal": {"bg": "#fff8e8", "ink": "#2b2230", "muted": "#776d73", "accent": "#de584c", "accent2": "#e1a044", "panel": "#fffdf8", "panel2": "#ffe2c8", "contrast": "#fff0d9"},
31
+ }
32
+
33
+
34
+ @lru_cache(maxsize=128)
35
+ def font(size, serif=False):
36
+ directory = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
37
+ names = ("NotoSerifSC-VF.ttf", "simhei.ttf", "msyhbd.ttc") if serif else ("NotoSansSC-VF.ttf", "msyhbd.ttc", "simhei.ttf")
38
+ candidates = [directory / name for name in names]
39
+ candidates += [Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc")]
40
+ for candidate in candidates:
41
+ try:
42
+ return ImageFont.truetype(str(candidate), size)
43
+ except OSError:
44
+ pass
45
+ return ImageFont.load_default()
46
+
47
+
48
+ def ease(value):
49
+ return 1 - (1 - max(0, min(1, value))) ** 3
50
+
51
+
52
+ def wrap(draw, text, face, width):
53
+ lines, line = [], ""
54
+ for character in str(text):
55
+ if character == "\n":
56
+ lines.append(line)
57
+ line = ""
58
+ elif line and draw.textlength(line + character, font=face) > width:
59
+ lines.append(line)
60
+ line = character
61
+ else:
62
+ line += character
63
+ if line:
64
+ lines.append(line)
65
+ return lines
66
+
67
+
68
+ def typeset(draw, text, box, color, size=96, minimum=30, serif=False, align="left"):
69
+ """Fit the complete text by measured pixels, without truncation."""
70
+ if not text:
71
+ return
72
+ x, y, width, height = box
73
+ for point in range(size, minimum - 1, -2):
74
+ face = font(point, serif)
75
+ lines = wrap(draw, text, face, width)
76
+ # Include the glyph bearing (CJK fonts have a nonzero top bearing).
77
+ advance = int(point * 1.34)
78
+ if len(lines) * advance <= height and all(draw.textlength(line, font=face) <= width for line in lines):
79
+ break
80
+ else:
81
+ raise ValueError(f"屏幕文字无法完整排入画面,请精简:{text}")
82
+ for index, line in enumerate(lines):
83
+ left = x + (width - draw.textlength(line, font=face)) / 2 if align == "center" else x
84
+ draw.text((int(left), int(y + index * advance)), line, font=face, fill=color, anchor="lt")
85
+
86
+
87
+ def _gradient(top, bottom, width=WIDTH, height=HEIGHT):
88
+ image = Image.new("RGB", (width, height))
89
+ pixels = image.load()
90
+ for y in range(height):
91
+ ratio = y / max(1, height - 1)
92
+ color = tuple(int(top[i] * (1 - ratio) + bottom[i] * ratio) for i in range(3))
93
+ for x in range(width):
94
+ pixels[x, y] = color
95
+ return image
96
+
97
+
98
+ @lru_cache(maxsize=6)
99
+ def backdrop(theme):
100
+ colors = THEMES[theme]
101
+ base = tuple(int(colors["bg"][index:index + 2], 16) for index in (1, 3, 5))
102
+ panel = tuple(int(colors["panel"][index:index + 2], 16) for index in (1, 3, 5))
103
+ image = _gradient(tuple(min(255, value + 10) for value in base), base)
104
+ draw = ImageDraw.Draw(image)
105
+ # A fine grid and offset bands create depth without decorative blobs.
106
+ for x in range(0, WIDTH, 80):
107
+ draw.line((x, 0, x, HEIGHT), fill=tuple(min(255, c + 8) for c in panel), width=1)
108
+ for y in range(0, HEIGHT, 80):
109
+ draw.line((0, y, WIDTH, y), fill=tuple(min(255, c + 8) for c in panel), width=1)
110
+ draw.polygon([(0, 760), (560, 430), (1220, 770), (680, 1080), (0, 1080)], fill=colors["panel"])
111
+ draw.polygon([(1510, 0), (1920, 0), (1920, 420), (1730, 300)], fill=colors["panel2"])
112
+ draw.line((112, 136, 1808, 136), fill=colors["muted"], width=2)
113
+ # Sparse print grain, deterministic and low contrast.
114
+ rng = random.Random(187)
115
+ for _ in range(2600):
116
+ x, y = rng.randrange(WIDTH), rng.randrange(HEIGHT)
117
+ draw.point((x, y), fill=colors["panel2"])
118
+ return image
119
+
120
+
121
+ def fish_cameo(canvas, state, x, y, size=380, alpha=255):
122
+ """Let the established fish character appear as a speaker or reaction beat."""
123
+ fish = state.get("fish")
124
+ if fish is None:
125
+ return
126
+ try:
127
+ layer = fish.render(mouth=float(state.get("mouth", 0)), sway=0.18, bounce=0.12)
128
+ layer = layer.resize((size, size), Image.Resampling.LANCZOS)
129
+ if alpha < 255:
130
+ layer.putalpha(layer.getchannel("A").point(lambda value: int(value * alpha / 255)))
131
+ canvas.alpha_composite(layer, (int(x), int(y)))
132
+ except (AttributeError, OSError, ValueError):
133
+ # A missing optional portrait must never stop a text-led production.
134
+ return
135
+
136
+
137
+ def reaction_card(draw, text, colors, x=1260, y=715, width=520):
138
+ """A compact editorial reaction card, separate from the full subtitle lane."""
139
+ draw.rounded_rectangle((x, y, x + width, y + 108), radius=18, fill=colors["contrast"])
140
+ draw.ellipse((x + 24, y + 34, x + 42, y + 52), fill=colors["accent"])
141
+ typeset(draw, text, (x + 68, y + 27, width - 92, 60), colors["bg"], 27, minimum=18)
142
+
143
+
144
+ def motif(draw, center, radius, colors, t, variant="orbit"):
145
+ x, y = center
146
+ for index, factor in enumerate((.48, .74, 1.0)):
147
+ r = radius * factor
148
+ draw.ellipse((x-r, y-r, x+r, y+r), outline=colors["accent"] if index == 1 else colors["muted"], width=2 if index != 1 else 3)
149
+ angle = t * (.22 + index * .06) + index * 2.1
150
+ px, py = x + math.cos(angle)*r, y + math.sin(angle)*r
151
+ dot = 8 if index == 1 else 5
152
+ draw.ellipse((px-dot, py-dot, px+dot, py+dot), fill=colors["accent"])
153
+ if variant == "spark":
154
+ points = []
155
+ for index in range(16):
156
+ angle = index * math.pi / 8 + t * .05
157
+ r = radius * (.43 if index % 2 == 0 else .14)
158
+ points.append((x + math.cos(angle)*r, y + math.sin(angle)*r))
159
+ draw.polygon(points, fill=colors["accent"])
160
+ else:
161
+ typeset(draw, "?", (x-radius*.25, y-radius*.40, radius*.5, radius*.9), colors["accent"], int(radius*.7), align="center", serif=True)
162
+
163
+
164
+ def caption_parts(text, limit=23):
165
+ parts, current = [], ""
166
+ for character in text:
167
+ current += character
168
+ if len(current) >= limit or (character in ",。!?;:!?;" and len(current) >= 7):
169
+ parts.append(current)
170
+ current = ""
171
+ if current:
172
+ parts.append(current)
173
+ return parts
174
+
175
+
176
+ def active_caption(text, t, duration):
177
+ """Phrase timing is proportional within the measured utterance, not ASR."""
178
+ if not text or not 0 <= t < duration:
179
+ return ""
180
+ parts = caption_parts(text)
181
+ position = t / max(.01, duration) * sum(len(part) for part in parts)
182
+ for part in parts:
183
+ position -= len(part)
184
+ if position < 0:
185
+ return part
186
+ return parts[-1]
187
+
188
+
189
+ def contrast_ink(colors):
190
+ """Pick text that stays readable inside the warm caption card."""
191
+ raw = colors.get("contrast", "#ffffff").lstrip("#")
192
+ try:
193
+ luminance = .2126 * int(raw[0:2], 16) + .7152 * int(raw[2:4], 16) + .0722 * int(raw[4:6], 16)
194
+ except (ValueError, IndexError):
195
+ luminance = 255
196
+ # Light editorial caption lanes need the dark ink; the old dark-theme rule
197
+ # selected the canvas color here and made captions nearly disappear.
198
+ return colors["ink"] if luminance > 150 else colors["bg"]
199
+
200
+
201
+ @lru_cache(maxsize=1)
202
+ def host_avatar():
203
+ """Load the reviewed rigged portrait so the presenter keeps the mouth animation."""
204
+ if DialoguePortrait is not None:
205
+ return DialoguePortrait()
206
+ if Portrait is not None:
207
+ portrait = Portrait(520)
208
+ if portrait.ok:
209
+ return portrait
210
+ return Fish(520) if Fish is not None else None
211
+
212
+
213
+ def place_host(image, draw, state, t, colors, compact=False):
214
+ """A restrained presenter cameo; mouth follows the measured voice envelope."""
215
+ avatar = state.get("dialogue_portrait") or host_avatar()
216
+ if avatar is None:
217
+ return
218
+ target = 390 if compact else 520
219
+ driven = avatar.render(mouth=state.get("mouth", 0.0), expression="happy" if state.get("mouth", 0) > .45 else "neutral")
220
+ ratio = target / max(1, driven.height)
221
+ driven = driven.resize((max(1, int(driven.width * ratio)), target), Image.Resampling.LANCZOS)
222
+ width, height = driven.size
223
+ x = WIDTH - width - 86
224
+ y = 290 if not compact else 205
225
+ # A quiet stage gives the transparent portrait a deliberate home in the frame.
226
+ draw.rounded_rectangle((x - 34, y + 44, x + width + 34, y + height + 22), radius=28,
227
+ fill=colors["panel"], outline=colors["muted"], width=2)
228
+ draw.ellipse((x + 28, y + 20, x + 62, y + 54), fill=colors["accent"])
229
+ draw.text((x + 78, y + 17), "大肥鱼在说", font=font(22), fill=colors["muted"])
230
+ image.alpha_composite(driven, (x, y))
231
+
232
+
233
+ def render(shot, t, duration, state):
234
+ theme = state.get("theme", "paper")
235
+ colors = THEMES[theme]
236
+ image = backdrop(theme).copy().convert("RGBA")
237
+ draw = ImageDraw.Draw(image)
238
+ index, count = state.get("shot_index", 0), max(1, state.get("shot_count", 1))
239
+ draw.ellipse((112, 69, 128, 85), fill=colors["accent"])
240
+ draw.text((145, 58), "DeepMoYu / 浪里有料", font=font(26), fill=colors["ink"])
241
+ scene = shot.get("scene", "headline")
242
+ label = {"hook": "先看一个问题", "headline": "换个角度", "compare": "放在一起看", "steps": "拆开来看", "quote": "记住这一句", "spotlight": "值得放大", "outro": "把问题留给你"}.get(scene, "继续看")
243
+ label = shot.get("kicker") or label
244
+ draw.text((112, 174), label, font=font(26), fill=colors["accent"])
245
+ folio = f"{index+1:02d} / {count:02d}"
246
+ draw.text((1808-draw.textlength(folio, font=font(26)), 58), folio, font=font(26), fill=colors["muted"])
247
+ energy = shot.get("energy", "warm")
248
+ energy_amp = {"quiet": 12, "warm": 24, "punchy": 42, "surprise": 58}.get(energy, 24)
249
+ rise = int((1 - ease(t / (.32 if energy in {"punchy", "surprise"} else .48))) * energy_amp)
250
+ headline, body = shot.get("headline", ""), shot.get("body", "")
251
+
252
+ if scene in ("hook", "headline", "outro"):
253
+ typeset(draw, headline, (112, 267+rise, 1120, 340), colors["ink"], 118 if scene == "hook" else 106, serif=True)
254
+ typeset(draw, body, (118, 667+rise, 1080, 130), colors["muted"], 34)
255
+ draw.rectangle((114, 623+rise, 294+int(ease(t/.7)*160), 631+rise), fill=colors["accent"])
256
+ motif(draw, (1515, 493), 222 + 5*math.sin(t*.6), colors, t, "orbit" if scene == "hook" else "spark")
257
+ if shot.get("speaker") == "fish":
258
+ place_host(image, draw, state, t, colors)
259
+ if scene == "outro":
260
+ draw.line((1420, 779, 1630, 779), fill=colors["accent"], width=4)
261
+ draw.line((1600, 751, 1630, 779, 1600, 807), fill=colors["accent"], width=4)
262
+
263
+ elif scene == "compare":
264
+ typeset(draw, headline, (112, 245+rise, 1650, 130), colors["ink"], 82, serif=True)
265
+ for slot, item in enumerate(shot.get("items", [])):
266
+ x = 112 + slot*876
267
+ y = 405 + int((1-ease((t-slot*.13)/.5))*35)
268
+ draw.rounded_rectangle((x, y, x+820, y+378), radius=14, fill=colors["panel"] if slot == 0 else colors["panel2"])
269
+ ink = colors["ink"]
270
+ accent = colors["accent"] if slot == 0 else colors["accent2"]
271
+ draw.text((x+40, y+28), f"0{slot+1}", font=font(25), fill=accent)
272
+ typeset(draw, item["label"], (x+40, y+108, 740, 150), ink, 66)
273
+ typeset(draw, item.get("detail", ""), (x+40, y+275, 740, 82), ink, 32)
274
+ if shot.get("speaker") == "fish":
275
+ place_host(image, draw, state, t, colors, compact=True)
276
+
277
+ elif scene == "steps":
278
+ typeset(draw, headline, (112, 245+rise, 1650, 130), colors["ink"], 82, serif=True)
279
+ items = shot.get("items", [])
280
+ width = (1696 - max(0, len(items)-1)*28) / max(1, len(items))
281
+ for slot, item in enumerate(items):
282
+ x = int(112 + slot*(width+28))
283
+ y = 410 + int((1-ease((t-slot*.16)/.5))*40)
284
+ draw.rectangle((x, y, int(x+width), y+385), fill=colors["panel"])
285
+ draw.rectangle((x, y, int(x+width*ease((t-slot*.16)/.8)), y+5), fill=colors["accent"])
286
+ draw.text((x+28, y+24), f"{slot+1:02d}", font=font(67, True), fill=colors["accent"])
287
+ typeset(draw, item["label"], (x+28, y+140, width-56, 116), colors["ink"], 48)
288
+ typeset(draw, item.get("detail", ""), (x+28, y+273, width-56, 96), colors["muted"], 29, minimum=23)
289
+ if shot.get("speaker") == "fish":
290
+ place_host(image, draw, state, t, colors, compact=True)
291
+
292
+ elif scene == "quote":
293
+ draw.text((96, 210+rise), "“", font=font(235, True), fill=colors["accent"], anchor="lt")
294
+ typeset(draw, headline, (270, 325+rise, 1390, 300), colors["ink"], 110, serif=True)
295
+ draw.rectangle((278, 692, 350, 696), fill=colors["accent"])
296
+ typeset(draw, body, (385, 670, 1260, 120), colors["muted"], 32)
297
+ if shot.get("speaker") == "fish":
298
+ place_host(image, draw, state, t, colors, compact=True)
299
+
300
+ elif scene == "spotlight":
301
+ draw.ellipse((1190, 245, 1780, 835), fill=colors["panel"])
302
+ motif(draw, (1485, 540), 228, colors, t, "spark")
303
+ typeset(draw, headline, (112, 282+rise, 1030, 300), colors["ink"], 114, serif=True)
304
+ typeset(draw, body, (118, 660, 1010, 130), colors["muted"], 34)
305
+ if shot.get("speaker") == "fish":
306
+ place_host(image, draw, state, t, colors, compact=True)
307
+
308
+ # Dedicated caption and source lanes never share the content field.
309
+ caption = active_caption(shot.get("text", ""), t, duration)
310
+ if caption:
311
+ draw.rounded_rectangle((112, 863, 1808, 975), radius=8, fill=colors["contrast"])
312
+ typeset(draw, caption, (145, 891, 1630, 70), contrast_ink(colors), 43, align="center")
313
+ source_ids = shot.get("source_ids", [])
314
+ source = state.get("source_map", {}).get(source_ids[0], {}) if source_ids else {}
315
+ provenance = "逐字引用" if shot.get("provenance") == "source-quote" else "公开来源改编"
316
+ source_text = f"{provenance} / {source.get('title', '')}" if source else ("虚构演绎 / 原创评论" if state.get("mode") == "custom" else "原创评论")
317
+ typeset(draw, source_text, (112, 1000, 1450, 38), colors["muted"], 22, minimum=12)
318
+ draw.rectangle((0, 1068, WIDTH, 1079), fill=colors["panel"])
319
+ progress = (index + max(0, min(1, t/max(.01, duration)))) / count
320
+ draw.rectangle((0, 1068, int(WIDTH*progress), 1079), fill=colors["accent"])
321
+ return image
322
+
323
+
324
+ def cover(title, ratio, theme="paper"):
325
+ """Full-bleed editorial cover, sharing the film's typography and palette."""
326
+ colors = THEMES[theme]
327
+ width = 1920 if ratio == "16x9" else 1440
328
+ image = Image.new("RGB", (width, HEIGHT), colors["bg"])
329
+ draw = ImageDraw.Draw(image)
330
+ draw.rectangle((0, 0, 28, HEIGHT), fill=colors["accent"])
331
+ # Decorative geometry contains no extra claims or cover copy.
332
+ motif(draw, (width-150, 720), 340, colors, 1.5, "spark")
333
+ draw.rectangle((90, 170, width-90, 785), fill=colors["bg"])
334
+ typeset(draw, title, (100, 265, width-215, 485), colors["ink"], 150, minimum=38, serif=True)
335
+ draw.rectangle((104, 830, 410, 844), fill=colors["accent"])
336
+ return image.convert("RGBA")
@@ -0,0 +1,267 @@
1
+ """Validate a locally rendered PV package without ASR or human-listening claims.
2
+
3
+ Usage: python pv_verify.py --video movie.mp4 --timeline timeline.json --outdir package [--max-duration 300] [--target-duration 90]
4
+ Writes outdir/verification.json and exits nonzero for a failed check.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import array
10
+ import json
11
+ import math
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ import imageio_ffmpeg
17
+ from PIL import Image
18
+
19
+
20
+ def _probe(video: Path) -> tuple[dict, int]:
21
+ # imageio-ffmpeg bundles ffmpeg, not ffprobe. Decode without frame duplication
22
+ # so nb_frames is an observed count rather than fps * container duration.
23
+ frame_count = 0
24
+ reader = imageio_ffmpeg.read_frames(
25
+ str(video), pix_fmt="rgb24", input_params=["-xerror"], output_params=["-vsync", "0"]
26
+ )
27
+ try:
28
+ metadata = next(reader)
29
+ for _ in reader:
30
+ frame_count += 1
31
+ except Exception as exc:
32
+ raise ValueError(f"视频无法完整解码:{str(exc)[-300:]}") from exc
33
+ finally:
34
+ reader.close()
35
+ if not metadata or frame_count <= 0:
36
+ raise ValueError("视频没有可解码画面")
37
+ fps = _duration(metadata.get("fps"))
38
+ width, height = metadata.get("size", (0, 0))
39
+ duration = _duration(metadata.get("duration"))
40
+ audio_codec = str(metadata.get("audio_codec") or "")
41
+ streams = [{"codec_type": "video", "width": int(width), "height": int(height),
42
+ "r_frame_rate": str(fps), "nb_frames": str(frame_count)}]
43
+ if audio_codec:
44
+ streams.append({"codec_type": "audio", "codec_name": audio_codec})
45
+ return {"streams": streams, "format": {"duration": str(duration)}}, frame_count
46
+
47
+
48
+ def _stream(streams: list[dict], kind: str) -> dict | None:
49
+ return next((row for row in streams if row.get("codec_type") == kind), None)
50
+
51
+
52
+ def _duration(value: object) -> float:
53
+ try:
54
+ number = float(value)
55
+ except (TypeError, ValueError, OverflowError):
56
+ return 0.0
57
+ return number if math.isfinite(number) and number >= 0 else 0.0
58
+
59
+
60
+ def _number(value: object, field: str) -> float:
61
+ # Do not silently turn negative/NaN/missing cue times into zero, or accept
62
+ # JSON booleans and numeric strings as measured timeline values.
63
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
64
+ raise ValueError(f"{field} 必须是有限数值")
65
+ try:
66
+ number = float(value)
67
+ except OverflowError as exc:
68
+ raise ValueError(f"{field} 必须是有限数值") from exc
69
+ if not math.isfinite(number):
70
+ raise ValueError(f"{field} 必须是有限数值")
71
+ return number
72
+
73
+
74
+ def _decode_frames(video: Path, outdir: Path, width: int, height: int, frame_count: int) -> dict:
75
+ # Select by actual decoded indices, including the final frame at low fps.
76
+ # Raw RGB bytes never pass through text-mode subprocess capture.
77
+ indices = {"first": 0, "middle": frame_count // 2, "last": frame_count - 1}
78
+ selected = sorted(set(indices.values()))
79
+ expression = "+".join(f"eq(n\\,{index})" for index in selected)
80
+ try:
81
+ result = subprocess.run(
82
+ [imageio_ffmpeg.get_ffmpeg_exe(), "-nostdin", "-v", "error", "-xerror",
83
+ "-i", str(video), "-map", "0:v:0", "-vf", f"select={expression}",
84
+ "-vsync", "0", "-pix_fmt", "rgb24", "-vcodec", "rawvideo", "-f", "rawvideo", "pipe:1"],
85
+ capture_output=True, check=False, timeout=120,
86
+ )
87
+ except subprocess.TimeoutExpired as exc:
88
+ raise ValueError("视频抽帧超时") from exc
89
+ if result.returncode != 0:
90
+ error = result.stderr.decode("utf-8", errors="replace").strip()[-300:]
91
+ raise ValueError("视频抽帧失败:" + (error or f"exit {result.returncode}"))
92
+ frame_bytes = width * height * 3
93
+ if len(result.stdout) != frame_bytes * len(selected):
94
+ raise ValueError("视频缺少可解码的首帧、中帧或末帧")
95
+ for offset, source_index in enumerate(selected):
96
+ pixels = result.stdout[offset * frame_bytes:(offset + 1) * frame_bytes]
97
+ image = Image.frombytes("RGB", (width, height), pixels)
98
+ for label, index in indices.items():
99
+ if index == source_index:
100
+ image.save(outdir / f"verify_{label}.png")
101
+ return indices
102
+
103
+
104
+ def _timeline_check(timeline: dict, duration: float) -> dict:
105
+ if not isinstance(timeline, dict) or timeline.get("schema") != "dafeiyu-timeline-v1":
106
+ raise ValueError("timeline schema 不匹配")
107
+ fps = _number(timeline.get("fps"), "timeline.fps")
108
+ planned = _number(timeline.get("duration"), "timeline.duration")
109
+ if not 1 <= fps <= 60 or planned <= 0:
110
+ raise ValueError("timeline 的 fps 或 duration 无效")
111
+ cues = timeline.get("cues")
112
+ if not isinstance(cues, list) or not cues:
113
+ raise ValueError("timeline 缺少有效 cues")
114
+ previous = 0.0
115
+ for index, cue in enumerate(cues):
116
+ field = f"timeline.cues[{index}]"
117
+ if not isinstance(cue, dict):
118
+ raise ValueError(f"{field} 无效")
119
+ for key in ("shot", "speaker", "text", "start", "end", "audio_duration", "engine", "mouth_frames"):
120
+ if key not in cue:
121
+ raise ValueError(f"{field} 缺少 {key}")
122
+ start = _number(cue["start"], f"{field}.start")
123
+ end = _number(cue["end"], f"{field}.end")
124
+ audio = _number(cue["audio_duration"], f"{field}.audio_duration")
125
+ if start < 0 or end > planned + 1e-6 or end <= start or audio <= 0:
126
+ raise ValueError(f"{field} 时间超出 timeline 范围或为空")
127
+ if abs((end - start) - audio) > 0.15 or start + 1e-6 < previous:
128
+ raise ValueError(f"{field} 时间重叠或与音频时长不一致")
129
+ mouth = cue["mouth_frames"]
130
+ if (not isinstance(cue["text"], str) or not cue["text"].strip()
131
+ or isinstance(mouth, bool) or not isinstance(mouth, int) or mouth <= 0):
132
+ raise ValueError(f"{field} 缺少有效对白或口型帧")
133
+ previous = end
134
+ if abs(planned - duration) > max(0.35, 2 / fps):
135
+ raise ValueError(f"视频时长 {duration:.3f}s 与 timeline {planned:.3f}s 不一致")
136
+ return {"timeline_fps": fps, "timeline_duration": planned,
137
+ "cue_count": len(cues), "last_cue_end": previous}
138
+
139
+
140
+ def _decode_audio(video: Path, duration: float) -> dict:
141
+ # Decode to mono signed 16-bit PCM. Capture bytes, not locale-decoded text;
142
+ # this is an acoustic sanity check, not speech recognition or a listening test.
143
+ rate = 16000
144
+ try:
145
+ result = subprocess.run(
146
+ [imageio_ffmpeg.get_ffmpeg_exe(), "-nostdin", "-v", "error", "-xerror",
147
+ "-i", str(video), "-map", "0:a:0", "-vn", "-ac", "1", "-ar", str(rate),
148
+ "-acodec", "pcm_s16le", "-f", "s16le", "pipe:1"],
149
+ capture_output=True, check=False, timeout=60,
150
+ )
151
+ except subprocess.TimeoutExpired as exc:
152
+ raise ValueError("音轨解码超时") from exc
153
+ if result.returncode != 0:
154
+ error = result.stderr.decode("utf-8", errors="replace").strip()[-300:]
155
+ raise ValueError("音轨解码失败:" + (error or f"exit {result.returncode}"))
156
+ if not result.stdout or len(result.stdout) % 2:
157
+ raise ValueError("音轨没有可解码的 PCM 样本")
158
+ samples = array.array("h")
159
+ samples.frombytes(result.stdout)
160
+ if sys.byteorder != "little":
161
+ samples.byteswap()
162
+ audio_duration = len(samples) / rate
163
+ # AAC padding at the renderer's 24 kHz is under 0.1s and is independent
164
+ # of video fps; a low-fps movie must not permit seconds of missing audio.
165
+ if abs(audio_duration - duration) > 0.15:
166
+ raise ValueError(f"音轨时长 {audio_duration:.3f}s 与 timeline {duration:.3f}s 不一致")
167
+ peak = max(abs(value) for value in samples)
168
+ rms = math.sqrt(sum(value * value for value in samples) / len(samples))
169
+ # One PCM least-significant bit tolerates codec noise without accepting an
170
+ # otherwise silent AAC stream. Normalized levels are recorded for inspection.
171
+ if peak <= 1 or rms <= 1:
172
+ raise ValueError("音轨为空或静音")
173
+ return {"audio_duration": round(audio_duration, 6), "audio_sample_count": len(samples),
174
+ "audio_peak": round(peak / 32768, 8), "audio_rms": round(rms / 32768, 8)}
175
+
176
+
177
+ def verify(video: Path, timeline_file: Path, outdir: Path, max_duration: float = 300.0,
178
+ target_duration: float | None = None) -> dict:
179
+ max_duration = _number(max_duration, "max-duration")
180
+ if max_duration <= 0:
181
+ raise ValueError("max-duration 必须大于零")
182
+ if target_duration is not None:
183
+ target_duration = _number(target_duration, "target-duration")
184
+ if target_duration <= 0:
185
+ raise ValueError("target-duration 必须大于零")
186
+ if not video.is_file() or video.stat().st_size <= 1024:
187
+ raise ValueError("视频文件不存在或为空")
188
+ outdir.mkdir(parents=True, exist_ok=True)
189
+ try:
190
+ timeline = json.loads(timeline_file.read_text(encoding="utf-8"))
191
+ except Exception as exc:
192
+ raise ValueError("timeline.json 不是有效 JSON") from exc
193
+ streams_info, frame_count = _probe(video)
194
+ streams = streams_info.get("streams") or []
195
+ video_stream = _stream(streams, "video")
196
+ audio_stream = _stream(streams, "audio")
197
+ if not video_stream or not audio_stream:
198
+ raise ValueError("成片必须同时包含视频和非空音轨")
199
+ width, height = int(video_stream.get("width") or 0), int(video_stream.get("height") or 0)
200
+ if width < 320 or height < 180 or width > 4096 or height > 4096:
201
+ raise ValueError("视频尺寸不在允许范围")
202
+ duration = _duration((streams_info.get("format") or {}).get("duration"))
203
+ if not duration or duration > max_duration + 0.35:
204
+ raise ValueError(f"视频时长 {duration:.3f}s 超过 {max_duration:g}s 预算")
205
+ minimum_duration = target_duration * 0.60 if target_duration is not None else None
206
+ if minimum_duration is not None and duration + 1e-9 < minimum_duration:
207
+ raise ValueError(
208
+ f"视频实测时长 {duration:.3f}s 低于目标 {target_duration:g}s 的60%"
209
+ f"(至少 {minimum_duration:.3f}s)"
210
+ )
211
+ timeline_info = _timeline_check(timeline, duration)
212
+ fps = _duration(video_stream.get("r_frame_rate"))
213
+ if not 1 <= fps <= 60:
214
+ raise ValueError("视频帧率不在允许范围")
215
+ if abs(fps - timeline_info["timeline_fps"]) > 0.001:
216
+ raise ValueError(f"视频帧率 {fps:g} 与 timeline {timeline_info['timeline_fps']:g} 不一致")
217
+ expected_frames = round(timeline_info["timeline_duration"] * timeline_info["timeline_fps"])
218
+ # The renderer uses int(total * fps); a floating-point frame-boundary roundoff
219
+ # can remove one frame. Larger differences indicate truncated/extra footage.
220
+ if abs(frame_count - expected_frames) > 1:
221
+ raise ValueError(f"视频实际帧数 {frame_count} 与 timeline {expected_frames} 不一致")
222
+ sample_indices = _decode_frames(video, outdir, width, height, frame_count)
223
+ audio_info = _decode_audio(video, timeline_info["timeline_duration"])
224
+ receipt = {
225
+ "ok": True,
226
+ "video": str(video),
227
+ "timeline": str(timeline_file),
228
+ "width": width,
229
+ "height": height,
230
+ "fps": round(fps, 6),
231
+ "duration": round(duration, 6),
232
+ "frame_count": frame_count,
233
+ "expected_frame_count": expected_frames,
234
+ "sample_frame_indices": sample_indices,
235
+ "audio_codec": audio_stream.get("codec_name", ""),
236
+ **audio_info,
237
+ **timeline_info,
238
+ "limitations": ["仅验证画面解码、音轨解码与非静音、时长及时间轴一致;不进行 ASR,也不声称有人听过音频。"],
239
+ }
240
+ if target_duration is not None:
241
+ receipt["target_duration"] = round(target_duration, 6)
242
+ receipt["minimum_target_duration"] = round(minimum_duration, 6)
243
+ (outdir / "verification.json").write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
244
+ return receipt
245
+
246
+
247
+ def main() -> int:
248
+ parser = argparse.ArgumentParser()
249
+ parser.add_argument("--video", required=True, type=Path)
250
+ parser.add_argument("--timeline", required=True, type=Path)
251
+ parser.add_argument("--outdir", required=True, type=Path)
252
+ parser.add_argument("--max-duration", type=float, default=300.0)
253
+ parser.add_argument("--target-duration", type=float)
254
+ args = parser.parse_args()
255
+ try:
256
+ result = verify(args.video, args.timeline, args.outdir, args.max_duration, args.target_duration)
257
+ except Exception as exc:
258
+ args.outdir.mkdir(parents=True, exist_ok=True)
259
+ (args.outdir / "verification.json").write_text(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
260
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
261
+ return 1
262
+ print(json.dumps(result, ensure_ascii=False))
263
+ return 0
264
+
265
+
266
+ if __name__ == "__main__":
267
+ raise SystemExit(main())
@@ -0,0 +1,61 @@
1
+ /** Cheap, read-only environment metadata checks. Never spawn Python during status polling. */
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ export const MAIN_PYTHON_RANGE = '>=3.11,<3.14'
6
+ export const MAIN_ENV_PACKAGES = Object.freeze(['bilibili_api', 'mcp', 'qrcode', 'PIL', 'edge_tts', 'imageio_ffmpeg'])
7
+ export const PYTHON_COMPATIBILITY_CHECK = 'import sys;sys.exit(0 if (3,11)<=sys.version_info[:2]<(3,14) else 2)'
8
+ export const MAIN_RUNTIME_CHECK = 'import sys,PIL,bilibili_api,mcp,qrcode,edge_tts,imageio_ffmpeg;sys.exit(0 if (3,11)<=sys.version_info[:2]<(3,14) and int(PIL.__version__.split(".")[0])>=13 else 2)'
9
+ export const ASR_ENV_PACKAGES = Object.freeze(['faster_whisper', 'yt_dlp', 'ctranslate2', 'av'])
10
+ export const ASR_RUNTIME_CHECK = 'import faster_whisper,yt_dlp,ctranslate2,av;print("DeepMoYu ASR runtime imports OK")'
11
+
12
+ const read = file => { try { return readFileSync(file, 'utf8') } catch { return '' } }
13
+ const list = folder => { try { return readdirSync(folder) } catch { return [] } }
14
+ export const environmentPython = (root, platform = process.platform) => join(root, platform === 'win32' ? 'Scripts' : 'bin', platform === 'win32' ? 'python.exe' : 'python')
15
+
16
+ export function inspectMainEnvironment(root, { platform = process.platform, ignoreRuntimeFailure = false } = {}) {
17
+ const python = environmentPython(root, platform), exists = existsSync(python)
18
+ const config = read(join(root, 'pyvenv.cfg'))
19
+ const pythonVersion = /^(?:version|version_info)\s*=\s*(\d+\.\d+(?:\.\d+)?)/mi.exec(config)?.[1] || ''
20
+ const version = pythonVersion.split('.').map(Number)
21
+ const pythonCompatible = exists && version[0] === 3 && version[1] >= 11 && version[1] < 14
22
+ const roots = [join(root, 'Lib', 'site-packages'), ...list(join(root, 'lib')).filter(name => /^python\d+\.\d+$/.test(name)).map(name => join(root, 'lib', name, 'site-packages'))]
23
+ const site = roots.find(folder => existsSync(folder)) || roots[0]
24
+ const names = list(site)
25
+ const missing = MAIN_ENV_PACKAGES.filter(name => !names.includes(name) && !names.includes(name + '.py'))
26
+ let pillowVersion = /__version__\s*=\s*["'](\d+\.\d+(?:\.\d+)?)["']/.exec(read(join(site, 'PIL', '_version.py')))?.[1] || ''
27
+ if (!pillowVersion) {
28
+ const candidates = names.filter(name => /^pillow-.+\.dist-info$/i.test(name))
29
+ .map(name => /^Version:\s*(\d+\.\d+(?:\.\d+)?)\s*$/mi.exec(read(join(site, name, 'METADATA')))?.[1]).filter(Boolean)
30
+ if (new Set(candidates).size === 1) pillowVersion = candidates[0]
31
+ }
32
+ const pillowCompatible = /^\d+\.\d+(?:\.\d+)?$/.test(pillowVersion) && Number(pillowVersion.split('.')[0]) >= 13 && !missing.includes('PIL')
33
+ const reasons = []
34
+ if (!exists) reasons.push('尚未创建插件 Python 环境')
35
+ else if (!pythonCompatible) reasons.push(`需要 Python 3.11–3.13(当前 ${pythonVersion || '版本无法确认'})`)
36
+ if (!pillowCompatible) reasons.push(`需要 Pillow 13.0+(当前 ${pillowVersion || '未安装或版本无法确认'})`)
37
+ if (missing.length) reasons.push('缺少依赖:' + missing.join(' / '))
38
+ let runtimeFailed = false
39
+ if (!ignoreRuntimeFailure && existsSync(join(root, 'deepmoyu-health.json'))) {
40
+ try { runtimeFailed = JSON.parse(read(join(root, 'deepmoyu-health.json')))?.ok !== true } catch { runtimeFailed = true }
41
+ if (runtimeFailed) reasons.push('上次实际运行校验未通过,请重新安装主依赖以复核')
42
+ }
43
+ return { ready: exists && pythonCompatible && pillowCompatible && missing.length === 0 && !runtimeFailed, exists, python,
44
+ pythonVersion, pythonCompatible, pillowVersion, pillowCompatible, missing, reasons,
45
+ runtimeFailed, upgradeRequired: exists && (!pythonCompatible || !pillowCompatible), upgradeHint: reasons.join(';') }
46
+ }
47
+
48
+ /** Optional ASR remains independent from main Python/Pillow constraints and supports complete legacy envs. */
49
+ export function inspectAsrEnvironment(root, { platform = process.platform, installState = null } = {}) {
50
+ const python = environmentPython(root, platform), exists = existsSync(python)
51
+ const roots = [join(root, 'Lib', 'site-packages'), ...list(join(root, 'lib')).filter(name => /^python\d+\.\d+$/.test(name)).map(name => join(root, 'lib', name, 'site-packages'))]
52
+ const site = roots.find(folder => existsSync(folder)) || roots[0], names = list(site)
53
+ const missing = ASR_ENV_PACKAGES.filter(name => !names.includes(name) && !names.includes(name + '.py'))
54
+ const blocked = Boolean(installState && (installState.ok === false || installState.running || ['running', 'failed', 'interrupted'].includes(installState.state)))
55
+ const reasons = []
56
+ if (!exists) reasons.push('尚未创建转写 Python 环境')
57
+ if (missing.length) reasons.push('缺少转写运行依赖:' + missing.join(' / '))
58
+ if (blocked) reasons.push(installState.running ? '转写依赖仍在安装或校验' : String(installState.error || '上次转写安装或校验未通过,请重试'))
59
+ return { ready: exists && missing.length === 0 && !blocked, python, exists, missing, reasons,
60
+ install: installState || { state: 'unknown', ok: null, running: false }, installFailed: blocked && !installState?.running }
61
+ }