@pls-compile-owo/fisheye 0.4.2 → 0.7.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/CHANGELOG.md +38 -0
- package/README.md +50 -1
- package/fisheye.mjs +79 -5
- package/lib/audio.mjs +1 -0
- package/lib/batch.mjs +191 -0
- package/lib/bili.mjs +69 -14
- package/lib/frame.mjs +1 -1
- package/lib/i18n.mjs +18 -2
- package/lib/ocr.mjs +386 -0
- package/lib/progress.mjs +140 -0
- package/lib/resume.mjs +378 -0
- package/lib/stages.mjs +290 -26
- package/lib/ytdlp.mjs +393 -0
- package/mcp-video-server.js +261 -0
- package/package.json +3 -2
- package/test/audio.test.mjs +23 -0
- package/test/batch.test.mjs +212 -0
- package/test/bili.test.mjs +70 -0
- package/test/budget.test.mjs +63 -0
- package/test/env.test.mjs +35 -0
- package/test/frame.test.mjs +35 -0
- package/test/i18n.test.mjs +45 -0
- package/test/ocr-sample.png +0 -0
- package/test/ocr.test.mjs +89 -0
- package/test/progress.test.mjs +139 -0
- package/test/resume.test.mjs +232 -0
- package/test/retry.test.mjs +69 -0
- package/test/ytdlp.test.mjs +179 -0
package/lib/resume.mjs
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// 断点续跑:manifest 是唯一真相源。
|
|
2
|
+
//
|
|
3
|
+
// 长视频最贵的两步是「下载」和「抽帧」,而它们都能「重来一半」:
|
|
4
|
+
// · 下载:cache 里的 mp4 还在 → 跳过
|
|
5
|
+
// · 抽帧:out/ 里的拼图还在 → 已抽的图不用重抽,只补缺的
|
|
6
|
+
// · 精读/字幕带同理,用 stages 记录状态即可
|
|
7
|
+
//
|
|
8
|
+
// 不接 CLI、不接 auto:这里只提供纯函数 + 原子写回,由 fisheye.mjs/stages.mjs
|
|
9
|
+
// 在阶段边界调用。接口故意做成「state 进、state 出」,方便链式调用和单测。
|
|
10
|
+
//
|
|
11
|
+
// manifest 的拼图数组兼容两种写法(读时归一化,写回时不改字段名,不破坏旧消费方):
|
|
12
|
+
// 新:state.grids = [{ file, from, to, grid }]
|
|
13
|
+
// 旧:state.chunks = [{ sheet, from, to, grid }] ← stageGrid 目前写的是这个
|
|
14
|
+
// 也可能叫 images / 字符串数组。
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
|
|
18
|
+
// 记住 state 是从哪个目录、哪个 bvid 读出来的(Symbol 键不会被 JSON.stringify 写进 manifest)。
|
|
19
|
+
// 这样 recordStage(state, ...) 就能自己原子写回,调用方不用到处传 outDir/bvid。
|
|
20
|
+
const SRC = Symbol('fisheye.manifest.source')
|
|
21
|
+
|
|
22
|
+
// 挂在 state 上的「来历」:不可枚举,deepEqual / JSON.stringify 都不会被它干扰
|
|
23
|
+
function setSource(state, outDir, bvid) {
|
|
24
|
+
Object.defineProperty(state, SRC, { value: { outDir, bvid }, enumerable: false, writable: true, configurable: true })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 阶段名 → 中文;未知阶段原样显示
|
|
28
|
+
const STAGE_LABEL = {
|
|
29
|
+
download: '下载',
|
|
30
|
+
grids: '抽帧',
|
|
31
|
+
subs: '字幕带',
|
|
32
|
+
refine: '精读',
|
|
33
|
+
audio: '音频转写',
|
|
34
|
+
danmaku: '弹幕',
|
|
35
|
+
ocr: 'OCR',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STATUS_LABEL = {
|
|
39
|
+
done: '已完成',
|
|
40
|
+
running: '进行中',
|
|
41
|
+
failed: '失败',
|
|
42
|
+
skipped: '已跳过',
|
|
43
|
+
pending: '未开始',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const stageLabel = (name) => STAGE_LABEL[name] ?? name
|
|
47
|
+
|
|
48
|
+
/** `out/<bvid>-manifest.json` */
|
|
49
|
+
export function manifestPath(outDir, bvid) {
|
|
50
|
+
return path.join(outDir, `${bvid}-manifest.json`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 读 manifest。不存在 / JSON 坏 / 不是对象 → null(坏文件不该拦住续跑流程,调用方自行决定重跑)。
|
|
55
|
+
* @returns {object|null}
|
|
56
|
+
*/
|
|
57
|
+
export function loadManifestState(outDir, bvid) {
|
|
58
|
+
let raw
|
|
59
|
+
try { raw = fs.readFileSync(manifestPath(outDir, bvid), 'utf8') } catch { return null }
|
|
60
|
+
let state
|
|
61
|
+
try {
|
|
62
|
+
state = JSON.parse(raw.replace(/^\uFEFF/, '')) // 兼容 PowerShell/记事本写的 BOM
|
|
63
|
+
} catch { return null }
|
|
64
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return null
|
|
65
|
+
setSource(state, outDir, bvid)
|
|
66
|
+
return state
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 原子写回:同目录临时文件 → rename。
|
|
71
|
+
* Windows 上 fs.renameSync 是替换语义(MoveFileEx),目标已存在也能覆盖;
|
|
72
|
+
* 写一半断电时正式文件仍是旧的完整版本,不会留下半截 JSON。
|
|
73
|
+
* @returns {string} manifest 路径
|
|
74
|
+
*/
|
|
75
|
+
export function saveManifest(outDir, bvid, state) {
|
|
76
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
|
77
|
+
throw new TypeError('saveManifest: state 必须是对象')
|
|
78
|
+
}
|
|
79
|
+
if (!bvid) throw new TypeError('saveManifest: bvid 不能为空')
|
|
80
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
81
|
+
const file = manifestPath(outDir, bvid)
|
|
82
|
+
const tmp = path.join(outDir, `.${bvid}-manifest.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`)
|
|
83
|
+
try {
|
|
84
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8')
|
|
85
|
+
fs.renameSync(tmp, file)
|
|
86
|
+
} catch (e) {
|
|
87
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
88
|
+
throw e
|
|
89
|
+
}
|
|
90
|
+
setSource(state, outDir, bvid)
|
|
91
|
+
return file
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- manifest 内部结构的归一化 ----
|
|
95
|
+
|
|
96
|
+
const num = (v) => {
|
|
97
|
+
const n = Number(v)
|
|
98
|
+
return Number.isFinite(n) ? n : null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** '01:05' / '1:05:30' / 65 → 秒;非法 → null(不走 util.parseTime:那条路会 process.exit) */
|
|
102
|
+
function parseClock(v) {
|
|
103
|
+
const n = num(v)
|
|
104
|
+
if (n !== null) return n
|
|
105
|
+
const parts = String(v ?? '').trim().split(':').map((s) => Number(s))
|
|
106
|
+
if (parts.length < 2 || parts.length > 3 || parts.some((x) => !Number.isFinite(x) || x < 0)) return null
|
|
107
|
+
return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 拼图数组:grids → images → chunks(不做拷贝,只返回引用) */
|
|
111
|
+
function gridsOf(state) {
|
|
112
|
+
for (const key of ['grids', 'images', 'chunks']) {
|
|
113
|
+
if (Array.isArray(state?.[key])) return state[key]
|
|
114
|
+
}
|
|
115
|
+
return []
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 单张拼图的文件路径 */
|
|
119
|
+
function fileOf(g) {
|
|
120
|
+
if (typeof g === 'string') return g
|
|
121
|
+
if (!g || typeof g !== 'object') return null
|
|
122
|
+
return g.file ?? g.sheet ?? g.path ?? g.image ?? g.sheetPath ?? g.sheetFile ?? null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 单张拼图的时间范围 {from,to};无法解析 → null */
|
|
126
|
+
function rangeOf(g) {
|
|
127
|
+
if (!g || typeof g !== 'object') return null
|
|
128
|
+
const from = parseClock(g.from ?? g.start)
|
|
129
|
+
const to = parseClock(g.to ?? g.end)
|
|
130
|
+
if (from !== null && to !== null) return { from, to }
|
|
131
|
+
if (typeof g.range === 'string') {
|
|
132
|
+
// manifest 里 range 是 '00:00-05:00' 这种展示字符串
|
|
133
|
+
const m = /^\s*([\d:.]+)\s*-\s*([\d:.]+)\s*$/.exec(g.range)
|
|
134
|
+
if (m) {
|
|
135
|
+
const a = parseClock(m[1])
|
|
136
|
+
const b = parseClock(m[2])
|
|
137
|
+
if (a !== null && b !== null) return { from: a, to: b }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function existsNonEmpty(file) {
|
|
144
|
+
try {
|
|
145
|
+
const st = fs.statSync(file)
|
|
146
|
+
return st.isFile() && st.size > 0
|
|
147
|
+
} catch { return false }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function fmtSec(sec) {
|
|
151
|
+
const s = Number(sec)
|
|
152
|
+
if (!Number.isFinite(s) || s < 0) return '?'
|
|
153
|
+
const h = Math.floor(s / 3600)
|
|
154
|
+
const m = Math.floor((s % 3600) / 60)
|
|
155
|
+
const ss = Math.floor(s % 60)
|
|
156
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
157
|
+
return h ? `${h}:${pad(m)}:${pad(ss)}` : `${pad(m)}:${pad(ss)}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 区间补齐默认值:from 缺省 0,to 缺省「片尾」。dur=Infinity 表示时长未知 */
|
|
161
|
+
function normRange(obj, dur) {
|
|
162
|
+
const fromRaw = obj?.from
|
|
163
|
+
const toRaw = obj?.to
|
|
164
|
+
const from = fromRaw === undefined || fromRaw === null ? 0 : num(fromRaw)
|
|
165
|
+
const to = toRaw === undefined || toRaw === null ? dur : num(toRaw)
|
|
166
|
+
return {
|
|
167
|
+
from: from !== null ? from : 0,
|
|
168
|
+
to: to !== null ? to : dur,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sameRange(a, b) {
|
|
173
|
+
const close = (x, y) => (Number.isFinite(x) && Number.isFinite(y) ? Math.abs(x - y) <= 1 : x === y)
|
|
174
|
+
return close(a.from, b.from) && close(a.to, b.to)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rangeText = (r) => `${fmtSec(r.from)}-${Number.isFinite(r.to) ? fmtSec(r.to) : '片尾'}`
|
|
178
|
+
|
|
179
|
+
function intersects(g, r) {
|
|
180
|
+
const gr = rangeOf(g)
|
|
181
|
+
if (!gr) return true // 旧数据看不出范围时不排除,宁可多算一张「可复用」
|
|
182
|
+
const toA = Number.isFinite(r.to) ? r.to : Infinity
|
|
183
|
+
const toB = Number.isFinite(gr.to) ? gr.to : Infinity
|
|
184
|
+
return Math.max(gr.from, r.from) < Math.min(toA, toB)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pick = (...vals) => {
|
|
188
|
+
for (const v of vals) if (v !== undefined && v !== null) return v
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 判断旧 manifest 能不能复用。
|
|
194
|
+
*
|
|
195
|
+
* @param {object|null} state loadManifestState 的返回值
|
|
196
|
+
* @param {{plan?: {budget?:number, density?:number, from?:number, to?:number}}} opts
|
|
197
|
+
* @returns {{resumable: boolean, reason: string, doneGrids: number}}
|
|
198
|
+
*
|
|
199
|
+
* 判定顺序(先便宜的先判):
|
|
200
|
+
* 1. manifest 存在且是对象
|
|
201
|
+
* 2. 计划参数一致:只比调用方明确给出的 budget / density;
|
|
202
|
+
* manifest 没记录就判不可复用(宁可重抽,也别用不同密度的图冒充本次结果)
|
|
203
|
+
* 3. 时间区间一致(容差 1s,manifest 里存的是 toFixed(1))
|
|
204
|
+
* 4. 拼图文件还在:一张都用不了 → 不可复用;部分缺失 → 可复用、只补缺的
|
|
205
|
+
*/
|
|
206
|
+
export function canResume(state, { plan } = {}) {
|
|
207
|
+
const no = (reason, doneGrids = 0) => ({ resumable: false, reason, doneGrids })
|
|
208
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
|
209
|
+
return no('没有可复用的 manifest(这个视频还没跑过)')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const given = plan && typeof plan === 'object' && !Array.isArray(plan) ? plan : {}
|
|
213
|
+
const rec = state.plan && typeof state.plan === 'object' && !Array.isArray(state.plan) ? state.plan : {}
|
|
214
|
+
// 老 manifest 没有 plan 对象时,兼容直接写在顶层的字段
|
|
215
|
+
const recorded = {
|
|
216
|
+
budget: pick(rec.budget, state.budget),
|
|
217
|
+
density: pick(rec.density, state.density),
|
|
218
|
+
from: pick(rec.from, state.range?.from),
|
|
219
|
+
to: pick(rec.to, state.range?.to),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 1) budget / density:undefined / null 表示「调用方不关心这个维度」
|
|
223
|
+
for (const key of ['budget', 'density']) {
|
|
224
|
+
const want = given[key]
|
|
225
|
+
if (want === undefined || want === null) continue
|
|
226
|
+
const had = recorded[key]
|
|
227
|
+
if (had === null) {
|
|
228
|
+
return no(`manifest 里没有记录 ${key},无法确认计划参数是否一致;要复用请重跑或手动补 plan`)
|
|
229
|
+
}
|
|
230
|
+
if (Number(want) !== Number(had)) {
|
|
231
|
+
return no(`计划参数变了:${key} ${had} → ${want}(旧拼图的时间/密度和新计划对不上,抽帧不能跳过)`)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 2) 时间区间
|
|
236
|
+
const hasPlanRange = given.from !== undefined || given.to !== undefined
|
|
237
|
+
const dur = Number.isFinite(Number(state.duration)) ? Number(state.duration) : Infinity
|
|
238
|
+
let wantRange = null
|
|
239
|
+
if (hasPlanRange) {
|
|
240
|
+
wantRange = normRange(given, dur)
|
|
241
|
+
const hadRange = normRange(recorded, dur)
|
|
242
|
+
if (!sameRange(wantRange, hadRange)) {
|
|
243
|
+
return no(`时间区间变了:${rangeText(hadRange)} → ${rangeText(wantRange)}(时间轴不同,旧拼图拼不进这次结果)`)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 3) 拼图文件
|
|
248
|
+
const all = gridsOf(state)
|
|
249
|
+
if (!all.length) return no('manifest 里没有拼图记录(grids/images/chunks 为空),没有可跳过的东西')
|
|
250
|
+
const scoped = wantRange ? all.filter((g) => intersects(g, wantRange)) : all
|
|
251
|
+
if (!scoped.length) return no('计划区间内没有已记录的拼图')
|
|
252
|
+
|
|
253
|
+
const paths = scoped.map(fileOf)
|
|
254
|
+
const usable = paths.filter((f) => f && existsNonEmpty(f)).length
|
|
255
|
+
const missing = scoped.length - usable
|
|
256
|
+
if (!usable) {
|
|
257
|
+
return no(`拼图文件全部缺失(0/${scoped.length}),抽帧需要从头来`, 0)
|
|
258
|
+
}
|
|
259
|
+
if (missing > 0) {
|
|
260
|
+
return {
|
|
261
|
+
resumable: true,
|
|
262
|
+
reason: `可复用 ${usable}/${scoped.length} 张拼图,补抽 ${missing} 张即可`,
|
|
263
|
+
doneGrids: usable,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
resumable: true,
|
|
268
|
+
reason: `拼图齐全(${usable}/${scoped.length}),可跳过抽帧`,
|
|
269
|
+
doneGrids: usable,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function persist(state) {
|
|
274
|
+
const src = state[SRC] ?? (state.outDir && state.bvid ? { outDir: state.outDir, bvid: state.bvid } : null)
|
|
275
|
+
if (!src?.outDir || !src?.bvid) return false
|
|
276
|
+
saveManifest(src.outDir, src.bvid, state)
|
|
277
|
+
return true
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 记录阶段进度,并在能定位到 manifest 时原子写回。
|
|
282
|
+
* @param {object} state
|
|
283
|
+
* @param {string} stage 'download' | 'grids' | 'subs' | 'refine' | ...
|
|
284
|
+
* @param {object|string} [info] 对象(会合并进阶段记录)或字符串状态('done' / 'running')
|
|
285
|
+
* @returns {object} 同一个 state(方便链式调用)
|
|
286
|
+
*/
|
|
287
|
+
export function recordStage(state, stage, info = {}) {
|
|
288
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
|
289
|
+
throw new TypeError('recordStage: state 必须是对象')
|
|
290
|
+
}
|
|
291
|
+
const name = String(stage ?? '').trim()
|
|
292
|
+
if (!name) throw new TypeError('recordStage: stage 必须是非空字符串')
|
|
293
|
+
|
|
294
|
+
const patch = info === null || info === undefined
|
|
295
|
+
? {}
|
|
296
|
+
: typeof info === 'string'
|
|
297
|
+
? { status: info }
|
|
298
|
+
: typeof info === 'object'
|
|
299
|
+
? { ...info }
|
|
300
|
+
: {}
|
|
301
|
+
|
|
302
|
+
if (!state.stages || typeof state.stages !== 'object' || Array.isArray(state.stages)) state.stages = {}
|
|
303
|
+
const prev = state.stages[name] && typeof state.stages[name] === 'object' && !Array.isArray(state.stages[name])
|
|
304
|
+
? state.stages[name]
|
|
305
|
+
: {}
|
|
306
|
+
const status = String(patch.status ?? prev.status ?? 'running')
|
|
307
|
+
const now = new Date().toISOString()
|
|
308
|
+
const next = { ...prev, ...patch, status, at: now }
|
|
309
|
+
if (status === 'done') next.doneAt = now
|
|
310
|
+
state.stages[name] = next
|
|
311
|
+
state.updatedAt = now
|
|
312
|
+
persist(state)
|
|
313
|
+
return state
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 标记阶段完成(等价于 recordStage(state, stage, { ...info, status: 'done' })) */
|
|
317
|
+
export function markDone(state, stage, info = {}) {
|
|
318
|
+
const extra = info && typeof info === 'object' && !Array.isArray(info) ? info : {}
|
|
319
|
+
return recordStage(state, stage, { ...extra, status: 'done' })
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* 人类可读摘要:哪些阶段到哪了、拼图还差几张、什么可以跳过。
|
|
324
|
+
* @returns {string} 多行文本(没有 manifest 时返回一句说明)
|
|
325
|
+
*/
|
|
326
|
+
export function resumeSummary(state) {
|
|
327
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
|
328
|
+
return '没有 manifest:还没有可续跑的进度'
|
|
329
|
+
}
|
|
330
|
+
const dur = Number.isFinite(Number(state.duration)) ? Number(state.duration) : Infinity
|
|
331
|
+
const lines = []
|
|
332
|
+
|
|
333
|
+
const head = [state.bvid ?? '未知视频']
|
|
334
|
+
if (state.title) head.push(String(state.title))
|
|
335
|
+
if (state.range) head.push(`区间 ${rangeText(normRange(state.range, dur))}`)
|
|
336
|
+
lines.push(head.join(' · '))
|
|
337
|
+
|
|
338
|
+
const entries = state.stages && typeof state.stages === 'object' && !Array.isArray(state.stages)
|
|
339
|
+
? Object.entries(state.stages)
|
|
340
|
+
: []
|
|
341
|
+
if (entries.length) {
|
|
342
|
+
const parts = entries.map(([k, v]) => {
|
|
343
|
+
const status = typeof v === 'string' ? v : (v?.status ?? 'unknown')
|
|
344
|
+
return `${stageLabel(k)} ${STATUS_LABEL[status] ?? status}`
|
|
345
|
+
})
|
|
346
|
+
lines.push(`阶段:${parts.join(' · ')}`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const grids = gridsOf(state)
|
|
350
|
+
const files = grids.map(fileOf).filter(Boolean)
|
|
351
|
+
let allGridsOk = false
|
|
352
|
+
if (files.length) {
|
|
353
|
+
const ok = files.filter(existsNonEmpty).length
|
|
354
|
+
allGridsOk = ok === files.length
|
|
355
|
+
lines.push(`拼图:${ok}/${files.length} 张可用${allGridsOk ? '(可跳过抽帧)' : `(缺 ${files.length - ok} 张,需补抽)`}`)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const plan = state.plan && typeof state.plan === 'object' && !Array.isArray(state.plan) ? state.plan : {}
|
|
359
|
+
const bits = []
|
|
360
|
+
const budget = pick(plan.budget, state.budget)
|
|
361
|
+
const density = pick(plan.density, state.density)
|
|
362
|
+
if (budget !== null) bits.push(`budget ${budget}`)
|
|
363
|
+
if (density !== null) bits.push(`density ${density}s`)
|
|
364
|
+
if (bits.length) lines.push(`计划:${bits.join(' · ')}`)
|
|
365
|
+
|
|
366
|
+
const skippable = []
|
|
367
|
+
for (const [k, v] of entries) {
|
|
368
|
+
const status = typeof v === 'string' ? v : v?.status
|
|
369
|
+
if (status === 'done') skippable.push(stageLabel(k))
|
|
370
|
+
}
|
|
371
|
+
if (allGridsOk && !skippable.includes(stageLabel('grids'))) skippable.push(stageLabel('grids'))
|
|
372
|
+
lines.push(`可跳过:${skippable.length ? skippable.join('、') : '无'}`)
|
|
373
|
+
|
|
374
|
+
const stamp = state.updatedAt ?? state.createdAt
|
|
375
|
+
if (stamp) lines.push(`更新:${stamp}`)
|
|
376
|
+
|
|
377
|
+
return lines.join('\n')
|
|
378
|
+
}
|