dsh-deepmoyu 2.3.0 → 2.4.1

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 (89) hide show
  1. package/AGENTS.md +91 -153
  2. package/CHANGELOG.md +27 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +141 -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 +1348 -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 +953 -270
  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/uvfetch.mjs +165 -153
  54. package/lib/verify.mjs +111 -127
  55. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  56. package/package.json +23 -12
  57. package/presets/deepmoyu.patch.yml +1 -1
  58. package/prompts/duty.md +4 -3
  59. package/screenshots.json +5 -0
  60. package/selfcheck.mjs +21 -6
  61. package/vendor/bilibili-mcp/requirements.in +9 -0
  62. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  63. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  64. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  65. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  66. package/tests/automation-routes.test.mjs +0 -183
  67. package/tests/automation.test.mjs +0 -79
  68. package/tests/background-tools.test.mjs +0 -182
  69. package/tests/background-window.test.mjs +0 -75
  70. package/tests/dup-cross-channel.test.mjs +0 -158
  71. package/tests/manual-browsing.test.mjs +0 -671
  72. package/tests/mcp-restart.test.mjs +0 -131
  73. package/tests/memory-chain.test.mjs +0 -413
  74. package/tests/model-relay.test.mjs +0 -116
  75. package/tests/persona.test.mjs +0 -67
  76. package/tests/pet-run-routes.test.mjs +0 -127
  77. package/tests/pet-run-settings.test.mjs +0 -38
  78. package/tests/pet-session.test.mjs +0 -125
  79. package/tests/pv-stage1.test.mjs +0 -203
  80. package/tests/sticker-routes.test.mjs +0 -141
  81. package/tests/sticker-upload-routes.test.mjs +0 -97
  82. package/tests/sticker-upload.test.mjs +0 -122
  83. package/tests/sticker.test.mjs +0 -169
  84. package/tests/test_guard_cross_channel.py +0 -141
  85. package/tests/test_pv_cover.py +0 -95
  86. package/tests/test_pv_stage1.py +0 -250
  87. package/tests/test_reply_pictures.py +0 -255
  88. package/tests/token-ledger.test.mjs +0 -78
  89. package/tests/verify_pv_sample.py +0 -91
package/lib/pv.mjs CHANGED
@@ -1,35 +1,19 @@
1
- /**
2
- * lib/pv.mjs —— 「昨日冲浪报告」PV 视频的 Node 侧编排
3
- * ============================================================================
4
- * 它是什么(2026-10-22 使用者要求):
5
- * 「每天生成一个视频,讲今天的冲浪情况 —— 今天刷了什么内容、有什么感悟、
6
- * 私信和评论是什么情况。用鲸鱼摸鱼的虚拟形象讲解,要有声音。」
7
- *
8
- * 为什么视频不在 Node 里做:
9
- * 画 Q 版角色、TTS、逐帧渲染、混音全是 Python 的活(Pillow + edge-tts + imageio-ffmpeg),
10
- * 见 `lib/pv.py`(渲染器)与 `lib/pv_plan.py`(把某天的日报/流水整理成分镜)。
11
- * 这一层只负责**编排**:出分镜 → 渲染 → 收成品,全程可看日志。
12
- *
13
- * 不变量(改之前先读):
14
- * · 渲染要几分钟 ⇒ **绝不能在工具调用里同步等**。start() 立刻返回,
15
- * 进度靠 status() 轮询(HTTP /api/bili-duty/pv 与 bili_pv({action:'status'}) 都走它)。
16
- * · 成品先写 `*.partial.mp4`,成功了才 rename 成正式名 ——
17
- * 半截文件永远不能被当成"做好了"(客户端是按文件名认的)。
18
- * · 一次只跑一个:两个 ffmpeg 抢 CPU 只会把两边都拖慢,而且中间产物目录会打架。
19
- * · 任何失败都只记进日志 + status.error,**绝不抛到宿主**(做视频挂了不能影响刷B站)。
20
- * · 输出目录**不写死盘符**:默认取镜像目录反推出的工作区根(见 lib/mirror.mjs),
21
- * 推不出来才退回家目录的 out/。
22
- */
1
+ /** Local PV coordinator: authored productions, isolated jobs and verified publication. */
23
2
  import { spawn } from 'node:child_process'
24
- import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
25
- import { join } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
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'
6
+ import { normalizeProduction } from './pv-production.mjs'
26
7
 
27
- /** 成品文件名前缀:`大肥鱼日报_2026-10-20.mp4` */
28
8
  export const PV_PREFIX = '大肥鱼日报'
29
9
  const PV_RE = /^大肥鱼日报_(\d{4}-\d{2}-\d{2})\.mp4$/
30
10
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
11
+ const PRODUCTION_ID_RE = /^\d{8}-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/
31
12
  const COVER_NAMES = { '16x9': '封面_16x9.png', '4x3': '封面_4x3.png' }
32
13
  const COVER_SIZES = { '16x9': [1920, 1080], '4x3': [1440, 1080] }
14
+ const ACTIVE_STAGES = new Set(['plan', 'cover', 'render', 'verify', 'publish'])
15
+ export const PV_RUNTIME_CHECK = 'import sys,PIL,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)'
16
+ export const PV_COVER_RUNTIME_CHECK = 'import sys,PIL;sys.exit(0 if (3,11)<=sys.version_info[:2]<(3,14) and int(PIL.__version__.split(".")[0])>=13 else 2)'
33
17
 
34
18
  export const pvDateOk = (s) => {
35
19
  const value = String(s || '')
@@ -38,36 +22,45 @@ export const pvDateOk = (s) => {
38
22
  return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
39
23
  }
40
24
 
41
- /** 「昨天」= 本地时区的昨天(不能用 toISOString,那是 UTC,东八区早上 8 点前会算错一天) */
25
+ export const PV_FAILURES_FILE = 'pv-failures.json'
26
+ export const FAILURE_KEEP = 20
27
+ export function readPvFailures(stateDir, keep = FAILURE_KEEP) {
28
+ try {
29
+ const list = JSON.parse(readFileSync(join(stateDir, PV_FAILURES_FILE), 'utf8'))
30
+ return Array.isArray(list) ? list.slice(-keep).reverse() : []
31
+ } catch { return [] }
32
+ }
33
+ function appendPvFailure(stateDir, entry) {
34
+ try {
35
+ const file = join(stateDir, PV_FAILURES_FILE)
36
+ let list = []
37
+ try { const parsed = JSON.parse(readFileSync(file, 'utf8')); if (Array.isArray(parsed)) list = parsed } catch { /* first failure */ }
38
+ list.push(entry)
39
+ mkdirSync(stateDir, { recursive: true })
40
+ const tmp = `${file}.tmp`
41
+ writeFileSync(tmp, JSON.stringify(list.slice(-FAILURE_KEEP), null, 2), 'utf8')
42
+ renameSync(tmp, file)
43
+ } catch { /* Failure history cannot break the host. */ }
44
+ }
45
+
46
+ /** Yesterday in the local timezone. */
42
47
  export function pvYesterday(now = new Date()) {
43
48
  const d = new Date(now.getTime() - 86400_000)
44
49
  const p = (n) => String(n).padStart(2, '0')
45
50
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
46
51
  }
47
-
48
- /**
49
- * 视频放哪儿。
50
- * @param {string} stateDir 插件家目录
51
- * @param {{pvDir?:string, mirrorDir?:string, resolveMirror?:(s:string,e:string)=>string}} o
52
- * @returns {{root:string, out:string, work:string}}
53
- * `root` = 反推出的工作区根(推不出来是空串)
54
- */
55
52
  export function pvDirs(stateDir, o = {}) {
56
53
  const explicit = String(o.pvDir || '').trim()
57
- if (explicit) {
58
- return { root: explicit, out: explicit, legacy: explicit, legacyDirs: [explicit], work: join(explicit, '_work') }
59
- }
54
+ if (explicit) return { root: explicit, out: explicit, legacy: explicit, legacyDirs: [explicit], work: join(explicit, '_work') }
60
55
  let root = ''
61
- try { root = String((o.resolveMirror ? o.resolveMirror(stateDir, o.mirrorDir || '') : '') || '').trim() } catch { root = '' }
56
+ try { root = String((o.resolveMirror ? o.resolveMirror(stateDir, o.mirrorDir || '') : '') || '').trim() } catch { /* no mirror */ }
62
57
  if (root) {
63
58
  const base = join(root, 'bili-work', 'pv')
64
59
  return { root, out: join(base, '日报'), legacy: root, legacyDirs: [root, base], work: base }
65
60
  }
66
- // Keep the same dated structure even when workspace discovery is unavailable.
67
61
  const base = join(stateDir, 'out')
68
62
  return { root: '', out: join(base, '日报'), legacy: base, legacyDirs: [base], work: join(stateDir, 'pv') }
69
63
  }
70
-
71
64
  const dateDir = (outDir, date) => join(outDir, date)
72
65
  const finalOf = (outDir, date) => join(dateDir(outDir, date), `${PV_PREFIX}_${date}.mp4`)
73
66
  const legacyFinalOf = (outDir, date) => join(outDir, `${PV_PREFIX}_${date}.mp4`)
@@ -76,7 +69,7 @@ const usableVideo = (file) => {
76
69
  try { const s = statSync(file); return s.isFile() && s.size > 1024 } catch { return false }
77
70
  }
78
71
 
79
- /** Scan canonical YYYY-MM-DD folders and flat legacy files. Canonical wins. */
72
+ /** Keep the historical date-indexed list compatible; studio jobs have productions(). */
80
73
  export function listPv(outDir, legacyDirs = outDir) {
81
74
  const byDate = new Map()
82
75
  const add = (date, file, canonical) => {
@@ -93,7 +86,7 @@ export function listPv(outDir, legacyDirs = outDir) {
93
86
  if (m) add(m[1], join(outDir, n), false)
94
87
  if (DATE_RE.test(n) && pvDateOk(n)) add(n, finalOf(outDir, n), true)
95
88
  }
96
- } catch { /* directory not created yet */ }
89
+ } catch { /* directory not created */ }
97
90
  for (const legacyDir of asDirs(legacyDirs)) {
98
91
  if (legacyDir === outDir) continue
99
92
  try {
@@ -101,11 +94,10 @@ export function listPv(outDir, legacyDirs = outDir) {
101
94
  const m = PV_RE.exec(n)
102
95
  if (m) add(m[1], join(legacyDir, n), false)
103
96
  }
104
- } catch { /* legacy directory may not exist */ }
97
+ } catch { /* optional legacy directory */ }
105
98
  }
106
- return [...byDate.values()].sort((a, b) => (a.date < b.date ? 1 : -1))
99
+ return [...byDate.values()].sort((a, b) => b.date.localeCompare(a.date))
107
100
  }
108
-
109
101
  export function pvFileOf(outDir, date, legacyDirs = outDir) {
110
102
  if (!pvDateOk(date)) return ''
111
103
  const canonical = finalOf(outDir, date)
@@ -116,341 +108,441 @@ export function pvFileOf(outDir, date, legacyDirs = outDir) {
116
108
  }
117
109
  return canonical
118
110
  }
119
-
120
111
  const validCover = (file, ratio) => {
121
112
  try {
122
113
  const [width, height] = COVER_SIZES[ratio]
123
- if (!statSync(file).isFile() || statSync(file).size === 0) return false
124
- // PNG IHDR width / height; the helper process also performs full decoding.
114
+ if (!statSync(file).isFile()) return false
125
115
  const data = readFileSync(file)
126
116
  return data.length > 24 && data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
127
117
  && data.readUInt32BE(16) === width && data.readUInt32BE(20) === height
128
118
  } catch { return false }
129
119
  }
130
-
131
120
  const tailOf = (file, n = 2600) => {
121
+ try { const s = readFileSync(file, 'utf8'); return s.length > n ? s.slice(-n) : s } catch { return '' }
122
+ }
123
+ const readJson = (file) => {
124
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
125
+ }
126
+ const verificationOk = (file) => readJson(file)?.ok === true
127
+ const atomicJson = (file, data) => {
128
+ const tmp = `${file}.tmp-${randomUUID()}`
129
+ try { writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); renameSync(tmp, file) }
130
+ finally { rmSync(tmp, { force: true }) }
131
+ }
132
+ const within = (root, file) => {
133
+ const rel = relative(root, file)
134
+ return rel !== '..' && !rel.startsWith(`..\\`) && !rel.startsWith('../') && !isAbsolute(rel)
135
+ }
136
+ /** Refuse symlinks/junction escapes for the HTTP-serving production index. */
137
+ const confinedProduction = (outDir, id) => {
138
+ if (typeof id !== 'string' || !PRODUCTION_ID_RE.test(id)) return ''
132
139
  try {
133
- const s = readFileSync(file, 'utf8')
134
- return s.length > n ? s.slice(-n) : s
140
+ const studio = resolve(outDir, 'studio')
141
+ if (lstatSync(studio).isSymbolicLink()) return ''
142
+ const path = resolve(studio, id)
143
+ if (!within(studio, path) || !lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink()) return ''
144
+ const realStudio = realpathSync(studio)
145
+ if (!within(realStudio, realpathSync(path))) return ''
146
+ for (const name of ['production.json', 'video.mp4', 'verification.json']) {
147
+ const file = join(path, name)
148
+ if (lstatSync(file).isSymbolicLink() || !lstatSync(file).isFile() || !within(realStudio, realpathSync(file))) return ''
149
+ }
150
+ return path
135
151
  } catch { return '' }
136
152
  }
153
+ const productionRow = (outDir, id) => {
154
+ const path = confinedProduction(outDir, id)
155
+ if (!path) return null
156
+ const metadata = readJson(join(path, 'production.json'))
157
+ const file = join(path, 'video.mp4')
158
+ if (!metadata || metadata.id !== id || metadata.status !== 'done' || !verificationOk(join(path, 'verification.json')) || !usableVideo(file)) return null
159
+ return { id, mode: String(metadata.mode || ''), title: String(metadata.title || ''), date: String(metadata.date || ''),
160
+ status: 'done', stage: 'done', path, metadata: join(path, 'production.json'), file, size: statSync(file).size,
161
+ at: String(metadata.completedAt || metadata.startedAt || ''), plan: join(path, 'plan.json'), timeline: join(path, 'timeline.json'),
162
+ log: join(path, 'build.log'), verification: join(path, 'verification.json'), targetDurationSec: metadata.targetDurationSec,
163
+ maxDurationSec: metadata.maxDurationSec, covers: Object.fromEntries(Object.entries(COVER_NAMES).map(([ratio, name]) => [ratio, join(path, name)])) }
164
+ }
137
165
 
138
166
  /**
139
- * 造一个 PV 编排器。
140
- * @param {{
141
- * stateDir: string,
142
- * pluginDir: string,
143
- * python: () => string, // 每次现算(cfg 可能被设置页改掉)
144
- * dirs: () => {root:string,out:string,work:string},
145
- * env?: Record<string,string>,
146
- * log?: (s:string)=>void,
147
- * timeoutMs?: number,
148
- * }} o
167
+ * No model calls occur here. render() consumes a complete model-authored plan.
168
+ * @param {{stateDir:string,pluginDir:string,python:()=>string,dirs:()=>object,env?:object,log?:(s:string)=>void,timeoutMs?:number,gsv?:()=>object}} o
149
169
  */
150
170
  export function createPv(o) {
151
171
  const { stateDir, pluginDir, python, dirs, env = null } = o
152
- const say = (s) => { try { o.log?.(s) } catch { /* 日志不许炸 */ } }
153
- const timeoutMs = Number(o.timeoutMs) || 20 * 60_000
154
- /** 🎙 真·本鱼音色(GPT-SoVITS):返回 {gsvRepo,gsvRef,gsvPrompt,gsvPython} 或 null */
172
+ const say = (s) => { try { o.log?.(s) } catch { /* host logger */ } }
173
+ const timeoutMs = Number(o.timeoutMs) > 0 ? Number(o.timeoutMs) : 20 * 60_000
155
174
  const gsvOf = () => { try { return o.gsv?.() || null } catch { return null } }
156
- /** @type {null | {date:string, stage:string, startedAt:string, t0:number, out:string, log:string, error?:string, file?:string, proc?:any, killed?:boolean}} */
157
175
  let job = null
176
+ const settledListeners = new Set()
177
+ const onSettled = (listener) => { settledListeners.add(listener); return () => settledListeners.delete(listener) }
158
178
  let depsCache = { at: 0, ok: null }
159
-
160
179
  const scripts = () => {
161
- const pick = (name) => {
162
- const packaged = join(pluginDir, 'lib', name)
163
- return existsSync(packaged) ? packaged : join(stateDir, name)
164
- }
165
- return { plan: pick('pv_plan.py'), render: pick('pv.py'), cover: pick('pv_cover.py') }
180
+ const pick = (name) => existsSync(join(pluginDir, 'lib', name)) ? join(pluginDir, 'lib', name) : join(stateDir, name)
181
+ return { plan: pick('pv_plan.py'), render: pick('pv.py'), cover: pick('pv_cover.py'), verify: pick('pv_verify.py') }
166
182
  }
167
-
168
- const append = (file, s) => { try { appendFileSync(file, String(s), 'utf8') } catch { /* 日志写不了也不能炸 */ } }
169
-
170
- const run = (cmd, args, logFile, jobRef) => new Promise((resolve) => {
171
- append(logFile, `\n$ ${cmd} ${args.join(' ')}\n`)
172
- let c = null
173
- try { c = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: env || process.env }) }
174
- catch (e) { append(logFile, `起不来:${e?.message ?? e}\n`); return resolve(1) }
175
- if (jobRef) jobRef.proc = c
176
- const w = (b) => append(logFile, String(b))
177
- c.stdout?.on('data', w)
178
- c.stderr?.on('data', w)
179
- const timer = setTimeout(() => {
180
- append(logFile, `\n--- 超时 ${Math.round(timeoutMs / 60000)} 分钟,强制结束 ---\n`)
181
- try { c.kill('SIGKILL') } catch { /* 已经没了 */ }
182
- }, timeoutMs)
183
- timer.unref?.()
184
- c.on('close', (code) => { clearTimeout(timer); if (jobRef?.proc === c) jobRef.proc = null; append(logFile, `\n--- exit ${code} ---\n`); resolve(code ?? 1) })
185
- c.on('error', (e) => { clearTimeout(timer); append(logFile, `起不来:${e?.message ?? e}\n`); resolve(1) })
183
+ const append = (file, s) => { try { appendFileSync(file, String(s), 'utf8') } catch { /* disk/log failure */ } }
184
+ const busy = () => Boolean(job && (job.active || job.proc || job.killProc || ACTIVE_STAGES.has(job.stage)))
185
+ const productions = () => {
186
+ const d = dirs()
187
+ try { return readdirSync(join(d.out, 'studio')).map((id) => productionRow(d.out, id)).filter(Boolean).sort((a, b) => b.at.localeCompare(a.at)) }
188
+ catch { return [] }
189
+ }
190
+ const productionOf = (j) => j?.production ? { ...j.production, status: j.stage, stage: j.stage,
191
+ error: j.error || '', ...(j.completedAt ? { completedAt: j.completedAt } : {}) } : null
192
+ const persist = (j) => {
193
+ if (!j.metadata) return
194
+ atomicJson(j.metadata, j.production ? productionOf(j) : { date: j.date, status: j.stage, stage: j.stage,
195
+ startedAt: j.startedAt, completedAt: j.completedAt || '', repairOnly: j.repairOnly,
196
+ verificationSkipped: j.verificationSkipped || false, error: j.error || '', plan: j.plan, timeline: j.timeline, verification: j.verification, file: j.file, log: j.log })
197
+ }
198
+ const recordFailure = (j, failedStage) => {
199
+ if (j.failureRecorded) return
200
+ j.failureRecorded = true
201
+ appendPvFailure(stateDir, { at: new Date().toISOString(), date: j.date, id: j.id || '', stage: failedStage,
202
+ repairOnly: Boolean(j.repairOnly), error: j.error, log: j.log })
203
+ }
204
+ const terminate = (j) => {
205
+ const child = j.proc
206
+ if (!child || j.killProc) return
207
+ if (process.platform === 'win32' && Number.isSafeInteger(child.pid) && child.pid > 0) {
208
+ let killer
209
+ try { killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore', env: env || process.env }) }
210
+ catch { try { child.kill('SIGKILL') } catch { /* already exited */ }; return }
211
+ j.killProc = killer
212
+ j.killPromise = new Promise((resolveKill) => {
213
+ let done = false
214
+ const finish = (code) => {
215
+ if (done) return
216
+ done = true
217
+ if (j.killProc === killer) j.killProc = null
218
+ if (code !== 0) { try { child.kill('SIGKILL') } catch { /* exited */ } }
219
+ resolveKill()
220
+ }
221
+ killer.once('close', finish)
222
+ killer.once('error', () => finish(1))
223
+ })
224
+ } else { try { child.kill('SIGKILL') } catch { /* exited */ } }
225
+ }
226
+ const cancel = (j, timedOut = false) => {
227
+ if (j.killed) return
228
+ const priorStage = j.stage
229
+ j.killed = true // Set before terminating any child or notifying observers.
230
+ j.stage = timedOut ? 'failed' : 'stopped'
231
+ j.error = timedOut ? `制作任务总时限已到(${timeoutMs}ms)` : ''
232
+ append(j.log, timedOut ? `\n--- ${j.error},终止进程树 ---\n` : '\n--- 手动停下;等待子进程退出 ---\n')
233
+ try { persist(j) } catch { /* cancellation must remain immediate */ }
234
+ if (timedOut) recordFailure(j, priorStage)
235
+ terminate(j)
236
+ }
237
+ const alive = (j) => {
238
+ if (j.killed) throw new Error('PV_JOB_CANCELLED')
239
+ if (Date.now() >= j.deadline) { cancel(j, true); throw new Error('PV_JOB_CANCELLED') }
240
+ }
241
+ const run = (cmd, args, j) => new Promise((resolveRun) => {
242
+ if (j.killed) return resolveRun(null)
243
+ try { alive(j) } catch { return resolveRun(null) }
244
+ append(j.log, `\n$ ${cmd} ${args.join(' ')}\n`)
245
+ let child
246
+ try { child = spawn(cmd, args, { cwd: stateDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: env || process.env }) }
247
+ catch (e) { append(j.log, `起不来:${e?.message ?? e}\n`); return resolveRun(1) }
248
+ j.proc = child
249
+ child.stdout?.on('data', (b) => append(j.log, b.toString()))
250
+ child.stderr?.on('data', (b) => append(j.log, b.toString()))
251
+ // close, not exit: stdout/stderr and grandchildren must have finished first.
252
+ child.once('error', (e) => append(j.log, `起不来:${e?.message ?? e}\n`))
253
+ child.once('close', (code) => {
254
+ if (j.proc === child) j.proc = null
255
+ append(j.log, `\n--- exit ${code} ---\n`)
256
+ resolveRun(code ?? 1)
257
+ })
186
258
  })
187
-
259
+ const phase = (j, stage) => { alive(j); j.stage = stage; persist(j) }
260
+ const requireExit = (code, message) => { if (code !== 0) throw new Error(`${message}(exit ${code})`) }
188
261
  const status = () => {
189
262
  const d = dirs()
190
- const running = !!job && (job.stage === 'plan' || job.stage === 'cover' || job.stage === 'render')
191
- const base = {
192
- ok: true,
193
- running,
194
- stage: job ? job.stage : 'idle',
195
- date: job ? job.date : '',
196
- startedAt: job ? job.startedAt : '',
197
- ms: job ? Date.now() - job.t0 : 0,
198
- file: job ? (job.file || job.out) : '',
199
- log: job ? job.log : '',
200
- plan: job ? job.plan : '',
201
- timeline: job ? job.timeline : '',
202
- error: job ? (job.error || '') : '',
203
- tail: job ? tailOf(job.log) : '',
204
- out: d.out,
205
- work: d.work,
206
- root: d.root,
207
- videos: listPv(d.out, d.legacyDirs || d.legacy || d.out),
208
- }
209
- return base
263
+ return { ok: true, running: busy(), stopping: Boolean(job?.killed && (job?.proc || job?.killProc || job?.active)),
264
+ stage: job?.stage || 'idle', date: job?.date || '', id: job?.id || '', startedAt: job?.startedAt || '',
265
+ ms: job ? Date.now() - job.t0 : 0, file: job ? (job.file || job.out) : '', log: job?.log || '',
266
+ plan: job?.plan || '', timeline: job?.timeline || '', verification: job?.verification || '',
267
+ verificationSkipped: Boolean(job?.verificationSkipped), repairOnly: Boolean(job?.repairOnly),
268
+ production: productionOf(job), productions: productions(), error: job?.error || '', tail: job ? tailOf(job.log) : '',
269
+ out: d.out, work: d.work, root: d.root, videos: listPv(d.out, d.legacyDirs || d.legacy || d.out), failures: readPvFailures(stateDir) }
210
270
  }
211
-
212
- /** 依赖探一下(edge-tts / imageio-ffmpeg / Pillow)—— 拿不到就说拿不到,别让它跑到一半才炸 */
271
+ const checkImport = (imports) => new Promise((resolveCheck) => {
272
+ try {
273
+ const child = spawn(String(python() || 'python'), ['-c', imports], { windowsHide: true, stdio: 'ignore', env: env || process.env })
274
+ const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* exited */ } }, Math.min(timeoutMs, 30_000))
275
+ timer.unref?.()
276
+ child.once('close', (code) => { clearTimeout(timer); resolveCheck(code === 0) })
277
+ child.once('error', () => { clearTimeout(timer); resolveCheck(false) })
278
+ } catch { resolveCheck(false) }
279
+ })
213
280
  const checkDeps = async (force = false) => {
214
281
  if (!force && depsCache.ok !== null && Date.now() - depsCache.at < 60_000) return depsCache.ok
215
- const py = String(python() || 'python')
216
- const exit = await new Promise((resolve) => {
217
- try {
218
- const c = spawn(py, ['-c', 'import edge_tts, imageio_ffmpeg, PIL'], { windowsHide: true, stdio: 'ignore', env: env || process.env })
219
- c.on('exit', (n) => resolve(n ?? 1))
220
- c.on('error', () => resolve(1))
221
- } catch { resolve(1) }
222
- })
223
- depsCache = { at: Date.now(), ok: exit === 0 }
224
- return depsCache.ok
282
+ const ok = await checkImport(PV_RUNTIME_CHECK)
283
+ depsCache = { at: Date.now(), ok }
284
+ return ok
225
285
  }
226
-
286
+ const checkCoverDeps = () => checkImport(PV_COVER_RUNTIME_CHECK)
227
287
  const isCoverRepair = (date, force = false) => {
228
- if (force) return false
288
+ if (force || !pvDateOk(date)) return false
229
289
  const d = dirs()
230
290
  const current = pvFileOf(d.out, date, d.legacyDirs || d.legacy || d.out)
231
- if (!existsSync(current)) return false
232
- if (current !== finalOf(d.out, date)) return true
233
- return !Object.entries(COVER_NAMES).every(([ratio, name]) => validCover(join(dateDir(d.out, date), name), ratio))
291
+ if (!usableVideo(current)) return false
292
+ return current !== finalOf(d.out, date) || !Object.entries(COVER_NAMES).every(([ratio, name]) => validCover(join(dateDir(d.out, date), name), ratio))
234
293
  }
235
294
  const needsWork = (date) => {
236
295
  const d = dirs()
237
296
  return !usableVideo(pvFileOf(d.out, date, d.legacyDirs || d.legacy || d.out)) || isCoverRepair(date)
238
297
  }
239
- const checkCoverDeps = async () => {
240
- const py = String(python() || 'python')
241
- return new Promise((resolve) => {
242
- try {
243
- const c = spawn(py, ['-c', 'import PIL'], { windowsHide: true, stdio: 'ignore', env: env || process.env })
244
- c.on('exit', (n) => resolve(n === 0))
245
- c.on('error', () => resolve(false))
246
- } catch { resolve(false) }
247
- })
248
- }
249
-
250
298
  const copyReport = (date, targetDir, logFile) => {
251
299
  const source = join(stateDir, 'out', `${date}.md`)
252
300
  if (!existsSync(source)) return
253
301
  const target = join(targetDir, '日报.md')
254
302
  const ready = `${target}.ready`
255
- try {
256
- mkdirSync(targetDir, { recursive: true })
257
- copyFileSync(source, ready)
258
- renameSync(ready, target)
259
- append(logFile, `\n日报副本 → ${target}\n`)
260
- } catch (e) {
261
- try { rmSync(ready, { force: true }) } catch { /* best effort */ }
262
- append(logFile, `\n日报副本未复制(不影响成片):${e?.message ?? e}\n`)
263
- }
303
+ try { copyFileSync(source, ready); renameSync(ready, target) }
304
+ catch (e) { try { rmSync(ready, { force: true }) } catch { /* cleanup */ }; append(logFile, `\n日报副本未复制:${e?.message ?? e}\n`) }
264
305
  }
265
-
266
- const commitOutputs = (tmp, stagedCovers, final, date, outDir) => {
267
- const targetDir = dateDir(outDir, date)
268
- mkdirSync(targetDir, { recursive: true })
269
- const pairs = [{ source: tmp, target: final, ratio: '' },
270
- ...Object.entries(COVER_NAMES).map(([ratio, name]) => ({ source: join(stagedCovers, name), target: join(targetDir, name), ratio }))]
271
- const stamp = `${process.pid}-${Date.now()}`
272
- const ready = pairs.map(({ target }) => ({ target, ready: `${target}.ready-${stamp}`, backup: `${target}.backup-${stamp}` }))
273
- let backedUp = false
306
+ /** Transactional replacement preserves a previously published daily package. */
307
+ const commitOutputs = (pairs) => {
308
+ const stamp = randomUUID()
309
+ const ready = pairs.map(({ target }) => ({ target, ready: `${target}.ready-${stamp}`, backup: `${target}.backup-${stamp}`, existed: existsSync(target) }))
310
+ let changing = false
274
311
  try {
275
- for (let i = 0; i < pairs.length; i++) {
276
- if (pairs[i].ratio && !validCover(pairs[i].source, pairs[i].ratio)) throw new Error(`封面校验失败:${pairs[i].source}`)
277
- if (!pairs[i].ratio && statSync(pairs[i].source).size < 1024) throw new Error('输出文件为空或不完整')
278
- copyFileSync(pairs[i].source, ready[i].ready)
312
+ for (let i = 0; i < pairs.length; i++) copyFileSync(pairs[i].source, ready[i].ready)
313
+ for (const item of ready) if (item.existed) copyFileSync(item.target, item.backup)
314
+ changing = true
315
+ for (const item of ready) { rmSync(item.target, { force: true }); renameSync(item.ready, item.target) }
316
+ } catch (e) {
317
+ if (changing) for (const item of ready) {
318
+ if (item.existed && existsSync(item.backup)) copyFileSync(item.backup, item.target)
319
+ else if (!item.existed) rmSync(item.target, { force: true })
279
320
  }
280
- for (const item of ready) if (existsSync(item.target)) copyFileSync(item.target, item.backup)
281
- backedUp = true
282
- try {
283
- for (const item of ready) {
284
- rmSync(item.target, { force: true })
285
- renameSync(item.ready, item.target)
286
- }
287
- } catch (error) {
288
- if (backedUp) {
289
- for (const item of ready) {
290
- if (existsSync(item.backup)) copyFileSync(item.backup, item.target)
291
- else rmSync(item.target, { force: true })
292
- }
321
+ throw e
322
+ } finally { for (const item of ready) { rmSync(item.ready, { force: true }); rmSync(item.backup, { force: true }) } }
323
+ }
324
+ const setupCheck = (sc, names) => {
325
+ const missing = names.find((name) => !existsSync(sc[name]))
326
+ if (missing) return { ok: false, error: `渲染脚本没铺好(缺 ${missing === 'render' ? 'pv.py' : `pv_${missing}.py`})` }
327
+ const py = String(python() || 'python')
328
+ if (!existsSync(py) && py !== 'python' && py !== 'python3') return { ok: false, error: `Python 找不到:${py}(检查设置里的 pythonPath)` }
329
+ return { ok: true, py }
330
+ }
331
+ const busyError = () => ({ ok: false, running: true, date: job.date, id: job.id || '', production: productionOf(job), error: `正在做 ${job.date} 的视频或等待子进程退出,等它结束再来` })
332
+ const execute = (j, task) => {
333
+ job = j
334
+ j.active = true
335
+ j.deadline = j.t0 + timeoutMs
336
+ j.timer = setTimeout(() => cancel(j, true), Math.max(1, j.deadline - Date.now()))
337
+ j.timer.unref?.()
338
+ // Start on a microtask so callers always receive the job before child work starts.
339
+ Promise.resolve().then(() => { alive(j); return task() })
340
+ .catch((e) => {
341
+ if (j.killed) return
342
+ const failedStage = j.stage
343
+ j.stage = 'failed'
344
+ j.error = String(e?.message ?? e)
345
+ append(j.log, `\n❌ ${j.error}\n`)
346
+ try { persist(j) } catch { /* original failure remains */ }
347
+ recordFailure(j, failedStage)
348
+ say(`🎬 ${j.date} 的视频失败:${j.error}`)
349
+ })
350
+ .finally(async () => {
351
+ clearTimeout(j.timer)
352
+ if (j.killPromise) await j.killPromise
353
+ j.active = false
354
+ // Observers receive only a settled job, never a partially published artifact.
355
+ const settled = { ok: j.stage === 'done', id: j.id || '', date: j.date, stage: j.stage, error: j.error || '',
356
+ file: j.file, plan: j.plan, timeline: j.timeline, verification: j.verification, log: j.log,
357
+ production: productionOf(j), verificationSkipped: Boolean(j.verificationSkipped) }
358
+ for (const listener of settledListeners) {
359
+ try { Promise.resolve(listener(settled)).catch(error => say(`🎬 制作回执未送达:${String(error?.message || error)}`)) }
360
+ catch (error) { say(`🎬 制作回执未送达:${String(error?.message || error)}`) }
293
361
  }
294
- throw error
295
- }
296
- } finally {
297
- for (const item of ready) {
298
- rmSync(item.ready, { force: true })
299
- rmSync(item.backup, { force: true })
300
- }
301
- }
302
- rmSync(tmp, { force: true })
362
+ // Keep failures/stops visible. A successful job may be released later.
363
+ if (j.stage !== 'done') return
364
+ const timer = setTimeout(() => { if (job === j && !busy()) job = null }, 30_000)
365
+ timer.unref?.()
366
+ })
367
+ }
368
+ const covers = async (j, sc, py, out, repair = false) => {
369
+ phase(j, 'cover')
370
+ const args = [sc.cover, '--plan', j.plan, '--outdir', out]
371
+ if (repair) args.push('--only-missing')
372
+ const code = await run(py, args, j)
373
+ alive(j)
374
+ requireExit(code, '封面生成失败')
375
+ for (const [ratio, name] of Object.entries(COVER_NAMES)) if (!validCover(join(out, name), ratio)) throw new Error(`封面缺失或尺寸不正确:${join(out, name)}`)
376
+ }
377
+ const renderVerify = async (j, sc, py) => {
378
+ phase(j, 'render')
379
+ const code = await run(py, [sc.render, '--plan', j.plan, '--out', j.tmp, '--work', j.renderWork], j)
380
+ alive(j)
381
+ requireExit(code, '渲染失败')
382
+ if (!usableVideo(j.tmp)) throw new Error('输出文件为空或不完整')
383
+ if (!existsSync(j.renderTimeline)) throw new Error('渲染器没有生成 timeline.json')
384
+ copyFileSync(j.renderTimeline, j.timeline)
385
+ phase(j, 'verify')
386
+ // No stale/forged receipt may authorize a later render.
387
+ rmSync(j.verification, { force: true })
388
+ const verifyArgs = [sc.verify, '--video', j.tmp, '--timeline', j.timeline, '--outdir', j.packageDir, '--max-duration', String(j.maxDurationSec)]
389
+ if (Number(j.targetDurationSec) > 0) verifyArgs.push('--target-duration', String(j.targetDurationSec))
390
+ const verifyCode = await run(py, verifyArgs, j)
391
+ alive(j)
392
+ requireExit(verifyCode, '成片校验失败')
393
+ const receipt = readJson(j.verification)
394
+ if (receipt?.ok !== true) throw new Error('成片校验失败:verification.json 缺失或未明确给出 ok:true')
395
+ // Publication preserves the exact validated bytes by renaming the temporary
396
+ // file. Expose the durable final path; keep only the relative input name as
397
+ // provenance because the temporary absolute path stops existing after rename.
398
+ atomicJson(j.verification, { ...receipt, video: j.file, verifiedVideo: j.file,
399
+ verificationInput: relative(j.packageDir, receipt.video || j.tmp) })
400
+ phase(j, 'publish')
401
+ }
402
+ const done = (j) => {
403
+ alive(j)
404
+ j.stage = 'done'
405
+ j.completedAt = new Date().toISOString()
406
+ persist(j)
407
+ append(j.log, `\n✅ ${j.file}\n`)
408
+ say(`🎬 ${j.date} 的视频做好了:${j.file}`)
303
409
  }
304
410
 
305
- /**
306
- * Start rendering or repair only missing covers; always returns immediately.
307
- * @param {string} date YYYY-MM-DD
308
- * @param {{force?:boolean, noNet?:boolean}} opt
309
- */
411
+ /** Legacy date-based path, retained for scheduled daily films and cover repairs. */
310
412
  const start = (date, opt = {}) => {
311
413
  const day = String(date || '').trim()
312
414
  if (!pvDateOk(day)) return { ok: false, error: `日期得是 YYYY-MM-DD(拿到的是 "${day}")` }
313
- if (job && (job.stage === 'plan' || job.stage === 'cover' || job.stage === 'render' || job.proc)) {
314
- return { ok: false, running: true, date: job.date, error: `正在做 ${job.date} 的视频,等它做完再来` }
315
- }
316
- const sc = scripts()
317
- if (!existsSync(sc.plan) || !existsSync(sc.render) || !existsSync(sc.cover)) {
318
- const missing = !existsSync(sc.plan) ? 'pv_plan.py' : !existsSync(sc.cover) ? 'pv_cover.py' : 'pv.py'
319
- return { ok: false, error: `渲染脚本没铺好(缺 ${missing})—— 重启一次插件会让 lib/state.mjs 铺过去` }
320
- }
321
- const py = String(python() || 'python')
322
- if (!existsSync(py) && py !== 'python' && py !== 'python3') {
323
- return { ok: false, error: `Python 找不到:${py}(先装依赖 / 检查设置里的 pythonPath)` }
324
- }
415
+ if (busy()) return busyError()
325
416
  const d = dirs()
326
417
  const current = pvFileOf(d.out, day, d.legacyDirs || d.legacy || d.out)
327
418
  const repairOnly = isCoverRepair(day, opt.force === true)
328
- if (existsSync(current) && !opt.force && !repairOnly) {
329
- return { ok: true, skipped: true, date: day, file: current, hint: '这天的视频和两张封面都已经有了;要重做传 force:true' }
330
- }
331
- const final = finalOf(d.out, day)
419
+ if (usableVideo(current) && !opt.force && !repairOnly) return { ok: true, skipped: true, date: day, file: current, hint: '这天的视频和两张封面都已经有了;要重做传 force:true' }
420
+ const sc = scripts()
421
+ const check = setupCheck(sc, repairOnly ? ['plan', 'cover'] : ['plan', 'cover', 'render', 'verify'])
422
+ if (!check.ok) return check
423
+ const { py } = check
424
+ const runDir = join(d.work, 'runs', `${day.replace(/-/g, '')}-${randomUUID()}`)
425
+ const packageDir = join(runDir, 'package')
426
+ const renderWork = join(runDir, 'media')
332
427
  const targetDir = dateDir(d.out, day)
333
- const plan = join(d.work, `plan_${day}.json`)
334
- const tmp = join(d.work, `${PV_PREFIX}_${day}.partial.mp4`)
335
- const stageDir = join(d.work, `_publish_${day}`)
336
- const coverOut = repairOnly ? targetDir : stageDir
428
+ const plan = join(packageDir, 'plan.json')
337
429
  try {
338
- mkdirSync(d.out, { recursive: true })
339
- mkdirSync(d.work, { recursive: true })
430
+ mkdirSync(packageDir, { recursive: true })
340
431
  mkdirSync(targetDir, { recursive: true })
341
- if (!repairOnly) { rmSync(stageDir, { recursive: true, force: true }); mkdirSync(stageDir, { recursive: true }) }
342
432
  } catch (e) { return { ok: false, error: `建目录失败:${e?.message ?? e}` } }
343
-
344
- const logFile = join(d.work, `build_${day}.log`)
345
- try { rmSync(logFile, { force: true }) } catch { /* 清不掉就追加 */ }
346
- const j = { date: day, stage: repairOnly && existsSync(plan) ? 'cover' : 'plan', startedAt: new Date().toISOString(), t0: Date.now(), out: final, file: final, log: logFile, plan, timeline: join(d.work, `_w${day.replace(/-/g, '')}`, 'timeline.json'), proc: null, killed: false, repairOnly }
347
- job = j
348
- append(logFile, `=== ${PV_PREFIX} ${day} ${repairOnly ? '补封面' : '开始'} ${new Date().toLocaleString('zh-CN')} ===\n`)
349
- say(repairOnly ? `🎬 ${day} 已有成片,只补封面` : `🎬 开始做 ${day} 的视频`)
350
-
433
+ const j = { date: day, stage: 'plan', startedAt: new Date().toISOString(), t0: Date.now(), file: finalOf(d.out, day),
434
+ out: finalOf(d.out, day), log: join(packageDir, 'build.log'), plan, timeline: join(packageDir, 'timeline.json'),
435
+ verification: join(packageDir, 'verification.json'), metadata: join(packageDir, 'job.json'), renderWork,
436
+ renderTimeline: join(renderWork, 'timeline.json'), tmp: join(packageDir, 'video.partial.mp4'), packageDir,
437
+ maxDurationSec: 300, proc: null, killed: false, repairOnly, verificationSkipped: repairOnly }
438
+ append(j.log, `=== ${PV_PREFIX} ${day} ${repairOnly ? '仅补封面(旧成片未重新校验)' : '开始'} ===\n`)
439
+ try { persist(j) } catch (e) { return { ok: false, error: `任务记录写入失败:${e?.message ?? e}` } }
351
440
  const planArgs = [sc.plan, '--auto', day, '-o', plan, '--home', stateDir]
352
441
  if (opt.noNet) planArgs.push('--no-net')
353
- let isFirst = false
354
- try { isFirst = !listPv(d.out, d.legacy || d.out).some((v) => v.date < day) } catch { isFirst = false }
355
- if (isFirst) planArgs.push('--first')
442
+ if (!listPv(d.out, d.legacyDirs || d.legacy || d.out).some((v) => v.date < day)) planArgs.push('--first')
356
443
  if (opt.voice) planArgs.push('--voice', String(opt.voice))
357
444
  const gsv = gsvOf()
358
445
  if (gsv) {
359
446
  planArgs.push('--gsv-repo', gsv.gsvRepo, '--gsv-ref', gsv.gsvRef, '--gsv-prompt', gsv.gsvPrompt)
360
447
  if (gsv.gsvPython) planArgs.push('--gsv-python', gsv.gsvPython)
361
- append(logFile, `🎙 音色:GPT-SoVITS(参考 ${gsv.gsvRef})\n`)
362
- }
363
-
364
- const planRun = repairOnly && existsSync(plan) ? Promise.resolve(0) : run(py, planArgs, logFile, j)
365
- planRun
366
- .then((planCode) => {
367
- if (j.killed) return null
368
- if (planCode !== 0) throw new Error(`出分镜失败(exit ${planCode}),看日志 ${logFile}`)
369
- j.stage = 'cover'
370
- append(logFile, repairOnly ? '\n只补缺失封面,不重做配音或视频。\n' : '\n生成两种比例封面。\n')
371
- const coverArgs = [sc.cover, '--plan', plan, '--outdir', coverOut]
372
- if (repairOnly) coverArgs.push('--only-missing')
373
- return run(py, coverArgs, logFile, j)
374
- })
375
- .then((coverCode) => {
376
- if (j.killed || coverCode === null) return null
377
- if (coverCode !== 0) throw new Error(`封面生成失败(exit ${coverCode}),看日志 ${logFile}`)
378
- for (const [ratio, name] of Object.entries(COVER_NAMES)) {
379
- if (!validCover(join(coverOut, name), ratio)) throw new Error(`封面缺失或尺寸不正确:${join(coverOut, name)}`)
380
- }
381
- if (repairOnly) {
382
- if (!existsSync(final) && existsSync(current)) {
383
- const ready = `${final}.ready.mp4`
384
- try { copyFileSync(current, ready); renameSync(ready, final) }
385
- catch (e) { rmSync(ready, { force: true }); throw new Error(`旧成片归档失败(原文件仍保留):${e?.message ?? e}`) }
386
- }
387
- copyReport(day, targetDir, logFile)
388
- j.stage = 'done'
389
- j.file = final
390
- append(logFile, `\n封面已补齐:${targetDir}\n`)
391
- say(`🎬 ${day} 的封面已补齐:${targetDir}`)
392
- return null
393
- }
394
- j.stage = 'render'
395
- say(`🎬 ${day} 封面就绪,开始渲染视频`)
396
- return run(py, [sc.render, '--plan', plan, '--out', tmp, '--work', join(d.work, `_w${day.replace(/-/g, '')}`)], logFile, j)
397
- })
398
- .then((renderCode) => {
399
- if (j.killed || renderCode === null || repairOnly) return
400
- if (renderCode !== 0) throw new Error(`渲染失败(exit ${renderCode}),看日志 ${logFile}`)
401
- if (statSync(tmp).size < 1024) throw new Error('输出文件为空或不完整')
402
- commitOutputs(tmp, stageDir, final, day, d.out)
403
- copyReport(day, targetDir, logFile)
404
- j.stage = 'done'
405
- j.file = final
406
- append(logFile, `\n✅ ${final}\n`)
407
- say(`🎬 ${day} 的视频做好了:${final}`)
408
- })
409
- .catch((e) => {
410
- j.stage = 'failed'
411
- j.error = String(e?.message ?? e)
412
- append(logFile, `\n❌ ${j.error}\n`)
413
- say(`🎬 ${day} 的视频失败:${j.error}`)
414
- })
415
- .finally(() => {
416
- j.proc = null
417
- if (!repairOnly) { try { rmSync(stageDir, { recursive: true, force: true }) } catch { /* staging cleanup */ } }
418
- const t = setTimeout(() => { if (job === j) job = null }, 30_000)
419
- t.unref?.()
420
- })
421
-
422
- return {
423
- ok: true, started: true, repairing: repairOnly, date: day, file: final, log: logFile, work: d.work,
424
- hint: repairOnly ? '正在只补两种比例的封面,不会重做视频或配音。' : '做好了我会写日志;进度用 bili_pv({action:"status"}) 或桌宠 ⚙️ 设置 →「视频 · 模型」里的「做视频」看(一条 2–6 分钟)',
425
448
  }
449
+ execute(j, async () => {
450
+ const previousPlan = [join(targetDir, 'plan.json'), join(d.work, `plan_${day}.json`)].find((file) => existsSync(file))
451
+ if (repairOnly && previousPlan) copyFileSync(previousPlan, plan)
452
+ else {
453
+ const code = await run(py, planArgs, j)
454
+ alive(j)
455
+ requireExit(code, '出分镜失败')
456
+ }
457
+ await covers(j, sc, py, repairOnly ? targetDir : packageDir, repairOnly)
458
+ alive(j)
459
+ if (repairOnly) {
460
+ if (!usableVideo(j.file) && usableVideo(current)) commitOutputs([{ source: current, target: j.file }])
461
+ copyReport(day, targetDir, j.log)
462
+ append(j.log, '\n仅补封面:原成片未重新校验;不据此声称旧影片通过验证。\n')
463
+ done(j)
464
+ return
465
+ }
466
+ await renderVerify(j, sc, py)
467
+ alive(j)
468
+ const names = ['plan.json', 'timeline.json', 'verification.json', ...Object.values(COVER_NAMES)]
469
+ commitOutputs([{ source: j.tmp, target: j.file }, ...names.map((name) => ({ source: join(packageDir, name), target: join(targetDir, name) }))])
470
+ copyReport(day, targetDir, j.log)
471
+ done(j)
472
+ // The completed, validated package retains its provenance and build log beside the film.
473
+ copyFileSync(j.log, join(targetDir, 'build.log'))
474
+ copyFileSync(j.metadata, join(targetDir, 'job.json'))
475
+ })
476
+ say(repairOnly ? `🎬 ${day} 已有成片,只补封面(不重新校验旧影片)` : `🎬 开始做 ${day} 的视频`)
477
+ return { ok: true, started: true, repairing: repairOnly, verificationSkipped: repairOnly, date: day, file: j.file,
478
+ log: j.log, work: runDir, plan, timeline: j.timeline, verification: j.verification,
479
+ hint: repairOnly ? '只补两种比例封面;已有影片未重新校验。' : '正在制作;渲染后必须通过音视频校验才会替换正式成片。' }
426
480
  }
427
481
 
482
+ /** Start an authored production immediately; options are trusted host configuration. */
483
+ const render = (production, opt = {}) => {
484
+ if (busy()) return busyError()
485
+ let plan
486
+ try { plan = normalizeProduction(production, { date: opt.date, voice: opt.voice, gsv: opt.gsv === undefined ? gsvOf() : opt.gsv }) }
487
+ catch (e) { return { ok: false, error: String(e?.message ?? e) } }
488
+ const sc = scripts()
489
+ const check = setupCheck(sc, ['cover', 'render', 'verify'])
490
+ if (!check.ok) return check
491
+ const d = dirs()
492
+ const id = `${plan.date.replace(/-/g, '')}-${randomUUID()}`
493
+ const studio = join(d.out, 'studio')
494
+ const path = join(studio, id)
495
+ try {
496
+ mkdirSync(studio, { recursive: true })
497
+ if (lstatSync(studio).isSymbolicLink()) throw new Error('studio 不能是符号链接或目录联接')
498
+ mkdirSync(path) // Exclusive UUID folder: never reuse or overwrite another output.
499
+ } catch (e) { return { ok: false, error: `建制作目录失败:${e?.message ?? e}` } }
500
+ const renderWork = join(path, 'media')
501
+ const j = { id, date: plan.date, stage: 'cover', startedAt: new Date().toISOString(), t0: Date.now(),
502
+ out: join(path, 'video.mp4'), file: join(path, 'video.mp4'), log: join(path, 'build.log'), plan: join(path, 'plan.json'),
503
+ timeline: join(path, 'timeline.json'), verification: join(path, 'verification.json'), metadata: join(path, 'production.json'),
504
+ packageDir: path, renderWork, renderTimeline: join(renderWork, 'timeline.json'), tmp: join(path, 'video.partial.mp4'),
505
+ proc: null, killed: false, repairOnly: false, targetDurationSec: plan.targetDurationSec, maxDurationSec: plan.maxDurationSec }
506
+ j.production = { id, mode: plan.mode, title: plan.title, date: plan.date, path, metadata: j.metadata,
507
+ file: j.file, plan: j.plan, timeline: j.timeline, log: j.log, verification: j.verification, startedAt: j.startedAt,
508
+ targetDurationSec: plan.targetDurationSec, maxDurationSec: plan.maxDurationSec, estimatedDurationSec: plan.estimatedDurationSec }
509
+ try {
510
+ atomicJson(j.plan, plan)
511
+ append(j.log, `=== studio ${id} · ${plan.mode} · ${plan.title} ===\n`)
512
+ persist(j)
513
+ } catch (e) { return { ok: false, error: `制作计划写入失败:${e?.message ?? e}` } }
514
+ execute(j, async () => {
515
+ await covers(j, sc, check.py, path)
516
+ await renderVerify(j, sc, check.py)
517
+ alive(j)
518
+ renameSync(j.tmp, j.file)
519
+ try { done(j) }
520
+ catch (e) {
521
+ // Failed metadata commit cannot leave a publicly named successful movie.
522
+ renameSync(j.file, j.tmp)
523
+ throw e
524
+ }
525
+ })
526
+ say(`🎬 制作会话交来的《${plan.title}》开始渲染`)
527
+ return { ok: true, started: true, id, date: plan.date, stage: j.stage, production: productionOf(j), file: j.file,
528
+ log: j.log, work: path, plan: j.plan, timeline: j.timeline, verification: j.verification,
529
+ hint: '已接收完整原创分镜;封面、渲染、实测校验完成后才会出现在成片列表。' }
530
+ }
428
531
  const stop = () => {
429
- if (!job) return { ok: true, stopped: false, hint: '现在没有在做视频' }
532
+ if (!job || !busy()) return { ok: true, stopped: false, hint: '现在没有在做视频' }
430
533
  const j = job
431
- j.killed = true
432
- try { j.proc?.kill('SIGKILL') } catch { /* 已经没了 */ }
433
- append(j.log, '\n--- 手动停下 ---\n')
434
- j.stage = 'stopped'
435
- const day = j.date
436
- // ⚠️ 必须在 5 秒后才清 —— 不然 stop 刚返回,界面下一次轮询就看不到「停下了」这件事
437
- const t = setTimeout(() => { if (job === j && !j.proc) job = null }, 5_000)
438
- t.unref?.()
439
- say(`🎬 ${day} 的视频手动停了`)
440
- return { ok: true, stopped: true, date: day }
534
+ cancel(j)
535
+ say(`🎬 ${j.date} 的视频手动停了`)
536
+ return { ok: true, stopped: true, date: j.date, id: j.id || '', stage: j.stage,
537
+ stopping: Boolean(j.active || j.proc || j.killProc), production: productionOf(j) }
441
538
  }
442
-
443
539
  return {
444
- status, start, stop, checkDeps, checkCoverDeps,
445
- needsCoverRepair: (date) => isCoverRepair(date),
446
- needsWork: (date) => needsWork(date),
540
+ status, start, render, stop, checkDeps, checkCoverDeps, productions, onSettled,
541
+ productionFileOf: (id) => productionRow(dirs().out, id)?.file || '',
542
+ needsCoverRepair: (date) => isCoverRepair(date), needsWork,
447
543
  list: () => { const d = dirs(); return listPv(d.out, d.legacyDirs || d.legacy || d.out) },
448
544
  paths: () => dirs(),
449
545
  fileOf: (date) => { const d = dirs(); return pvFileOf(d.out, date, d.legacyDirs || d.legacy || d.out) },
450
- /** 🎙 现在用的是什么音色(设置页要显示「真·本鱼原声」还是「edge-tts 顶班」) */
451
- voiceMode: () => {
452
- const g = gsvOf()
453
- return g ? { mode: 'gsv', repo: g.gsvRepo, ref: g.gsvRef } : { mode: 'edge' }
454
- },
546
+ voiceMode: () => { const g = gsvOf(); return g ? { mode: 'gsv', repo: g.gsvRepo, ref: g.gsvRef } : { mode: 'edge' } },
455
547
  }
456
548
  }