@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/lib/ocr.mjs ADDED
@@ -0,0 +1,386 @@
1
+ // OCR(可选):tesseract 命令行识别画面里的文字(截图标题、PPT、烧进画面的字幕条)
2
+ //
3
+ // 定位:fisheye 的信息源是「官方字幕 → 画面/字幕带 → 弹幕 → 音频」。画面里的文字
4
+ // (弹幕截图、PPT、标题条、烧进画面的字幕)模型只能"看图猜",OCR 能直接给出原文,
5
+ // 让模型对照文本而不是辨认像素。
6
+ // 它是**可选能力**:找不到 tesseract / 语言包一律返回 null,绝不让整个看片流程失败。
7
+ //
8
+ // 设计约束(和项目其他模块一致):
9
+ // · 零 npm 依赖,Node ≥18
10
+ // · 所有子进程用 spawnSync;stdout / stderr 分别重定向到临时文件(沙箱禁管道捕获),
11
+ // 分开落盘是为了不让 stderr 的 "Estimating resolution as ..." 混进正文
12
+ // · Windows 路径全部 path.join,可执行文件补 .exe
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import crypto from 'node:crypto'
17
+ import { spawnSync } from 'node:child_process'
18
+ import { PKG_DIR } from './env.mjs'
19
+
20
+ const IS_WIN = process.platform === 'win32'
21
+ const EXE = (name) => (IS_WIN ? `${name}.exe` : name)
22
+
23
+ // tesseract --version 每次探测都跑一遍太浪费,按 cmd 缓存
24
+ const versionCache = new Map()
25
+
26
+ const stripQuotes = (s) => String(s ?? '').trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
27
+
28
+ function readText(file, limit = 65536) {
29
+ try { return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').slice(0, limit) } catch { return '' }
30
+ }
31
+
32
+ function tmpFile(tag) {
33
+ return path.join(os.tmpdir(), `fisheye-ocr-${tag}-${process.pid}-${crypto.randomUUID().slice(0, 8)}.txt`)
34
+ }
35
+
36
+ /**
37
+ * 跑一个短命令,把 stdout / stderr 分别落到两个临时文件。
38
+ * 沙箱里不能捕获管道,所以不能用 spawnSync 的 'pipe' —— 只能 fd 重定向。
39
+ */
40
+ function capture(cmd, args, timeoutMs = 8000) {
41
+ const outFile = tmpFile('out')
42
+ const errFile = tmpFile('err')
43
+ let fdOut = null
44
+ let fdErr = null
45
+ try {
46
+ fdOut = fs.openSync(outFile, 'w')
47
+ fdErr = fs.openSync(errFile, 'w')
48
+ const r = spawnSync(cmd, args, {
49
+ stdio: ['ignore', fdOut, fdErr],
50
+ timeout: timeoutMs,
51
+ windowsHide: true,
52
+ })
53
+ return {
54
+ ok: r.status === 0,
55
+ status: r.status,
56
+ error: r.error,
57
+ out: readText(outFile),
58
+ err: readText(errFile),
59
+ }
60
+ } catch (e) {
61
+ return { ok: false, status: null, error: e, out: '', err: '' }
62
+ } finally {
63
+ if (fdOut !== null) try { fs.closeSync(fdOut) } catch {}
64
+ if (fdErr !== null) try { fs.closeSync(fdErr) } catch {}
65
+ try { fs.unlinkSync(outFile) } catch {}
66
+ try { fs.unlinkSync(errFile) } catch {}
67
+ }
68
+ }
69
+
70
+ // where.exe / which 的全部命中行(没有命中返回 [])
71
+ function whereAll(name) {
72
+ const r = capture(IS_WIN ? 'where.exe' : 'which', [name], 5000)
73
+ if (r.status !== 0) return []
74
+ return r.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
75
+ }
76
+
77
+ /**
78
+ * 判断一个 where 命中项是否值得 spawn。
79
+ * Windows 上 `where tesseract` 一般命中真 exe;但 PATHEXT 里还有 .cpl/.bat 之类,
80
+ * 直接 spawn 会 ENOENT 或弹控制面板,所以先按扩展名筛掉。
81
+ */
82
+ function spawnable(p) {
83
+ if (!p) return false
84
+ const ext = path.extname(p).toLowerCase()
85
+ if (IS_WIN && ['.cpl', '.dll', '.msi', '.sys', '.scr', '.bat', '.cmd', '.ps1', '.vbs', '.js'].includes(ext)) return false
86
+ const looksPath = p.includes('/') || p.includes('\\')
87
+ if (looksPath && !fs.existsSync(p)) return false
88
+ try {
89
+ if (fs.existsSync(p)) {
90
+ const st = fs.statSync(p)
91
+ if (!st.isFile() || st.size === 0) return false
92
+ }
93
+ } catch { return false }
94
+ return true
95
+ }
96
+
97
+ function isDir(p) {
98
+ try { return fs.statSync(p).isDirectory() } catch { return false }
99
+ }
100
+
101
+ /** tessdata 目录:TESSDATA_PREFIX → exe 同级 tessdata → 标准 share 布局 */
102
+ function tessdataDir(cmd, env = process.env) {
103
+ const cands = []
104
+ const explicit = stripQuotes(env.TESSDATA_PREFIX)
105
+ if (explicit) {
106
+ if (isDir(explicit)) cands.push(explicit)
107
+ // 少数分发把 TESSDATA_PREFIX 指到 "tessdata 的父目录"
108
+ if (isDir(path.join(explicit, 'tessdata'))) cands.push(path.join(explicit, 'tessdata'))
109
+ }
110
+ if (cmd && (cmd.includes('/') || cmd.includes('\\'))) {
111
+ const binDir = path.dirname(cmd)
112
+ cands.push(
113
+ path.join(binDir, 'tessdata'),
114
+ path.join(binDir, '..', 'share', 'tesseract-ocr', 'tessdata'),
115
+ path.join(binDir, '..', 'share', 'tessdata'),
116
+ path.join(binDir, '..', 'tessdata'),
117
+ )
118
+ }
119
+ for (const d of cands) if (isDir(d)) return path.resolve(d)
120
+ return null
121
+ }
122
+
123
+ /** tessdata 里的语言包(* .traineddata 的文件名,去掉扩展名) */
124
+ function listLangs(dir) {
125
+ if (!dir) return []
126
+ try {
127
+ return fs.readdirSync(dir, { withFileTypes: true })
128
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.traineddata'))
129
+ .map((e) => e.name.slice(0, -'.traineddata'.length))
130
+ .sort((a, b) => a.localeCompare(b))
131
+ } catch { return [] }
132
+ }
133
+
134
+ /** 常见安装路径(不含 PATH);顺序按需求固定:surfcore → LOCALAPPDATA → Program Files → 项目 bin */
135
+ function commonTesseractPaths(env = process.env) {
136
+ const home = os.homedir()
137
+ const localApp = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local')
138
+ return [
139
+ path.join(home, '.dsh', 'surfcore', 'tesseract', EXE('tesseract')),
140
+ path.join(home, '.dsh', 'surfcore', 'tesseract', 'bin', EXE('tesseract')),
141
+ path.join(localApp, 'Programs', 'Tesseract-OCR', EXE('tesseract')),
142
+ path.join(localApp, 'Tesseract-OCR', EXE('tesseract')),
143
+ 'C:\\Program Files\\Tesseract-OCR\\tesseract.exe',
144
+ 'C:\\Program Files (x86)\\Tesseract-OCR\\tesseract.exe',
145
+ path.join(PKG_DIR, 'bin', EXE('tesseract')),
146
+ ]
147
+ }
148
+
149
+ // FISHEYE_TESSERACT 可能是 exe 路径 / 安装目录 / PATH 上的命令名
150
+ function resolveEnvCommand(raw, env, notes) {
151
+ const v = stripQuotes(raw)
152
+ if (!v) return null
153
+ if (isDir(v)) {
154
+ const p = path.join(v, EXE('tesseract'))
155
+ if (fs.existsSync(p) && spawnable(p)) return path.resolve(p)
156
+ notes.push(`FISHEYE_TESSERACT=${v} 是目录,但里面没有 ${EXE('tesseract')}`)
157
+ return null
158
+ }
159
+ if (fs.existsSync(v)) {
160
+ if (!spawnable(v)) { notes.push(`FISHEYE_TESSERACT=${v} 不是可执行文件`); return null }
161
+ return path.resolve(v)
162
+ }
163
+ if (path.isAbsolute(v) || v.includes('/') || v.includes('\\')) {
164
+ notes.push(`FISHEYE_TESSERACT 指向的文件不存在:${v}`)
165
+ return null
166
+ }
167
+ const hits = whereAll(v).filter(spawnable)
168
+ if (hits.length) return hits[0]
169
+ notes.push(`FISHEYE_TESSERACT=${v} 在 PATH 上找不到`)
170
+ return null
171
+ }
172
+
173
+ function describe(cmd, source, env) {
174
+ const abs = path.resolve(cmd)
175
+ return {
176
+ cmd: fs.existsSync(abs) ? abs : cmd,
177
+ binDir: path.dirname(fs.existsSync(abs) ? abs : cmd),
178
+ tessdata: tessdataDir(abs, env),
179
+ source,
180
+ }
181
+ }
182
+
183
+ function findTesseractDetailed({ env = process.env } = {}) {
184
+ const e = env ?? {}
185
+ const notes = []
186
+
187
+ // 1) 环境变量:显式指定优先级最高
188
+ if (e.FISHEYE_TESSERACT) {
189
+ const cmd = resolveEnvCommand(e.FISHEYE_TESSERACT, e, notes)
190
+ if (cmd) return { tesseract: describe(cmd, '环境变量 FISHEYE_TESSERACT', e), notes }
191
+ }
192
+
193
+ // 2) PATH 上所有 tesseract 命中项
194
+ for (const cmd of whereAll('tesseract').filter(spawnable)) {
195
+ return { tesseract: describe(cmd, 'PATH', e), notes }
196
+ }
197
+
198
+ // 3) 常见安装路径(用户手动解压时它通常不在 PATH 里)
199
+ for (const cmd of commonTesseractPaths(e)) {
200
+ if (!fs.existsSync(cmd) || !spawnable(cmd)) continue
201
+ return { tesseract: describe(cmd, '常见安装路径', e), notes }
202
+ }
203
+
204
+ return { tesseract: null, notes }
205
+ }
206
+
207
+ /**
208
+ * 探测可用的 tesseract。
209
+ * 探测顺序:FISHEYE_TESSERACT → PATH 上 tesseract → 常见安装路径(~/.dsh/surfcore/tesseract、
210
+ * %LOCALAPPDATA%\Programs\Tesseract-OCR、C:\Program Files\Tesseract-OCR、项目 bin/)。
211
+ * @param {{ env?: NodeJS.ProcessEnv }} [options]
212
+ * @returns {{ cmd: string, binDir: string, tessdata: string|null } | null}
213
+ */
214
+ export function findTesseract({ env } = {}) {
215
+ const r = findTesseractDetailed({ env: env ?? process.env })
216
+ if (!r.tesseract) return null
217
+ const { cmd, binDir, tessdata } = r.tesseract
218
+ return { cmd, binDir, tessdata }
219
+ }
220
+
221
+ // --version 输出形如 "tesseract v5.4.0.20240606" / "tesseract 4.1.1"
222
+ function tesseractVersion(cmd) {
223
+ const key = String(cmd).toLowerCase()
224
+ if (versionCache.has(key)) return versionCache.get(key)
225
+ const r = capture(cmd, ['--version'], 8000)
226
+ const m = `${r.out}\n${r.err}`.match(/tesseract\s+v?([0-9][\w.-]*)/i)
227
+ const v = m ? m[1] : null
228
+ versionCache.set(key, v)
229
+ return v
230
+ }
231
+
232
+ // 请求的语言里有的语言包没有时降级到交集;一个都没有返回 null
233
+ function resolveLang(requested, langs) {
234
+ // osd 只做方向检测,不是识别语言,混进 -l 会让 tesseract 输出空页
235
+ const want = String(requested ?? '').split('+').map((s) => s.trim()).filter(Boolean)
236
+ .filter((l) => l.toLowerCase() !== 'osd')
237
+ if (!want.length) return null
238
+ if (!langs.length) return want.join('+') // 语言包目录没探到,交给 tesseract 自己找
239
+ const have = new Set(langs.map((l) => l.toLowerCase()))
240
+ const kept = want.filter((l) => have.has(l.toLowerCase()))
241
+ return kept.length ? kept.join('+') : null
242
+ }
243
+
244
+ // chi_sim 会在每个汉字之间插一个空格("你 好 世 界"),合并回"你好世界";
245
+ // 只处理「汉字 + 空格 + 汉字」这一种相邻关系,不会动英文单词之间和中文与数字之间的空格
246
+ const CJK = '\\u3400-\\u4dbf\\u4e00-\\u9fff\\u3005-\\u303f\\uff00-\\uffef'
247
+ const CJK_GAP = new RegExp(`([${CJK}])[ \\t]+(?=[${CJK}])`, 'g')
248
+
249
+ function normalizeText(raw) {
250
+ return String(raw ?? '')
251
+ .replace(/\u000c/g, '\n') // tesseract 每页结尾的 form feed
252
+ .replace(/^\uFEFF/, '')
253
+ .split(/\r?\n/)
254
+ .map((l) => l.trim())
255
+ .join('\n')
256
+ .replace(CJK_GAP, '$1')
257
+ .replace(/\n{3,}/g, '\n\n')
258
+ .trim()
259
+ }
260
+
261
+ /**
262
+ * 能力描述(给 CLI / selftest 打印用)。
263
+ * available 为 true 才表示「现在真的能做 OCR」:tesseract + 至少一个语言包。
264
+ * @param {{ env?: NodeJS.ProcessEnv, tesseract?: object }} [options]
265
+ * @returns {{ available: boolean, reason: string, summary: string, hint: string|null,
266
+ * cmd: string|null, binDir: string|null, langs: string[],
267
+ * tessdata: string|null, version: string|null, notes: string[] }}
268
+ */
269
+ export function ocrCaps(options = {}) {
270
+ const env = options.env ?? process.env
271
+ try {
272
+ const detail = options.tesseract
273
+ ? { tesseract: { ...options.tesseract, source: 'opts.tesseract' }, notes: [] }
274
+ : findTesseractDetailed({ env })
275
+ const t = detail.tesseract
276
+ const langs = t ? listLangs(t.tessdata) : []
277
+ const hasLang = langs.some((l) => l.toLowerCase() !== 'osd')
278
+ const version = t ? tesseractVersion(t.cmd) : null
279
+ const caps = {
280
+ available: false,
281
+ reason: '',
282
+ summary: '',
283
+ hint: null,
284
+ cmd: t?.cmd ?? null,
285
+ binDir: t?.binDir ?? null,
286
+ langs,
287
+ tessdata: t?.tessdata ?? null,
288
+ version,
289
+ notes: detail.notes ?? [],
290
+ }
291
+
292
+ if (!t) {
293
+ caps.reason = '未找到 tesseract 可执行文件(已试 FISHEYE_TESSERACT / PATH / 常见安装路径)'
294
+ caps.hint = '装 tesseract 到 ~/.dsh/surfcore/tesseract,或把 tesseract.exe 放进 PATH;'
295
+ + '中文识别还需要 tessdata/chi_sim.traineddata'
296
+ } else if (!hasLang) {
297
+ caps.reason = `找到 ${path.basename(t.cmd)},但 tessdata 里没有可用的语言包(只有 osd 不算)`
298
+ caps.hint = '把 eng.traineddata / chi_sim.traineddata 放进 tessdata 目录(或用 TESSDATA_PREFIX 指定)'
299
+ } else {
300
+ caps.available = true
301
+ const bits = [
302
+ `tesseract ${version ? `v${version}` : path.basename(t.cmd)}`,
303
+ `语言包 ${langs.join('/')}`,
304
+ ]
305
+ if (t.source) bits.push(t.source)
306
+ caps.reason = bits.join(',')
307
+ if (!langs.some((l) => l.toLowerCase() === 'chi_sim')) {
308
+ caps.hint = '没有 chi_sim 语言包,中文画面只能识别英文/数字;把 chi_sim.traineddata 放进 tessdata'
309
+ }
310
+ }
311
+
312
+ caps.summary = `OCR:${caps.available ? '可用' : '不可用'} —— ${caps.reason}`
313
+ return caps
314
+ } catch (e) {
315
+ return {
316
+ available: false,
317
+ reason: `能力探测异常:${String(e?.message ?? e).slice(0, 120)}`,
318
+ summary: 'OCR:不可用 —— 能力探测异常',
319
+ hint: null,
320
+ cmd: null,
321
+ binDir: null,
322
+ langs: [],
323
+ tessdata: null,
324
+ version: null,
325
+ notes: [],
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * 识别一张图片里的文字(可选能力,失败不抛异常)。
332
+ *
333
+ * @param {string} imagePath 图片路径(png/jpg/bmp/tiff/webp 等,tesseract 支持的格式)
334
+ * @param {{ lang?: string, timeoutMs?: number, env?: NodeJS.ProcessEnv,
335
+ * tesseract?: object, quiet?: boolean }} [options]
336
+ * @returns {{ text: string, cmd: string, lang: string } | null}
337
+ */
338
+ export function ocrImage(imagePath, { lang = 'chi_sim+eng', timeoutMs = 30000, env = process.env, tesseract, quiet = false } = {}) {
339
+ const warn = (msg) => { if (!quiet) console.warn(`[OCR] ${msg}`) }
340
+
341
+ try {
342
+ const file = imagePath ? path.resolve(String(imagePath)) : ''
343
+ if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
344
+ warn(`找不到图片:${imagePath ?? '(空)'}`)
345
+ return null
346
+ }
347
+
348
+ const t = tesseract ?? findTesseract({ env })
349
+ if (!t) {
350
+ warn('没有可用的 tesseract,跳过 OCR(装 tesseract 或设 FISHEYE_TESSERACT)')
351
+ return null
352
+ }
353
+
354
+ const langs = listLangs(t.tessdata)
355
+ const useLang = resolveLang(lang, langs)
356
+ if (!useLang) {
357
+ warn(`tessdata 里没有 ${lang} 对应的语言包(现有:${langs.join('/') || '无'})`)
358
+ return null
359
+ }
360
+ if (useLang !== lang) warn(`语言包不全:${lang} → 改用 ${useLang}`)
361
+
362
+ // tesseract <img> stdout [--tessdata-dir <dir>] -l <lang>
363
+ const args = [file, 'stdout']
364
+ const tessdata = t.tessdata ? path.resolve(t.tessdata) : null
365
+ if (tessdata) args.push('--tessdata-dir', tessdata)
366
+ args.push('-l', useLang)
367
+
368
+ const r = capture(t.cmd, args, timeoutMs)
369
+ if (!r.ok) {
370
+ const why = r.error?.code === 'ETIMEDOUT' ? '超时' : (r.error?.code ?? `退出码 ${r.status}`)
371
+ const detail = (r.err || r.out).trim().split(/\r?\n/)[0]?.slice(0, 160) ?? ''
372
+ warn(`tesseract 执行失败(${why})${detail ? `:${detail}` : ''}`)
373
+ return null
374
+ }
375
+
376
+ const text = normalizeText(r.out)
377
+ if (!text) {
378
+ warn('识别结果为空(画面里可能没有可读文字)')
379
+ return null
380
+ }
381
+ return { text, cmd: t.cmd, lang: useLang }
382
+ } catch (e) {
383
+ warn(`OCR 异常:${String(e?.message ?? e).split('\n')[0].slice(0, 120)}`)
384
+ return null
385
+ }
386
+ }
@@ -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
+ }