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.
- package/AGENTS.md +91 -153
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +51 -0
- package/CONTRIBUTORS.md +3 -7
- package/NOTICE.md +3 -3
- package/README.md +141 -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 +1348 -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 +953 -270
- 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/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
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 {
|
|
25
|
-
import {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
97
|
+
} catch { /* optional legacy directory */ }
|
|
105
98
|
}
|
|
106
|
-
return [...byDate.values()].sort((a, b) => (a.date
|
|
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()
|
|
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
|
|
134
|
-
|
|
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
|
-
*
|
|
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)
|
|
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
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
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 (!
|
|
232
|
-
|
|
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
|
-
|
|
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 = (
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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 (
|
|
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 (
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
append(
|
|
349
|
-
|
|
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
|
-
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
}
|