dsh-deepmoyu 2.7.1 → 2.7.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/CHANGELOG.md +15 -0
- package/SECURITY.md +4 -0
- package/client.js +89 -13
- package/docs/RELEASE_AUDIT.md +2 -2
- package/docs/RELEASING.md +15 -0
- package/docs/tool-evidence.json +1 -1
- package/index.js +281 -62
- package/lib/asr.py +22 -0
- package/lib/favops.py +38 -6
- package/lib/learn-review.mjs +11 -1
- package/lib/login.mjs +20 -3
- package/lib/mirror.mjs +15 -1
- package/lib/model-relay.mjs +6 -2
- package/lib/persona.mjs +3 -1
- package/lib/pet-session.mjs +9 -3
- package/lib/pv-session.mjs +16 -1
- package/lib/pv.mjs +18 -3
- package/lib/pv.py +142 -18
- package/lib/pv_cover.py +11 -1
- package/lib/pv_editorial.py +23 -2
- package/lib/pv_plan.py +20 -2
- package/lib/pv_verify.py +14 -0
- package/lib/state.mjs +6 -2
- package/lib/sticker-automation.mjs +16 -2
- package/lib/sticker.mjs +4 -1
- package/lib/uvfetch.mjs +38 -12
- package/package.json +1 -1
- package/vendor/bilibili-mcp/mcp_server.py +116 -32
- package/vendor/bilibili-mcp/runtime_guard.py +32 -6
package/lib/asr.py
CHANGED
|
@@ -18,6 +18,7 @@ import argparse
|
|
|
18
18
|
import os
|
|
19
19
|
import re
|
|
20
20
|
import sys
|
|
21
|
+
import time
|
|
21
22
|
import uuid
|
|
22
23
|
|
|
23
24
|
# ⚠️ 临时目录必须待在家目录里:值班那一轮跑在沙箱里(只允许写工作区/cwd),
|
|
@@ -56,6 +57,25 @@ def cleanup_download(tmp: str, tag: str) -> None:
|
|
|
56
57
|
pass
|
|
57
58
|
|
|
58
59
|
|
|
60
|
+
def sweep_stale_downloads(tmp: str = _TMP, max_age_hours: float = 24.0) -> None:
|
|
61
|
+
"""P3-33: 异常中断(进程被杀/断电)会留下 tag.* 碎片,且只有成功路径会清 ——
|
|
62
|
+
启动时把 tmp/asr 下超过 24h 的下载碎片一并回收(当前这次的文件是新的,不受影响)。"""
|
|
63
|
+
cutoff = time.time() - max_age_hours * 3600
|
|
64
|
+
try:
|
|
65
|
+
with os.scandir(tmp) as entries:
|
|
66
|
+
for entry in entries:
|
|
67
|
+
try:
|
|
68
|
+
# 只回收普通文件与失效链接,目录一律不碰;删不掉就留给下一次。
|
|
69
|
+
if not (entry.is_file(follow_symlinks=False) or entry.is_symlink()):
|
|
70
|
+
continue
|
|
71
|
+
if entry.stat(follow_symlinks=False).st_mtime < cutoff:
|
|
72
|
+
os.remove(entry.path)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
59
79
|
def to_url(target: str) -> str:
|
|
60
80
|
t = target.strip()
|
|
61
81
|
if t.upper().startswith('BV') and len(t) <= 20:
|
|
@@ -99,6 +119,8 @@ def main() -> int:
|
|
|
99
119
|
ap.add_argument('--lang', default='zh', help='语言(默认 zh;不给就自动判断请传 auto)')
|
|
100
120
|
args = ap.parse_args()
|
|
101
121
|
prepare_runtime()
|
|
122
|
+
# P3-33: 每次启动顺手清扫超龄碎片(只在真的要下载时才需要目录,清扫本身静默失败无妨)
|
|
123
|
+
sweep_stale_downloads()
|
|
102
124
|
|
|
103
125
|
src = args.target
|
|
104
126
|
# ⚠️ 不用 mkdtemp:沙箱只允许写"子进程启动前就存在"的目录,新建的会被拒 ✗
|
package/lib/favops.py
CHANGED
|
@@ -11,6 +11,14 @@ import os
|
|
|
11
11
|
import re
|
|
12
12
|
import sys
|
|
13
13
|
|
|
14
|
+
# P3-31: Windows 管道默认 GBK —— 错误 JSON 里的中文/emoji 会把 print 打挂
|
|
15
|
+
#(UnicodeEncodeError 连 JSON 都出不来),先钉 UTF-8 + replace(写法照 lib/pv.py:41-46)
|
|
16
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
17
|
+
try:
|
|
18
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
14
22
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
15
23
|
|
|
16
24
|
# 凭证找两处:优先**当前工作目录**(宿主 spawn 时 cwd = 插件家,凭证就在那儿),
|
|
@@ -23,10 +31,32 @@ CRED_FILE = next((p for p in CRED_CANDIDATES if os.path.exists(p)), CRED_CANDIDA
|
|
|
23
31
|
|
|
24
32
|
|
|
25
33
|
def out(obj):
|
|
34
|
+
# P3-31: stdout 已钉 utf-8 + errors="replace",任何字符都出得去,不再 UnicodeEncodeError
|
|
26
35
|
print(json.dumps(obj, ensure_ascii=False))
|
|
27
36
|
sys.exit(0)
|
|
28
37
|
|
|
29
38
|
|
|
39
|
+
def _classify(error):
|
|
40
|
+
"""P3-32: 错误分类 —— code 取 bilibili_api 异常自带的 API code(ResponseCodeException.code),
|
|
41
|
+
kind 让宿主分得清「要重扫码」(auth = -101/-111)、「等风控」(risk = 412/-352)、
|
|
42
|
+
「已经收藏过」(dup,幂等成功)和其余失败(unknown)。"""
|
|
43
|
+
code = getattr(error, 'code', None)
|
|
44
|
+
try:
|
|
45
|
+
code = int(code) if code is not None else None
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
code = None
|
|
48
|
+
msg = str(error)
|
|
49
|
+
if code in (-101, -111):
|
|
50
|
+
kind = 'auth'
|
|
51
|
+
elif code in (412, -352):
|
|
52
|
+
kind = 'risk'
|
|
53
|
+
elif '已经' in msg or 'already' in msg.lower():
|
|
54
|
+
kind = 'dup'
|
|
55
|
+
else:
|
|
56
|
+
kind = 'unknown'
|
|
57
|
+
return code, kind, msg
|
|
58
|
+
|
|
59
|
+
|
|
30
60
|
def load():
|
|
31
61
|
from bilibili_api import Credential
|
|
32
62
|
if not os.path.exists(CRED_FILE):
|
|
@@ -65,11 +95,11 @@ async def add(bvid, fid):
|
|
|
65
95
|
try:
|
|
66
96
|
await favorite_list.FavoriteListResource(aid, type_=2, credential=cred).add(fid)
|
|
67
97
|
except Exception as error: # noqa: BLE001
|
|
68
|
-
msg =
|
|
69
|
-
#
|
|
70
|
-
if
|
|
71
|
-
out({'ok': True, 'already': True})
|
|
72
|
-
out({'ok': False, 'error': msg[:200]})
|
|
98
|
+
code, kind, msg = _classify(error)
|
|
99
|
+
# 已经收藏过不算失败:幂等判断保留子串兜底,但优先按 code 分好的类走
|
|
100
|
+
if kind == 'dup':
|
|
101
|
+
out({'ok': True, 'already': True, 'code': code, 'kind': kind})
|
|
102
|
+
out({'ok': False, 'error': msg[:200], 'code': code, 'kind': kind})
|
|
73
103
|
out({'ok': True})
|
|
74
104
|
|
|
75
105
|
|
|
@@ -87,7 +117,9 @@ def main():
|
|
|
87
117
|
else:
|
|
88
118
|
out({'ok': False, 'error': '子命令不认识:' + ' '.join(args)})
|
|
89
119
|
except Exception as error: # noqa: BLE001
|
|
90
|
-
|
|
120
|
+
# P3-32: 兜底出口同样带 code/kind,宿主不用再猜错误文本
|
|
121
|
+
code, kind, msg = _classify(error)
|
|
122
|
+
out({'ok': False, 'error': msg[:300], 'code': code, 'kind': kind})
|
|
91
123
|
|
|
92
124
|
|
|
93
125
|
if __name__ == '__main__':
|
package/lib/learn-review.mjs
CHANGED
|
@@ -40,6 +40,11 @@ export function buildLearningReviewPrompt(items, { tokenBudget = 12000 } = {}) {
|
|
|
40
40
|
export function learningReviewRetryAllowed(previous = {}, { batchKey, model = '', now = Date.now() } = {}) {
|
|
41
41
|
const last = Number(previous.lastAttemptAt) || 0
|
|
42
42
|
if (now - last < 180_000) return false
|
|
43
|
+
/* 审计 P3-11(2026-11-12):人工停止(stopReason='user')以前与错误同样进退避——
|
|
44
|
+
* 180 秒后 automationTick 又把同一批拉起来,"点了停止"实际停不住。现在同一批
|
|
45
|
+
* (batchKey+model 都没变)被人工停过就不再自动重启;队列内容变化(batchKey 变)
|
|
46
|
+
* 或换了模型才算新任务。手动 startLearningReview('manual') 不走这里,不受影响。 */
|
|
47
|
+
if (previous.userStop && previous.userStop.batchKey === batchKey && String(previous.userStop.model || '') === model) return false
|
|
43
48
|
if (previous.batchKey !== batchKey || String(previous.model || '') !== model) return true
|
|
44
49
|
return now >= (Number(previous.nextRetryAt) || last + 180_000)
|
|
45
50
|
}
|
|
@@ -55,7 +60,12 @@ export function summarizeLearningReview(items, ledger, { code, error = '', stopR
|
|
|
55
60
|
const failures = complete ? 0 : (same ? Number(previous.failures) || 0 : 0) + 1
|
|
56
61
|
const delay = Math.min(30 * 60_000, 180_000 * 2 ** Math.min(4, Math.max(0, failures - 1)))
|
|
57
62
|
const lastError = redactRecordText(error || (stopReason ? `审核已停止:${stopReason}` : code !== 0 ? `审核进程退出码 ${code}` : remaining ? `进程已退出,但本批仍有 ${remaining} 条未审核;已保留待审,稍后重试` : ''), { maxLength: 800 })
|
|
58
|
-
|
|
63
|
+
// 审计 P3-11:人工停止记下本批指纹(batchKey+model),同批不再被 automationTick 自动重拉;
|
|
64
|
+
// 非人工停止时若本批没变,沿用之前的 userStop 标记(一次人工停止对同一批持续有效)。
|
|
65
|
+
const userStop = stopReason === 'user'
|
|
66
|
+
? { batchKey, model }
|
|
67
|
+
: (previous.userStop && previous.userStop.batchKey === batchKey ? previous.userStop : undefined)
|
|
68
|
+
return { approved, rejected, remaining, complete, failures, nextRetryAt: complete ? 0 : now + delay, lastError, userStop }
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
export function decideLearningReview(ledger, batch, args = {}) {
|
package/lib/login.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* ============================================================================
|
|
16
16
|
*/
|
|
17
17
|
import { spawn } from 'node:child_process'
|
|
18
|
-
import { existsSync, mkdirSync } from 'node:fs'
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import { atomicWriteFileSync } from './record-storage.mjs'
|
|
21
21
|
import { randomUUID } from 'node:crypto'
|
|
@@ -93,14 +93,31 @@ export function saveCredential(stateDir, credential) {
|
|
|
93
93
|
|
|
94
94
|
/** 用 Python 的 qrcode 画一张 PNG,返回**文件路径**(拿不到就返回 null)
|
|
95
95
|
* ⭐ 2026-10-26:加 20 秒超时 —— python 挂住时("既不起来也不退出")这个 Promise 以前永远不 settle,
|
|
96
|
-
* `/login/start` 那个 HTTP 请求就永远挂着(probeCmd 那批同型问题修过,这里漏了)。
|
|
96
|
+
* `/login/start` 那个 HTTP 请求就永远挂着(probeCmd 那批同型问题修过,这里漏了)。
|
|
97
|
+
* 审计 P3-13(2026-11-12):失败/超时以前不清理半成品 PNG,成功路径也没人删——
|
|
98
|
+
* 每次登录尝试都在家目录留一个 qrcode_login.*.png(key 一次性且 180s 过期,无安全风险,纯卫生)。
|
|
99
|
+
* 现在失败即删;每次出码顺手清掉 24h 前的旧文件。 */
|
|
97
100
|
export function renderQrPng(pythonPath, stateDir, url, { timeoutMs = 20_000 } = {}) {
|
|
98
101
|
return new Promise((resolve) => {
|
|
99
102
|
const png = join(stateDir, `qrcode_login.${process.pid}.${randomUUID()}.png`)
|
|
103
|
+
try {
|
|
104
|
+
const cutoff = Date.now() - 24 * 3600_000
|
|
105
|
+
for (const entry of readdirSync(stateDir)) {
|
|
106
|
+
if (!/^qrcode_login\..+\.png$/.test(entry)) continue
|
|
107
|
+
const old = join(stateDir, entry)
|
|
108
|
+
try { if (statSync(old).mtimeMs < cutoff) rmSync(old, { force: true }) } catch { /* 占用中就留给下次 */ }
|
|
109
|
+
}
|
|
110
|
+
} catch { /* 扫不了目录就算了 */ }
|
|
100
111
|
const code = 'import sys, qrcode; qrcode.make(sys.argv[1]).save(sys.argv[2])'
|
|
101
112
|
let child
|
|
102
113
|
let settled = false
|
|
103
|
-
const finish = (v) => {
|
|
114
|
+
const finish = (v) => {
|
|
115
|
+
if (settled) return
|
|
116
|
+
settled = true
|
|
117
|
+
clearTimeout(timer)
|
|
118
|
+
if (!v) { try { rmSync(png, { force: true }) } catch { /* 半成品也留给 24h 清扫 */ } }
|
|
119
|
+
resolve(v)
|
|
120
|
+
}
|
|
104
121
|
const timer = setTimeout(() => {
|
|
105
122
|
try { child?.kill('SIGKILL') } catch { /* 已经没了 */ }
|
|
106
123
|
finish(null)
|
package/lib/mirror.mjs
CHANGED
|
@@ -44,8 +44,22 @@ export function resolveMirrorDir(stateDir, explicit = '') {
|
|
|
44
44
|
return ''
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** 审计 P3-9(2026-11-12):state.json 镜像里 `sendReview[].args.text`(bili_dm_reply)
|
|
48
|
+
* 是待审私信的明文正文——活动流水对私信整行剔除,镜像却原样带走,两处口径不一致。
|
|
49
|
+
* 镜像定位是"脱敏的只读副本",这里对齐:DM 待审正文以占位符替换,其余字段照常。 */
|
|
50
|
+
function stripPendingDmText(value) {
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
|
52
|
+
const rows = Array.isArray(value.sendReview) ? value.sendReview : null
|
|
53
|
+
if (!rows) return value
|
|
54
|
+
return { ...value, sendReview: rows.map((row) => {
|
|
55
|
+
if (!row || typeof row !== 'object' || row.tool !== 'bili_dm_reply') return row
|
|
56
|
+
if (!row.args || typeof row.args !== 'object' || typeof row.args.text !== 'string') return row
|
|
57
|
+
return { ...row, args: { ...row.args, text: '[私信正文已隐去——待审私信不进镜像]' } }
|
|
58
|
+
}) }
|
|
59
|
+
}
|
|
60
|
+
|
|
47
61
|
function mirrorText(fromDir, name, source) {
|
|
48
|
-
if (name.endsWith('.json')) return JSON.stringify(sanitizeRecord(JSON.parse(source), { maxKeys: 10000, maxLength: 16000, maxDepth: 10 }), null, 2) + '\n'
|
|
62
|
+
if (name.endsWith('.json')) return JSON.stringify(sanitizeRecord(stripPendingDmText(JSON.parse(source)), { maxKeys: 10000, maxLength: 16000, maxDepth: 10 }), null, 2) + '\n'
|
|
49
63
|
if (name === 'activity.jsonl') {
|
|
50
64
|
const tail = readActivityTail(fromDir, { maxBytes: MIRROR_MAX_BYTES, limit: 5000 })
|
|
51
65
|
if (tail.truncated) throw new Error('MIRROR_ACTIVITY_NEEDS_ROTATION')
|
package/lib/model-relay.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Local, per-run capability bridge to the parent's already configured LLM adapters.
|
|
2
2
|
* No credentials/profile copies, no route fallback, no public web endpoint. */
|
|
3
3
|
import { createServer } from 'node:http'
|
|
4
|
-
import { randomBytes
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
5
|
import { once } from 'node:events'
|
|
6
6
|
import { redactRecordText } from './record-privacy.mjs'
|
|
7
7
|
import { summarizeBackgroundDiagnostic } from './background-output.mjs'
|
|
@@ -56,8 +56,12 @@ export function createModelRelay({ getLlm, onError } = {}) {
|
|
|
56
56
|
// No browser origins, CORS or ambient cookie auth. Each child gets a revocable capability.
|
|
57
57
|
if (req.method !== 'POST' || req.url !== '/call' || req.headers.origin) return jsonReply(res, 403, { error: { code: 'RELAY_FORBIDDEN', message: '模型桥接拒绝此请求' } })
|
|
58
58
|
const token = String(req.headers.authorization || '').replace(/^Bearer /, '')
|
|
59
|
+
/* 审计 P3-25(2026-11-12):`grants.get(token)` 本身就是按 token 精确匹配——命中后
|
|
60
|
+
* 再 timingSafeEqual 比较的两个 buffer 恒等,这段比较从不起作用(错误长度的 token
|
|
61
|
+
* 走 `!grant` → 403 短路)。Map 键比较在此处是时间安全的(V8 字符串哈希桶查找,
|
|
62
|
+
* 键只在本进程内生成且不可枚举),直接删除冗余比较并留注说明。 */
|
|
59
63
|
const grant = grants.get(token)
|
|
60
|
-
if (!grant ||
|
|
64
|
+
if (!grant || Date.now() >= grant.expiresAt) return jsonReply(res, 403, { error: { code: 'RELAY_EXPIRED', message: '后台模型凭据已撤销或过期,请重新开跑' } })
|
|
61
65
|
const abort = new AbortController()
|
|
62
66
|
grant.controllers.add(abort)
|
|
63
67
|
const disconnected = () => { if (!res.writableEnded) abort.abort() }
|
package/lib/persona.mjs
CHANGED
|
@@ -170,7 +170,9 @@ export function ensureSoul(stateDir, log = () => {}) {
|
|
|
170
170
|
const file = soulFileOf(stateDir)
|
|
171
171
|
try {
|
|
172
172
|
if (existsSync(file)) return false
|
|
173
|
-
|
|
173
|
+
// 审计 P3-18(2026-11-12):初始铺模板也走原子写——裸 write 半篇留档会被 existsSync
|
|
174
|
+
// 永久当成"用户写过",残缺模板就再也修不回来了。
|
|
175
|
+
atomicWriteFileSync(file, soulTemplate(), 'utf8')
|
|
174
176
|
log(`已铺好:${file}`)
|
|
175
177
|
return true
|
|
176
178
|
} catch (e) {
|
package/lib/pet-session.mjs
CHANGED
|
@@ -62,10 +62,16 @@ function inheritPermissions(ctx, source, target) {
|
|
|
62
62
|
if (source.sandbox && source.sandbox !== before.sandbox) target.append('sandbox/mode', { mode: source.sandbox })
|
|
63
63
|
if (source.approval && source.approval !== before.approval) target.append('approval/policy', { policy: source.approval })
|
|
64
64
|
const after = permissionsOf(ctx, target)
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
// 审计 P3-22(2026-11-12):读回为 undefined(宿主缺 sessionProjections 等服务)时,
|
|
66
|
+
// 以前整个校验被跳过却返回"继承成功"——append 发出了但未经读回验证。现在把这种
|
|
67
|
+
// 情况显式标成 unverified,由调用方(HTTP 回执/日志)如实呈现。
|
|
68
|
+
let unverified = false
|
|
69
|
+
for (const key of ['sandbox', 'approval']) {
|
|
70
|
+
if (!source[key]) continue
|
|
71
|
+
if (after[key] === undefined) { unverified = true; continue }
|
|
72
|
+
if (after[key] !== source[key]) throw new Error(`会话已创建,但 ${key} 权限继承未生效;请手动检查该会话权限`)
|
|
67
73
|
}
|
|
68
|
-
return true
|
|
74
|
+
return unverified ? 'unverified' : true
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
/**
|
package/lib/pv-session.mjs
CHANGED
|
@@ -118,6 +118,13 @@ export function createVideoStudio({ ctx, pv, stateDir, config, publicSources = (
|
|
|
118
118
|
const current = pv.status(), row = sessionRow(sessionId) || (!sessionId && rows.find(item => item.productionId && item.productionId === current.id))
|
|
119
119
|
if (sessionId && !row) return { ok: true, stopped: false, hint: '没有找到这个制作会话' }
|
|
120
120
|
if (!row) return pv.stop()
|
|
121
|
+
/* 审计 P2-8(2026-11-12):终态不可逆——已交付回执(done + terminalDelivered)的会话
|
|
122
|
+
* 再收到 stop 时,以前会被无条件改写成 stopped,会话侧的「目标终态」丢失(磁盘成片
|
|
123
|
+
* 不受影响,但 status 从此显示"已手动停止")。现在对已交付终态的行直接原样返回。 */
|
|
124
|
+
if (row.stage === 'done' || row.terminalDelivered) {
|
|
125
|
+
return { ok: true, stopped: false, sessionId: row.sessionId, stage: row.stage,
|
|
126
|
+
hint: '该会话已交付终态(成片已完成且回执已送达),没有可停止的任务。' }
|
|
127
|
+
}
|
|
121
128
|
const before = row.stage
|
|
122
129
|
row.stage = 'stopped'; row.error = ''; remember(row) // Fence completion callbacks before cancellation.
|
|
123
130
|
const result = row.productionId && row.productionId === current.id ? pv.stop() : { ok: true, stopped: false }
|
|
@@ -132,7 +139,13 @@ export function createVideoStudio({ ctx, pv, stateDir, config, publicSources = (
|
|
|
132
139
|
const settled = async result => {
|
|
133
140
|
if (disposed || !result.id) return
|
|
134
141
|
const row = rows.find(item => item.productionId === result.id)
|
|
135
|
-
if (!row
|
|
142
|
+
if (!row) {
|
|
143
|
+
// 审计 P3-19(2026-11-12):行索引只留 60 条,被裁剪行的渲染完成事件以前无声丢失——
|
|
144
|
+
// 至少留一条可发现的记录(AGENTS:终态回传原会话一次;回传不了也要说清为什么)。
|
|
145
|
+
log(`⚠️ 渲染 ${result.id}(${result.date || '?'})完成,但对应的制作会话已被裁剪出列表——终态无法回传原会话,成片与回执见 productions 列表`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (row.stage === 'stopped' || row.terminalDelivered === result.id || delivering.has(result.id)) return
|
|
136
149
|
delivering.add(result.id)
|
|
137
150
|
try {
|
|
138
151
|
const verified = result.ok === true && result.stage === 'done' && !result.verificationSkipped
|
|
@@ -214,6 +227,8 @@ export function createVideoStudio({ ctx, pv, stateDir, config, publicSources = (
|
|
|
214
227
|
} finally { creating = false }
|
|
215
228
|
}
|
|
216
229
|
const prepare = (args = {}, sessionId = '') => {
|
|
230
|
+
// 审计 P3-24:preview/render 都有 disposed 闸,prepare 漏了——卸载竞态窗口内仍可返回素材快照。
|
|
231
|
+
if (disposed) return { ok: false, error: '视频制作服务已关闭' }
|
|
217
232
|
const saved = sessionRow(sessionId)
|
|
218
233
|
const request = saved || videoRequest(args)
|
|
219
234
|
if (saved && args.date && args.date !== saved.date) throw new Error('日期与当前制作会话不一致')
|
package/lib/pv.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
5
|
-
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
6
6
|
import { normalizeProduction } from './pv-production.mjs'
|
|
7
7
|
|
|
8
8
|
export const PV_PREFIX = '大肥鱼日报'
|
|
@@ -72,12 +72,23 @@ const usableVideo = (file) => {
|
|
|
72
72
|
/** Keep the historical date-indexed list compatible; studio jobs have productions(). */
|
|
73
73
|
export function listPv(outDir, legacyDirs = outDir) {
|
|
74
74
|
const byDate = new Map()
|
|
75
|
+
const verificationOk = (date, dir) => {
|
|
76
|
+
// 审计 P3-20(2026-11-12):legacy 列表以前只看「文件存在且 >1KB」——手工放进 out 的
|
|
77
|
+
// 任意 mp4 也会以成片身份进列表/播放端点(studio 的 productions() 严格要求校验回执)。
|
|
78
|
+
// 现在给每行带上 verified 标记:旁边有 ok:true 的 verification.json 才算验证过,
|
|
79
|
+
// 消费端(bili_pv list / 设置页)可以区分「校验过」与「只是个视频文件」。
|
|
80
|
+
try {
|
|
81
|
+
const receipt = JSON.parse(readFileSync(join(dateDir(dir, date), 'verification.json'), 'utf8'))
|
|
82
|
+
return receipt?.ok === true
|
|
83
|
+
} catch { return false }
|
|
84
|
+
}
|
|
75
85
|
const add = (date, file, canonical) => {
|
|
76
86
|
if (!pvDateOk(date) || !usableVideo(file)) return
|
|
77
87
|
const old = byDate.get(date)
|
|
78
88
|
if (!old || (!old.canonical && canonical)) {
|
|
79
89
|
const s = statSync(file)
|
|
80
|
-
byDate.set(date, { date, file, size: s.size, at: s.mtime.toISOString(), canonical
|
|
90
|
+
byDate.set(date, { date, file, size: s.size, at: s.mtime.toISOString(), canonical,
|
|
91
|
+
verified: verificationOk(date, canonical ? outDir : dirname(file)) })
|
|
81
92
|
}
|
|
82
93
|
}
|
|
83
94
|
try {
|
|
@@ -170,7 +181,11 @@ const productionRow = (outDir, id) => {
|
|
|
170
181
|
export function createPv(o) {
|
|
171
182
|
const { stateDir, pluginDir, python, dirs, env = null } = o
|
|
172
183
|
const say = (s) => { try { o.log?.(s) } catch { /* host logger */ } }
|
|
173
|
-
|
|
184
|
+
/* 审计 P2-12(2026-11-12):默认任务总时限 20 分钟 < py 侧 GSV 批处理自己的 60 分钟预算
|
|
185
|
+
* (pv.py:776 timeout=3600,且 pv_gsv.py 强制 CPU 推理,模型加载就要 30-50s)——长批处理
|
|
186
|
+
* 到点被 JS 强杀整树,py 的预算形同虚设。默认提到 75 分钟(> py 60 分钟 + 模型加载余量),
|
|
187
|
+
* py 侧超时先到、报错更有指向性;显式传 timeoutMs 的调用方不受影响。 */
|
|
188
|
+
const timeoutMs = Number(o.timeoutMs) > 0 ? Number(o.timeoutMs) : 75 * 60_000
|
|
174
189
|
const gsvOf = () => { try { return o.gsv?.() || null } catch { return null } }
|
|
175
190
|
let job = null
|
|
176
191
|
const settledListeners = new Set()
|
package/lib/pv.py
CHANGED
|
@@ -720,7 +720,14 @@ def ffmpeg_exe():
|
|
|
720
720
|
import imageio_ffmpeg
|
|
721
721
|
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
722
722
|
except Exception:
|
|
723
|
-
|
|
723
|
+
pass
|
|
724
|
+
found = shutil.which("ffmpeg")
|
|
725
|
+
if not found:
|
|
726
|
+
# P3-35: 裸 FileNotFoundError 排不了障 —— 报错带安装指引
|
|
727
|
+
raise RuntimeError(
|
|
728
|
+
"找不到 ffmpeg —— imageio_ffmpeg 会随渲染依赖自动安装(uv/pip 装依赖时带上),"
|
|
729
|
+
"或手动安装 ffmpeg 并加入 PATH 后重试")
|
|
730
|
+
return found
|
|
724
731
|
|
|
725
732
|
|
|
726
733
|
def gsv_batch(texts, out_dir: Path, voice: dict, log=print):
|
|
@@ -763,6 +770,20 @@ def gsv_batch(texts, out_dir: Path, voice: dict, log=print):
|
|
|
763
770
|
"--top-p", str(voice.get("gsvTopP", 0.9)),
|
|
764
771
|
"--temp", str(voice.get("gsvTemp", 0.75)),
|
|
765
772
|
"--repetition-penalty", str(voice.get("gsvRepPen", 1.3))]
|
|
773
|
+
# P2-11: gsvPython 漏配时会落到渲染 venv(sys.executable)—— 它没有 torch/soundfile,
|
|
774
|
+
# 子进程必死在 _patch_torchaudio 的 ModuleNotFoundError 上且报错不指向根因。
|
|
775
|
+
# 启动前先用同一个解释器跑一次 import 预检(首次冷盘导入也可能要十几秒,给 120s)。
|
|
776
|
+
try:
|
|
777
|
+
probe = subprocess.run([py, "-c", "import torch, soundfile"], capture_output=True, timeout=120)
|
|
778
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
779
|
+
raise RuntimeError(
|
|
780
|
+
f"gsvPython({py})预检失败({type(exc).__name__})——在设置页把 gsvPython "
|
|
781
|
+
"指到 GPT-SoVITS 自带的 python,或换一个能用的解释器") from exc
|
|
782
|
+
if probe.returncode != 0:
|
|
783
|
+
tail = (probe.stderr or b"").decode("utf-8", "ignore").strip().splitlines()
|
|
784
|
+
raise RuntimeError(
|
|
785
|
+
f"gsvPython({py})缺少 torch/soundfile({(tail[-1] if tail else 'import 失败')})——"
|
|
786
|
+
"在设置页把 gsvPython 指到 GPT-SoVITS 自带的 python,或在其环境里 pip install torch soundfile")
|
|
766
787
|
log(f"🎙 GPT-SoVITS 批处理:{len(texts)} 句(加载模型要 30~50 秒)")
|
|
767
788
|
t0 = time.time()
|
|
768
789
|
# ⚠️ 必须给子进程钉死 UTF-8:它会往管道里打中文和 emoji,Windows 默认 GBK 会炸
|
|
@@ -798,7 +819,9 @@ def tts(text: str, out_mp3: Path, voice="zh-CN-XiaoyiNeural", rate="+8%", pitch=
|
|
|
798
819
|
|
|
799
820
|
async def go():
|
|
800
821
|
c = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch)
|
|
801
|
-
|
|
822
|
+
# P2-14: 主合成路径此前没有任何超时 —— websocket 半开时 save() 永久挂起,
|
|
823
|
+
# prepare_audio 停摆到 JS 强杀。120s 墙钟;TimeoutError 落到下面的 SAPI 兜底。
|
|
824
|
+
await asyncio.wait_for(c.save(str(out_mp3)), timeout=120)
|
|
802
825
|
|
|
803
826
|
asyncio.run(go())
|
|
804
827
|
# ⭐ 2026-10-26:阈值 1200→600 —— "嗯。"这种极短句的 mp3 可能不足 1200 字节,
|
|
@@ -903,6 +926,71 @@ def squeeze_silence(pcm, rate, max_gap=0.42, keep=0.30, th=450.0, edge=0.12):
|
|
|
903
926
|
return out
|
|
904
927
|
|
|
905
928
|
|
|
929
|
+
def weight_len(s) -> float:
|
|
930
|
+
"""P3-27: 加权字符数 —— CJK=1、数字=0.5、拉丁字母=0.35、其他=1。
|
|
931
|
+
|
|
932
|
+
等权字符数会让「数字/英文读得快」的句子字幕切得慢(探针 F:英文估 3 倍偏差)。
|
|
933
|
+
"""
|
|
934
|
+
total = 0.0
|
|
935
|
+
for ch in str(s or ""):
|
|
936
|
+
if "\u4e00" <= ch <= "\u9fff":
|
|
937
|
+
total += 1
|
|
938
|
+
elif ch.isascii() and ch.isdigit():
|
|
939
|
+
total += 0.5
|
|
940
|
+
elif ch.isascii() and ch.isalpha():
|
|
941
|
+
total += 0.35
|
|
942
|
+
else:
|
|
943
|
+
total += 1
|
|
944
|
+
return total
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def spoken_chars(text) -> float:
|
|
948
|
+
"""P1-5: 有效字数(去标点/空白):CJK 记 1,连续英文/数字串整段记 0.5。
|
|
949
|
+
|
|
950
|
+
与 bili-video/build.py 的 0.32s/字守卫同一套「字数观」——标点不占口播时长。
|
|
951
|
+
"""
|
|
952
|
+
total, run = 0.0, 0
|
|
953
|
+
for ch in str(text or ""):
|
|
954
|
+
if ch.isascii() and ch.isalnum():
|
|
955
|
+
run += 1 # 连续英文/数字串攒着,断开时整段记 0.5
|
|
956
|
+
continue
|
|
957
|
+
if run:
|
|
958
|
+
total += 0.5
|
|
959
|
+
run = 0
|
|
960
|
+
if "\u4e00" <= ch <= "\u9fff":
|
|
961
|
+
total += 1
|
|
962
|
+
if run:
|
|
963
|
+
total += 0.5
|
|
964
|
+
return total
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
# P1-5 上限的短句开销余量(秒):「嗯。」这类一两个字的句子有起振/收音固定开销,
|
|
968
|
+
# 纯 0.32s/字会误杀 —— build.py 只跑长句所以没这问题,插件的单句可以很短。
|
|
969
|
+
GSV_TAKE_MIN_RATE = 0.15 # s/有效字,低于即截断(真机 GSV 正常 0.20~0.25)
|
|
970
|
+
GSV_TAKE_MAX_RATE = 0.32 # s/字(含标点),超过即复读/拖长(与 bili-video/build.py:85-95 同口径)
|
|
971
|
+
GSV_TAKE_OVERHEAD = 1.0 # 上限的固定余量
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def gsv_take_guard(index: int, text: str, dur: float) -> None:
|
|
975
|
+
"""P1-5: GSV 句级时长守卫 —— 截断/复读 take 拦在渲染前,别烧几十分钟渲废片。
|
|
976
|
+
|
|
977
|
+
真机实测(bili-work 各 timeline.json):正常 GSV ≈0.20~0.25s/有效字;
|
|
978
|
+
探针实测的截断形态是 15 字句只出 0.8s(0.053s/字),复读形态是时长成倍超上限。
|
|
979
|
+
"""
|
|
980
|
+
text = str(text or "")
|
|
981
|
+
eff = spoken_chars(text)
|
|
982
|
+
raw = max(1, len(text.strip()))
|
|
983
|
+
if eff > 0 and dur < eff * GSV_TAKE_MIN_RATE:
|
|
984
|
+
raise RuntimeError(
|
|
985
|
+
f"第 {index+1} 句 GSV 音频异常偏短(疑似截断):{dur:.2f}s / {eff:g} 有效字"
|
|
986
|
+
f"= {dur / eff:.2f}s/字(下限 {GSV_TAKE_MIN_RATE}s/字)——句首:「{text[:12]}」")
|
|
987
|
+
if dur > raw * GSV_TAKE_MAX_RATE + GSV_TAKE_OVERHEAD:
|
|
988
|
+
raise RuntimeError(
|
|
989
|
+
f"第 {index+1} 句 GSV 音频异常偏长(疑似复读):{dur:.2f}s / {raw} 字"
|
|
990
|
+
f"= {dur / raw:.2f}s/字(上限 {GSV_TAKE_MAX_RATE}s/字,同 bili-video/build.py)"
|
|
991
|
+
f"——句首:「{text[:12]}」")
|
|
992
|
+
|
|
993
|
+
|
|
906
994
|
def envelope(pcm, rate, fps):
|
|
907
995
|
"""把波形压成每帧一个 0~1 的响度包络(口型用)。"""
|
|
908
996
|
step = rate / fps
|
|
@@ -1962,17 +2050,40 @@ def split_sentences(text, maxlen=18):
|
|
|
1962
2050
|
|
|
1963
2051
|
用户说「字幕对不上」⇒ 根因是字幕显示的是摘要、语音念的是全文。
|
|
1964
2052
|
现在一句话一个镜头、字幕就是这句原话,字幕自然对得上。
|
|
2053
|
+
|
|
2054
|
+
⭐ P3-26(2026-11-12 审计):连续省略号(……)合并成一个边界,不再逐字切出
|
|
2055
|
+
孤立「…」碎句;句末标点后紧跟闭引号(」』"')时不当断句 —— 引号里的
|
|
2056
|
+
「你好。」是引语的一部分,句子续到真正的结尾(探针 A 实测)。
|
|
1965
2057
|
"""
|
|
1966
2058
|
text = str(text or "").strip()
|
|
1967
2059
|
if not text:
|
|
1968
2060
|
return []
|
|
2061
|
+
closing = "」』\"'"
|
|
1969
2062
|
# 先按句末标点切
|
|
1970
2063
|
parts, buf = [], ""
|
|
1971
|
-
|
|
2064
|
+
i, n = 0, len(text)
|
|
2065
|
+
while i < n:
|
|
2066
|
+
ch = text[i]
|
|
1972
2067
|
buf += ch
|
|
2068
|
+
if ch == "…":
|
|
2069
|
+
# 连续省略号(……)并成一个边界单位
|
|
2070
|
+
while i + 1 < n and text[i + 1] == "…":
|
|
2071
|
+
i += 1
|
|
2072
|
+
buf += "…"
|
|
1973
2073
|
if ch in "。!?…;!?;":
|
|
1974
|
-
|
|
1975
|
-
|
|
2074
|
+
j = i + 1
|
|
2075
|
+
while j < n and text[j] in closing:
|
|
2076
|
+
j += 1
|
|
2077
|
+
if j > i + 1 and j < n:
|
|
2078
|
+
# 句末标点后紧跟闭引号且后面还有正文 ⇒ 引语内标点,不断句,引号吸进本句
|
|
2079
|
+
buf += text[i + 1:j]
|
|
2080
|
+
i = j
|
|
2081
|
+
continue
|
|
2082
|
+
# 有实词才落句:开头就是省略号(buf 只剩标点)时不切出孤立「…」碎句
|
|
2083
|
+
if buf.strip(closing + "。!?…;!?;,,、:: \u3000\t"):
|
|
2084
|
+
parts.append(buf)
|
|
2085
|
+
buf = ""
|
|
2086
|
+
i += 1
|
|
1976
2087
|
if buf.strip():
|
|
1977
2088
|
parts.append(buf)
|
|
1978
2089
|
out = []
|
|
@@ -1996,11 +2107,11 @@ def split_sentences(text, maxlen=18):
|
|
|
1996
2107
|
return out
|
|
1997
2108
|
|
|
1998
2109
|
|
|
1999
|
-
def subtitle(img, text, t, dur
|
|
2110
|
+
def subtitle(img, text, t, dur):
|
|
2000
2111
|
"""底部字幕:只占左栏内容列(SUB_W),永不盖到右侧的角色。
|
|
2001
2112
|
|
|
2002
2113
|
⚠️ 「字幕对不上」的根因:以前字幕显示的是整段摘要,语音念的是全文。
|
|
2003
|
-
|
|
2114
|
+
现在按句切分、按加权字数分配时长,字幕只显示「此刻正在念的那一句」。
|
|
2004
2115
|
浅色主题 ⇒ 白色圆角条 + 深墨字,念过的字变浅。
|
|
2005
2116
|
"""
|
|
2006
2117
|
if not text:
|
|
@@ -2009,10 +2120,11 @@ def subtitle(img, text, t, dur, words):
|
|
|
2009
2120
|
# —— 选出当前这句 ——
|
|
2010
2121
|
sents = split_sentences(text, maxlen=22) or [text]
|
|
2011
2122
|
if len(sents) > 1 and dur > 0:
|
|
2012
|
-
|
|
2123
|
+
# P3-27: 按 weight_len 分摊 —— 数字/英文读得快,等权字符数会把它们切慢
|
|
2124
|
+
total_chars = sum(max(1.0, weight_len(s)) for s in sents)
|
|
2013
2125
|
acc, cur = 0.0, sents[-1]
|
|
2014
2126
|
for s in sents:
|
|
2015
|
-
span = dur * max(1,
|
|
2127
|
+
span = dur * max(1.0, weight_len(s)) / total_chars
|
|
2016
2128
|
if t < acc + span:
|
|
2017
2129
|
cur = s
|
|
2018
2130
|
break
|
|
@@ -2033,13 +2145,7 @@ def subtitle(img, text, t, dur, words):
|
|
|
2033
2145
|
outline=(150, 190, 226, 175), width=2)
|
|
2034
2146
|
paste_rgba(img, box, (x0, y0 - 14))
|
|
2035
2147
|
done_chars = len(cur)
|
|
2036
|
-
|
|
2037
|
-
done_chars = 0
|
|
2038
|
-
for wd in words:
|
|
2039
|
-
if t * 10_000_000 >= wd["offset"]:
|
|
2040
|
-
done_chars = min(len(cur), done_chars + len(wd["text"]))
|
|
2041
|
-
else:
|
|
2042
|
-
break
|
|
2148
|
+
# 2026-11-12 审计移除:words 逐字卡拉OK高亮是无生产者的死路径,且 100ns ticks 单位无文档
|
|
2043
2149
|
idx = 0
|
|
2044
2150
|
for i, ln in enumerate(lines):
|
|
2045
2151
|
yy = y0 + i * lh
|
|
@@ -2232,6 +2338,12 @@ def prepare_audio(shots, base_voice, work, fps):
|
|
|
2232
2338
|
pass
|
|
2233
2339
|
pcm,rate = to_wav(source,wav_path)
|
|
2234
2340
|
if engine == "gsv":
|
|
2341
|
+
# P2-10: 全静音/近静音的 take 要拦在合成后 —— 以前一路渲完整片才在 verify
|
|
2342
|
+
# 报「缺少有效对白或口型帧」,不指向根因句,白烧几十分钟。
|
|
2343
|
+
_rms = math.sqrt(sum(v * v for v in pcm) / max(1, len(pcm))) if pcm else 0.0
|
|
2344
|
+
if _rms < 100.0: # 16bit 满幅 32767;正常语音 RMS 在千级
|
|
2345
|
+
raise RuntimeError(
|
|
2346
|
+
f"第 {index+1} 句 GSV 输出为静音(RMS {_rms:.0f} < 100)——句首:「{text[:12]}」")
|
|
2235
2347
|
# ⭐ 2026-10-26:把 squeeze_silence 真正接上 —— 这个函数当初就是为
|
|
2236
2348
|
# 「GPT-SoVITS 分句边界偶发几秒死寂」写的,但一直没有任何调用点。
|
|
2237
2349
|
# 压缩后**写回 wav_path**:timeline 的时长和最终混音(重读 wav/xx.wav)
|
|
@@ -2242,6 +2354,8 @@ def prepare_audio(shots, base_voice, work, fps):
|
|
|
2242
2354
|
w.setsampwidth(2)
|
|
2243
2355
|
w.setframerate(rate)
|
|
2244
2356
|
w.writeframes(pcm.tobytes())
|
|
2357
|
+
# P1-5: 压完静音再按最终时长判 —— squeeze 能救回的(句中长死寂)不误杀
|
|
2358
|
+
gsv_take_guard(index, text, len(pcm) / rate)
|
|
2245
2359
|
dur = len(pcm) / rate
|
|
2246
2360
|
if dur <= .02:
|
|
2247
2361
|
raise RuntimeError(f"第 {index+1} 句音频为空")
|
|
@@ -2293,6 +2407,12 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
|
|
|
2293
2407
|
raise ValueError("fps must be 1..60; scale must be 0.1..2")
|
|
2294
2408
|
t_start = time.time()
|
|
2295
2409
|
shots = plan["shots"]
|
|
2410
|
+
# P2-13: 含空文本镜头的 plan 会把整片渲完才在 verify 必挂 —— 开头就拦,报错指向第几镜
|
|
2411
|
+
for _i, _shot in enumerate(shots):
|
|
2412
|
+
if not str((_shot or {}).get("text") or "").strip():
|
|
2413
|
+
raise ValueError(
|
|
2414
|
+
f"第 {_i+1} 镜(id={(_shot or {}).get('id', '?')})没有台词"
|
|
2415
|
+
"——纯视觉镜头暂不支持,给它一句旁白或删掉")
|
|
2296
2416
|
# Editorial v2 and dialogue plans use the 1080p stage. Legacy cards remain 720p.
|
|
2297
2417
|
editorial_plan = plan.get("schema") == "deepmoyu-editorial-v2"
|
|
2298
2418
|
editorial_renderer = None
|
|
@@ -2381,7 +2501,8 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
|
|
|
2381
2501
|
elif shot.get("kind") == "dialogue":
|
|
2382
2502
|
frame = dialogue_subtitle(frame,shot,active,max(0,local))
|
|
2383
2503
|
else:
|
|
2384
|
-
|
|
2504
|
+
# 2026-11-12 审计移除:words 字段读取随卡拉OK死路径一并删掉(无生产者)
|
|
2505
|
+
frame = subtitle(frame,str(shot.get("text","")),max(0,local),duration)
|
|
2385
2506
|
if frame.size != (NAT_W,NAT_H):
|
|
2386
2507
|
frame = frame.resize((NAT_W,NAT_H),Image.Resampling.LANCZOS)
|
|
2387
2508
|
if (W2,H2) != (NAT_W,NAT_H):
|
|
@@ -2481,7 +2602,10 @@ def build(plan: dict, out_path: Path, work: Path, fps=FPS, scale=1.0, still=None
|
|
|
2481
2602
|
frame = Image.blend(prev, frame, amount)
|
|
2482
2603
|
# 片头片尾各 600ms 淡入淡出(像一段真正剪过的短片,而不是突然开始)
|
|
2483
2604
|
if dialogue_plan:
|
|
2484
|
-
|
|
2605
|
+
# P3-34: 淡入 0.60s 会盖过 0.18s lead 的开头语音(开头两个字有声音没画面)
|
|
2606
|
+
# —— 淡入时长不得超过 lead;lead=0 时直接不做淡入(避免除零)。
|
|
2607
|
+
fade_in = min(.60, lead) if lead > 0 else 0.0
|
|
2608
|
+
head = 1.0 if fade_in <= 0 else clamp(tnow / fade_in, 0, 1)
|
|
2485
2609
|
tailf = clamp((total - tnow) / .60, 0, 1)
|
|
2486
2610
|
alpha = min(head, tailf)
|
|
2487
2611
|
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
|
|