@pls-compile-owo/fisheye 0.4.2 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ // 进度反馈:长视频抽帧/下载动辄几分钟,不给进度就像卡死。
2
+ //
3
+ // 设计约束:
4
+ // · 默认写 stderr —— stdout 留给机器可读结果(MANIFEST/SHEET/JSON),别混在一起
5
+ // · TTY 单行覆盖(\r),非 TTY(重定向到文件/管道)每次换行,否则日志会变成一坨
6
+ // · --quiet 用 enabled:false 整体静默,但 elapsed() 仍然准确(耗时统计不受影响)
7
+ // · stream 可注入:测试用假 stream 收集输出,不用去 patch process.stderr
8
+
9
+ export const isTTYStream = (stream) => Boolean(stream && stream.isTTY)
10
+
11
+ /** 毫秒 → 人类可读时长:300ms / 1.5s / 2m3s / 1h2m3s */
12
+ export function humanDuration(ms) {
13
+ let n = Number(ms)
14
+ if (!Number.isFinite(n) || n < 0) n = 0
15
+ if (n < 1000) return `${Math.round(n)}ms`
16
+ if (n < 60000) return `${(n / 1000).toFixed(1)}s`
17
+ const totalSec = Math.round(n / 1000)
18
+ const h = Math.floor(totalSec / 3600)
19
+ const m = Math.floor((totalSec % 3600) / 60)
20
+ const s = totalSec % 60
21
+ return h ? `${h}h${m}m${s}s` : `${m}m${s}s`
22
+ }
23
+
24
+ /** 安全写入:终端已关闭(EPIPE)不该炸掉整个抽帧流程 */
25
+ function safeWrite(stream, text) {
26
+ if (typeof stream?.write !== 'function') return
27
+ try { stream.write(text) } catch { /* 忽略写失败 */ }
28
+ }
29
+
30
+ /**
31
+ * 进度条。返回 { tick(n=1), set(done), done(), fail(msg), elapsed() }
32
+ *
33
+ * @param {string} label 前缀,如 '抽帧'
34
+ * @param {number} total 总数;<=0 / 非数字表示「总数未知」,只显示已计数
35
+ * @param {{stream?: {write: Function, isTTY?: boolean}, enabled?: boolean}} opts
36
+ */
37
+ export function createProgress(label, total, { stream = process.stderr, enabled = true } = {}) {
38
+ const t0 = Date.now()
39
+ const rawTotal = Number(total)
40
+ const totalN = Number.isFinite(rawTotal) && rawTotal > 0 ? Math.floor(rawTotal) : 0
41
+ const tty = isTTYStream(stream)
42
+ let done = 0
43
+ let dirty = false // TTY 下屏幕上是否已有一行「没换行」的进度
44
+ let ended = false
45
+
46
+ const clamp = (n) => {
47
+ const v = Number(n)
48
+ if (!Number.isFinite(v) || v < 0) return 0
49
+ return totalN ? Math.min(v, totalN) : v
50
+ }
51
+ const text = () => {
52
+ const base = `[${label}] `
53
+ if (!totalN) return `${base}${done}`
54
+ const pct = Math.min(100, Math.floor((done / totalN) * 100))
55
+ return `${base}${done}/${totalN} (${pct}%)`
56
+ }
57
+ const render = () => {
58
+ if (!enabled) return
59
+ if (tty) {
60
+ safeWrite(stream, `\r${text()}`)
61
+ dirty = true
62
+ } else {
63
+ safeWrite(stream, `${text()}\n`)
64
+ dirty = false
65
+ }
66
+ }
67
+ const closeLine = () => {
68
+ if (dirty) {
69
+ safeWrite(stream, '\n')
70
+ dirty = false
71
+ }
72
+ }
73
+
74
+ const api = {
75
+ tick(n = 1) {
76
+ if (ended) return api
77
+ const step = Number(n)
78
+ done = clamp(done + (Number.isFinite(step) ? step : 1))
79
+ render()
80
+ return api
81
+ },
82
+ set(n) {
83
+ if (ended) return api
84
+ done = clamp(n)
85
+ render()
86
+ return api
87
+ },
88
+ done() {
89
+ if (!ended) {
90
+ done = totalN || done
91
+ if (enabled) {
92
+ render()
93
+ closeLine()
94
+ }
95
+ ended = true
96
+ }
97
+ return api.elapsed()
98
+ },
99
+ fail(msg) {
100
+ if (!ended) {
101
+ if (enabled) {
102
+ if (tty && dirty) {
103
+ // 覆盖当前行,失败原因和停在哪一帧一起显示,方便直接定位
104
+ safeWrite(stream, `\r${text()} 失败:${msg}\n`)
105
+ dirty = false
106
+ } else {
107
+ closeLine()
108
+ safeWrite(stream, `[${label}] 失败:${msg}\n`)
109
+ }
110
+ }
111
+ ended = true
112
+ }
113
+ return api.elapsed()
114
+ },
115
+ elapsed() {
116
+ return Number(((Date.now() - t0) / 1000).toFixed(1))
117
+ },
118
+ }
119
+ return api
120
+ }
121
+
122
+ /**
123
+ * 阶段计时器。stop() 输出 `[label] 用时 3.2s`,返回秒数(重复 stop 只输出一次)。
124
+ * @param {string} label
125
+ * @param {{stream?: {write: Function, isTTY?: boolean}, enabled?: boolean}} opts
126
+ */
127
+ export function stageTimer(label, { stream = process.stderr, enabled = true } = {}) {
128
+ const t0 = Date.now()
129
+ let stopped = false
130
+ return {
131
+ stop() {
132
+ const ms = Date.now() - t0
133
+ if (!stopped) {
134
+ stopped = true
135
+ if (enabled) safeWrite(stream, `[${label}] 用时 ${humanDuration(ms)}\n`)
136
+ }
137
+ return Number((ms / 1000).toFixed(1))
138
+ },
139
+ }
140
+ }
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
+ }