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.
- package/AGENTS.md +93 -153
- package/CHANGELOG.md +36 -0
- package/CONTRIBUTING.md +51 -0
- package/CONTRIBUTORS.md +3 -7
- package/NOTICE.md +3 -3
- package/README.md +143 -81
- package/SECURITY.md +21 -0
- package/assets/screenshots/memory.png +0 -0
- package/assets/screenshots/menu.png +0 -0
- package/assets/screenshots/workspace.png +0 -0
- package/client.js +1404 -441
- package/cordis.patch.yml +1 -1
- package/docs/ASSETS.md +25 -0
- package/docs/DEPLOYMENT.md +102 -0
- package/docs/PRIVACY.md +32 -0
- package/docs/RELEASE_AUDIT.md +70 -0
- package/docs/RELEASING.md +94 -0
- package/docs/TEST_MATRIX.md +77 -0
- package/docs/assets-permissions.json +20 -0
- package/docs/tool-evidence.json +196 -0
- package/index.js +1095 -285
- package/lib/asr.py +41 -35
- package/lib/background-output.mjs +64 -17
- package/lib/background-tools.mjs +1 -0
- package/lib/companion.mjs +43 -0
- package/lib/config-policy.mjs +20 -0
- package/lib/favops.py +9 -2
- package/lib/ids.mjs +41 -0
- package/lib/login-session.mjs +34 -0
- package/lib/login.mjs +5 -4
- package/lib/mcp.mjs +3 -2
- package/lib/memory-center.mjs +74 -0
- package/lib/mode.mjs +6 -7
- package/lib/model-relay.mjs +5 -4
- package/lib/open-directory.mjs +93 -0
- package/lib/pace.mjs +92 -12
- package/lib/persona.mjs +46 -0
- package/lib/product-status.mjs +123 -0
- package/lib/prompts.mjs +40 -5
- package/lib/pv-production.mjs +280 -0
- package/lib/pv-session.mjs +316 -0
- package/lib/pv.mjs +398 -306
- package/lib/pv.py +84 -22
- package/lib/pv_cover.py +108 -26
- package/lib/pv_editorial.py +336 -0
- package/lib/pv_verify.py +267 -0
- package/lib/python-env.mjs +61 -0
- package/lib/reply.mjs +10 -3
- package/lib/run-settings.mjs +6 -0
- package/lib/sticker-automation.mjs +5 -4
- package/lib/sticker.mjs +135 -25
- package/lib/tool-scope.mjs +65 -0
- package/lib/update.mjs +265 -0
- package/lib/uvfetch.mjs +165 -153
- package/lib/verify.mjs +111 -127
- package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
- package/package.json +23 -12
- package/presets/deepmoyu.patch.yml +1 -1
- package/prompts/duty.md +4 -3
- package/screenshots.json +5 -0
- package/selfcheck.mjs +21 -6
- package/vendor/bilibili-mcp/requirements.in +9 -0
- package/vendor/bilibili-mcp/requirements.txt +1936 -11
- package/vendor/bilibili-mcp/runtime_guard.py +35 -10
- package/lib/__pycache__/pv.cpython-310.pyc +0 -0
- package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
- package/tests/automation-routes.test.mjs +0 -183
- package/tests/automation.test.mjs +0 -79
- package/tests/background-tools.test.mjs +0 -182
- package/tests/background-window.test.mjs +0 -75
- package/tests/dup-cross-channel.test.mjs +0 -158
- package/tests/manual-browsing.test.mjs +0 -671
- package/tests/mcp-restart.test.mjs +0 -131
- package/tests/memory-chain.test.mjs +0 -413
- package/tests/model-relay.test.mjs +0 -116
- package/tests/persona.test.mjs +0 -67
- package/tests/pet-run-routes.test.mjs +0 -127
- package/tests/pet-run-settings.test.mjs +0 -38
- package/tests/pet-session.test.mjs +0 -125
- package/tests/pv-stage1.test.mjs +0 -203
- package/tests/sticker-routes.test.mjs +0 -141
- package/tests/sticker-upload-routes.test.mjs +0 -97
- package/tests/sticker-upload.test.mjs +0 -122
- package/tests/sticker.test.mjs +0 -169
- package/tests/test_guard_cross_channel.py +0 -141
- package/tests/test_pv_cover.py +0 -95
- package/tests/test_pv_stage1.py +0 -250
- package/tests/test_reply_pictures.py +0 -255
- package/tests/token-ledger.test.mjs +0 -78
- 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")
|
package/lib/pv_verify.py
ADDED
|
@@ -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
|
+
}
|