@pls-compile-owo/fisheye 0.3.0 → 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/stages.mjs CHANGED
@@ -5,11 +5,17 @@ import path from 'node:path'
5
5
  import crypto from 'node:crypto'
6
6
  import { execFileSync, spawnSync } from 'node:child_process'
7
7
  import { spamReason, filterDanmaku } from '../danmaku-filter.mjs'
8
- import { PKG_DIR, WS, CACHE, OUT, CFG_FILE, LOCAL_CFG, onPath, resolveTool, FFMPEG, PYTHON, CRED } from './env.mjs'
8
+ import { PKG_DIR, WS, CACHE, OUT, CFG_FILE, LOCAL_CFG, onPath, resolveTool, FFMPEG, PYTHON, PYTHON_RES, CRED } from './env.mjs'
9
9
  import { fmt, fsec, jpegSize, fail, num, gridStr, parseTime, pageNum } from './util.mjs'
10
10
  import { UA, cred, H, getView, MIXIN_TAB, keyOf, clean, getOfficialSubs, looksLikeMp4, ensureMp4, getDanmaku } from './bili.mjs'
11
11
  import { detectScenes, subtractAds, pickTimes, snapSubsTimes, detectBand, grabFrame, runCapture } from './frame.mjs'
12
+ import { audioCaps, transcribeAudio } from './audio.mjs'
13
+ import { ocrCaps, ocrImage } from './ocr.mjs'
14
+ import { ytdlpCaps, downloadVideo } from './ytdlp.mjs'
15
+ import { parseBatchList, runBatch, formatBatchReport } from './batch.mjs'
16
+ import { loadManifestState, canResume, recordStage } from './resume.mjs'
12
17
  import { AD_SEGMENT_SIGNAL, detectAdSegments, summarizeDanmaku } from './danmaku.mjs'
18
+ import { estimateTokens, tokensPerUnit, modelFactor, COST } from './budget.mjs'
13
19
 
14
20
  export async function stageGrid(bvid, chunkSec, grid, pre = {}) {
15
21
  const pageNo = pre.page ?? 1
@@ -276,7 +282,11 @@ export async function stageSubs(bvid, count, bandArg, pre = {}) {
276
282
  // 平衡原则:先保「看懂所需的最低帧密度」,再在剩余预算里加张数;
277
283
  // 预算不够时诚实降级并报告,而不是悄悄变稀。
278
284
 
279
- export const COST = { grid: 1200, subs: 1000, fine: 1300 } // 单张成本估算(token)
285
+ // 单张成本不再写死:统一走 lib/budget.mjs 的 estimateTokens(kind, count)。
286
+ // 默认基准仍是 grid 1200 / subs 1000 / fine 1300(与旧版完全一致),
287
+ // 但支持 FISHEYE_TOKENS_PER_* 覆盖与 FISHEYE_TOKEN_MODEL 模型系数。
288
+ // COST 保留导出只为兼容旧的外部引用,内部不再直接乘它(它看不到环境变量)。
289
+ export { COST }
280
290
 
281
291
  export const MAX_GRIDS = 12 // 拼图张数硬上限,防长视频失控
282
292
 
@@ -312,7 +322,7 @@ export async function stageSelftest(opts = {}) {
312
322
  else {
313
323
  const r = runCapture(PYTHON, ['--version'], 10000)
314
324
  if (r.ok) rec('OK', 'python', r.out.trim())
315
- else rec('!!', 'python', `无法执行(来源 ${PYTHON_RES?.from ?? '?'}):${r.err.slice(0, 50)}`)
325
+ else rec('--', 'python', `无法执行(${PYTHON_RES?.from ?? '?'},可能是 Windows 商店占位符)——字幕带功能降级`)
316
326
  }
317
327
 
318
328
  // 4) analyze_subs.py
@@ -356,6 +366,18 @@ export async function stageSelftest(opts = {}) {
356
366
  const subs = await getOfficialSubs(testBv, cid, null)
357
367
  rec(subs ? 'OK' : '--', '官方字幕 API', subs ? `${subs.quality} / ${subs.lines} 行 / ${subs.chars} 字` : '这条视频无字幕轨(不影响,会用字幕带或画面)')
358
368
  } catch (e) { rec('!!', '官方字幕 API', String(e.message).slice(0, 50)) }
369
+ try {
370
+ const audio = audioCaps()
371
+ rec(audio.available ? 'OK' : '--', '音频转写', audio.summary)
372
+ } catch (e) { rec('!!', '音频转写', String(e.message).slice(0, 50)) }
373
+ try {
374
+ const ocr = ocrCaps()
375
+ rec(ocr.available ? 'OK' : '--', 'OCR', ocr.summary)
376
+ } catch (e) { rec('!!', 'OCR', String(e.message).slice(0, 50)) }
377
+ try {
378
+ const yt = ytdlpCaps()
379
+ rec(yt.available ? 'OK' : '--', 'yt-dlp', yt.summary)
380
+ } catch (e) { rec('!!', 'yt-dlp', String(e.message).slice(0, 50)) }
359
381
  } else {
360
382
  rec('--', '弹幕/字幕 API', '视频 API 不可用,跳过')
361
383
  }
@@ -425,6 +447,21 @@ export async function stageSelftest(opts = {}) {
425
447
  }
426
448
 
427
449
  export async function stageAuto(bvid, budget, opts = {}) {
450
+ // 断点:out/<BV>-manifest.json 存在且计划参数一致时可复用
451
+ // --resume 跳过已完成的抽帧阶段;--force 强制重跑;默认只提示
452
+ let prevManifest = null
453
+ let resumeInfo = null
454
+ try {
455
+ const prev = loadManifestState(OUT, bvid)
456
+ if (prev && !opts.force) {
457
+ resumeInfo = canResume(prev, { plan: { budget, density: opts.density ?? null, from: opts.from ?? null, to: opts.to ?? null } })
458
+ if (resumeInfo.resumable) {
459
+ prevManifest = prev
460
+ console.log('[断点] 检测到可复用产物:' + resumeInfo.reason +
461
+ (opts.resume === true ? '(--resume:跳过抽帧)' : '(加 --resume 跳过抽帧 / --force 强制重跑)'))
462
+ }
463
+ }
464
+ } catch {}
428
465
  const pageNo = opts.page ?? 1
429
466
  const mp4 = await ensureMp4(bvid, pageNo)
430
467
  const view = await getView(bvid)
@@ -452,16 +489,21 @@ export async function stageAuto(bvid, budget, opts = {}) {
452
489
  if (range) console.log(`[区间] 只处理 ${fmt(range.from)}-${fmt(range.to)}(${duration.toFixed(0)}s / 全片 ${fullDuration}s)`)
453
490
  if (pageCount > 1 && pageNo === 1) console.log(`[提示] 多P 视频,本次只处理 P1;要处理别的分P 用 --p N`)
454
491
 
455
- // 拼图 token 按实际画面比例修正:COST.grid 以 1920x1080 拼图为基准,
492
+ // 拼图 token 按实际画面比例修正:estimateTokens('grid', 1) 以 16:9(约 1920x1080)拼图为基准,
456
493
  // 方形/竖屏视频的拼图面积大得多(1920x1920 贵 78%,竖屏贵 3 倍+),不算清楚会误导预算决策
457
494
  const dim = view.dimension ?? {}
458
495
  const vw = Number(dim.width) || 1280
459
496
  const vh = Number(dim.height) || 720
460
497
  const cellH = Math.round((640 * vh) / Math.max(vw, 1))
461
498
  const sheetPx = 640 * 3 * (cellH * 3)
462
- const gridCost = Math.max(300, Math.round((COST.grid * sheetPx) / (1920 * 1080)))
499
+ const gridBase = estimateTokens('grid', 1)
500
+ const gridCost = Math.max(300, Math.round((gridBase * sheetPx) / (1920 * 1080)))
501
+ const factor = modelFactor()
502
+ if (factor !== 1) {
503
+ console.log(`[预算] 模型系数 ×${factor}(FISHEYE_TOKEN_MODEL,按该 tokenizer 的图像成本修正)`)
504
+ }
463
505
  if (Math.abs(sheetPx / (1920 * 1080) - 1) > 0.25) {
464
- console.log(`[比例] 画面 ${vw}x${vh} → 拼图 ${640 * 3}x${cellH * 3},单张约 ${gridCost} token(基准 1200 已按面积修正)`)
506
+ console.log(`[比例] 画面 ${vw}x${vh} → 拼图 ${640 * 3}x${cellH * 3},单张约 ${gridCost} token(基准 ${gridBase} 已按面积修正)`)
465
507
  }
466
508
 
467
509
  // 1. 内容变化率:只统计所处理区间内的显著变化(score≥0.3)
@@ -477,6 +519,7 @@ export async function stageAuto(bvid, budget, opts = {}) {
477
519
  const official = officialRaw?.quality === 'ok' ? officialRaw : null
478
520
  let bandResult = null
479
521
  let hasSubs = false
522
+ let transcript = '' // --audio 转写出的台词文本(有它就不再烧字幕带)
480
523
  if (official) {
481
524
  console.log(`[字幕] 官方${official.lanDoc ?? ''} ${official.lines} 行 / ${official.chars} 字 ≈ ${official.chars} token → 跳过字幕带`)
482
525
  console.log(` 文本: ${official.txt}`)
@@ -484,27 +527,52 @@ export async function stageAuto(bvid, budget, opts = {}) {
484
527
  console.log(` ⚠️ 字幕较长(${official.chars} 字 ≈ ${official.chars} token),建议只读关心的时段而不是全文`)
485
528
  }
486
529
  for (const line of official.head.slice(0, 3)) console.log(` ${line}`)
530
+ if (official.translated) {
531
+ console.log(`[字幕] 中文翻译 ${official.translated.lines} 行 / ${official.translated.chars} 字`)
532
+ }
487
533
  } else {
488
534
  if (officialRaw) {
489
535
  console.log(`[字幕] 官方字幕质量差(平均有效 ${officialRaw.avgEff} 字/行、去重率 ${officialRaw.uniqueRatio})——疑为音乐/音效噪声,不采用`)
490
536
  }
491
- // 传 fullDuration 而不是区间长度:字幕位置是视频级属性,采样必须跨全片
492
- bandResult = detectBand(mp4, fullDuration)
493
- // 置信度门槛:抖音式动态字幕(位置漂移 / 不是每帧都有)检测出来的「带」其实是画面内容,
494
- // 拼出来一堆非字幕行。低于 0.5 就不用字幕带,只靠画面拼图。
495
- const conf = bandResult?.confidence
496
- const confOk = conf === undefined || conf >= 0.5
497
- hasSubs = !!(bandResult?.ok && bandResult.bandRatio >= 0.015 && bandResult.bandRatio <= 0.35 && confOk)
498
- if (bandResult?.ok && !confOk) {
499
- console.log(`[字幕] 检出疑似字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%)但置信度仅 ${Number(conf).toFixed(2)}`)
500
- console.log(` —— 字幕位置漂移或不是每帧都有(抖音式动态字幕常见),跳过字幕带,只靠画面`)
501
- } else {
502
- console.log(`[字幕] ${hasSubs
503
- ? `无可用官方字幕,检出烧录字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%,置信度 ${Number(conf).toFixed(2)})→ 字幕带 1 张`
504
- : '无可用字幕 → 只靠画面'}`)
537
+ if (opts.audio === true) {
538
+ const caps = audioCaps()
539
+ console.log(`[音频] ${caps.summary}`)
540
+ if (!caps.available) {
541
+ if (caps.hint) console.log(` ${caps.hint}`)
542
+ console.log('[音频] 不可用 → 继续原逻辑(字幕带兜底)')
543
+ } else {
544
+ const tr = await transcribeAudio(mp4, OUT, { lang: opts.audioLang ?? 'zh', quiet: false })
545
+ const text = String(tr?.text ?? '').trim()
546
+ if (text) {
547
+ transcript = text
548
+ const txtFile = path.join(OUT, `${bvid}-audio-transcript.txt`)
549
+ fs.writeFileSync(txtFile, text, 'utf8')
550
+ console.log(`[音频] 转写成功:${text.length} 字(≈ ${text.length} token)→ ${txtFile}`)
551
+ console.log(` 文本: ${text.slice(0, 300)}`)
552
+ hasSubs = true // 台词文本已在手,不必再烧字幕带
553
+ } else {
554
+ console.log('[音频] 转写失败或结果为空 → 继续原逻辑(字幕带兜底)')
555
+ }
556
+ }
557
+ }
558
+ if (!transcript) {
559
+ // 传 fullDuration 而不是区间长度:字幕位置是视频级属性,采样必须跨全片
560
+ bandResult = detectBand(mp4, fullDuration)
561
+ // 置信度门槛:抖音式动态字幕(位置漂移 / 不是每帧都有)检测出来的「带」其实是画面内容,
562
+ // 拼出来一堆非字幕行。低于 0.5 就不用字幕带,只靠画面拼图。
563
+ const conf = bandResult?.confidence
564
+ const confOk = conf === undefined || conf >= 0.5
565
+ hasSubs = !!(bandResult?.ok && bandResult.bandRatio >= 0.015 && bandResult.bandRatio <= 0.35 && confOk)
566
+ if (bandResult?.ok && !confOk) {
567
+ console.log(`[字幕] 检出疑似字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%)但置信度仅 ${Number(conf).toFixed(2)}`)
568
+ console.log(` —— 字幕位置漂移或不是每帧都有(抖音式动态字幕常见),跳过字幕带,只靠画面`)
569
+ } else {
570
+ console.log(`[字幕] ${hasSubs
571
+ ? `无可用官方字幕,检出烧录字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%,置信度 ${Number(conf).toFixed(2)})→ 字幕带 1 张`
572
+ : '无可用字幕 → 只靠画面'}`)
573
+ }
574
+ }
505
575
  }
506
- }
507
-
508
576
  // 2.5 弹幕(第三个信息源:观众反应)
509
577
  // 不参与「能不能看懂」的判定,只用来找高光——观众在哪一段最激动,那里通常最值得精读
510
578
  const danmakuRaw = await getDanmaku(cid, range)
@@ -551,10 +619,11 @@ export async function stageAuto(bvid, budget, opts = {}) {
551
619
  const framesNeeded = Math.ceil(duration / maxGap)
552
620
  const minGrids = Math.max(1, Math.ceil(framesNeeded / 9))
553
621
 
554
- const subsCost = official ? Math.round(official.chars) : hasSubs ? COST.subs : 0
622
+ const subsCost = official ? Math.round(official.chars) : transcript ? transcript.length : hasSubs ? estimateTokens('subs', 1) : 0
555
623
  const subsRows = duration <= 180 ? 14 : duration <= 600 ? 20 : 24
556
624
  const minFine = (official || hasSubs) ? 2 : 3 // 字幕已在手,精读需求自然变少
557
- const fixedCost = subsCost + minFine * COST.fine
625
+ const fineReserve = estimateTokens('fine', minFine)
626
+ const fixedCost = subsCost + fineReserve
558
627
  const maxGridsByBudget = Math.max(1, Math.floor((budget - fixedCost) / gridCost))
559
628
 
560
629
  let gridCount = minGrids
@@ -573,10 +642,10 @@ export async function stageAuto(bvid, budget, opts = {}) {
573
642
  const actualGap = duration / (gridCount * 9)
574
643
 
575
644
  // 5. 报告
576
- const estTotal = subsCost + gridCount * gridCost + minFine * COST.fine
645
+ const estTotal = subsCost + gridCount * gridCost + fineReserve
577
646
  console.log(`[张数] 密度需要 ${minGrids} 张,预算允许 ${maxGridsByBudget} 张 → 采用 ${gridCount} 张`)
578
647
  console.log(`[参数] chunk=${chunk.toFixed(1)}s(每张覆盖),实际平均 ${actualGap.toFixed(1)}s/帧`)
579
- console.log(`[预算] ${budget} token ≈ 字幕带 ${subsCost} + 拼图 ${gridCount * gridCost} + 精读预留 ${minFine * COST.fine} = ${estTotal}`)
648
+ console.log(`[预算] ${budget} token ≈ 字幕带 ${subsCost} + 拼图 ${gridCount * gridCost} + 精读预留 ${fineReserve} = ${estTotal}`)
580
649
  if (estTotal > budget) {
581
650
  console.log(`[警告] 最低配置已要 ${estTotal} token,超出预算 ${budget}(字幕带和精读预留不可再砍),将超支运行`)
582
651
  }
@@ -588,13 +657,19 @@ export async function stageAuto(bvid, budget, opts = {}) {
588
657
  const pre = {
589
658
  mp4, view, scenes, bandResult, subsSheet: hasSubs, range, page: pageNo,
590
659
  subsCovered: !!(official || hasSubs), // 字幕信息是否已被覆盖(官方文本或烧录字幕带)
591
- subsSource: official ? 'official' : hasSubs ? 'burned-in' : null,
660
+ subsSource: official ? 'official' : transcript ? 'audio' : hasSubs ? 'burned-in' : null,
592
661
  danmakuHot: dmHot, // 写进 manifest,让 refine 也能用弹幕热区挑点
593
662
  danmakuTotal: danmaku?.length ?? 0,
594
663
  adSegments: adSegs, // 视频内疑似广告段(精读会避开 high 置信度的)
595
664
  useDanmakuFrames: opts.useDanmakuFrames !== false, // 弹幕热区是否也参与铺网取帧(默认是)
596
665
  }
597
- const manifest = await stageGrid(bvid, chunk, '3x3', pre)
666
+ let manifest
667
+ if (opts.resume === true && prevManifest && resumeInfo?.resumable) {
668
+ manifest = prevManifest
669
+ console.log(`[断点] 复用已有拼图(${resumeInfo.doneGrids} 张),跳过抽帧`)
670
+ } else {
671
+ manifest = await stageGrid(bvid, chunk, '3x3', pre)
672
+ }
598
673
  if (hasSubs) await stageSubs(bvid, subsRows, 'auto', { mp4, view, bandResult, scenes, range, page: pageNo })
599
674
 
600
675
  // 7. 精读建议 = 几何规则(画面变化剧烈)+ 弹幕热区(观众反应最激烈)
@@ -633,7 +708,14 @@ export async function stageAuto(bvid, budget, opts = {}) {
633
708
  } else {
634
709
  console.log(`\n[精读建议] 两个信号都没命中;读图后如需精读:node fisheye.mjs refine ${bvid} --template`)
635
710
  }
636
- return { gridCount, chunk, actualGap, maxGap, hasSubs, estTotal, notes, danmaku: danmaku?.length ?? 0, top: suggestions }
711
+ try {
712
+ const st = loadManifestState(OUT, bvid)
713
+ if (st) {
714
+ st.plan = { budget, density: opts.density ?? null, from: opts.from ?? null, to: opts.to ?? null }
715
+ recordStage(st, 'auto', { status: 'done', gridCount, hasSubs, audio: Boolean(transcript) })
716
+ }
717
+ } catch {}
718
+ return { gridCount, chunk, actualGap, maxGap, hasSubs, audio: Boolean(transcript), estTotal, notes, danmaku: danmaku?.length ?? 0, top: suggestions }
637
719
  }
638
720
 
639
721
  // refine:决定哪些格需要精读,然后自动截原分辨率单帧
@@ -775,12 +857,12 @@ export async function stageRefine(bvid, opts) {
775
857
  console.log(`\n[精读] 选中 ${picks.length} 个时间点:`)
776
858
  for (const p of picks) console.log(` ${p.ts} ${p.source} —— ${p.reason}`)
777
859
  if (picks.length > 8) {
778
- console.warn(`[警告] ${picks.length} 帧 ≈ ${picks.length * COST.fine} token,建议先只留真正看不清的几帧再跑`)
860
+ console.warn(`[警告] ${picks.length} 帧 ≈ ${estimateTokens('fine', picks.length)} token,建议先只留真正看不清的几帧再跑`)
779
861
  }
780
862
  console.log('')
781
863
  const grabbed = await stageGrab(bvid, picks.map((p) => p.t).join(','), { page: manifest.page ?? 1 })
782
- const est = picks.length * COST.fine
783
- console.log(`\n精读成本 ≈ ${est} token(${picks.length} 帧 × ${COST.fine})`)
864
+ const est = estimateTokens('fine', picks.length)
865
+ console.log(`\n精读成本 ≈ ${est} token(${picks.length} 帧 × ${tokensPerUnit('fine')})`)
784
866
  return { picks, grabbed }
785
867
  }
786
868
 
@@ -818,6 +900,159 @@ export async function stageGrab(bvid, timeStr, opts = {}) {
818
900
 
819
901
  // 本地视频:不依赖 B站,直接用 ffmpeg 抽帧拼图(fisheye local <文件>)
820
902
 
903
+ export function cleanTargets(target = 'all') {
904
+ const want = String(target ?? 'all').trim().toLowerCase()
905
+ if (!['cache', 'out', 'all'].includes(want)) {
906
+ throw new Error(`--target 只能是 cache / out / all,收到:${JSON.stringify(target)}`)
907
+ }
908
+ const dirs = []
909
+ if (want === 'cache' || want === 'all') dirs.push({ name: 'cache', dir: CACHE })
910
+ if (want === 'out' || want === 'all') dirs.push({ name: 'out', dir: OUT })
911
+ return dirs
912
+ }
913
+
914
+ // 递归统计文件数与总大小(不跟符号链接:链接按 0 字节计,避免统计到外部大文件)
915
+ function dirStat(root) {
916
+ let files = 0
917
+ let bytes = 0
918
+ const walk = (dir) => {
919
+ let ents = []
920
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
921
+ for (const e of ents) {
922
+ const p = path.join(dir, e.name)
923
+ if (e.isDirectory()) walk(p)
924
+ else if (e.isFile()) {
925
+ files++
926
+ try { bytes += fs.statSync(p).size } catch { /* 读不到就按 0 计 */ }
927
+ }
928
+ }
929
+ }
930
+ walk(root)
931
+ return { files, bytes }
932
+ }
933
+
934
+ function humanSize(bytes) {
935
+ const n = Number(bytes) || 0
936
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} GB`
937
+ if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(2)} MB`
938
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`
939
+ return `${n} B`
940
+ }
941
+
942
+ // dry-run 时列出实际会被删掉的文件(最多 20 个/目录,避免长视频跑完刷屏几千行)
943
+ function listEntries(root, max = 20) {
944
+ const out = []
945
+ const walk = (dir, depth) => {
946
+ if (out.length >= max) return
947
+ let ents = []
948
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
949
+ ents.sort((a, b) => a.name.localeCompare(b.name))
950
+ for (const e of ents) {
951
+ if (out.length >= max) return
952
+ const p = path.join(dir, e.name)
953
+ const rel = path.relative(root, p)
954
+ if (e.isDirectory()) walk(p, depth + 1)
955
+ else if (e.isFile()) {
956
+ let size = 0
957
+ try { size = fs.statSync(p).size } catch {}
958
+ out.push({ rel, size })
959
+ }
960
+ }
961
+ }
962
+ walk(root, 0)
963
+ return out
964
+ }
965
+
966
+
967
+ export async function stageClean(opts = {}) {
968
+ const target = String(opts.target ?? 'all').trim().toLowerCase()
969
+ const yes = opts.yes === true
970
+ const dirs = cleanTargets(target) // 先校验,非法 target 直接抛错
971
+ console.log(`\n=== 清理(target=${target}${yes ? ',--yes 实际删除' : ',dry-run'})===`)
972
+
973
+ const plans = []
974
+ for (const d of dirs) {
975
+ const exists = fs.existsSync(d.dir)
976
+ if (!exists) {
977
+ console.log(`[${d.name}] 目录不存在,跳过:${d.dir}`)
978
+ continue
979
+ }
980
+ const st = dirStat(d.dir)
981
+ plans.push({ ...d, ...st })
982
+ if (!st.files) {
983
+ console.log(`[${d.name}] 空目录:${d.dir}`)
984
+ continue
985
+ }
986
+ console.log(`[${d.name}] ${st.files} 个文件 / ${humanSize(st.bytes)} → ${d.dir}`)
987
+ for (const e of listEntries(d.dir)) {
988
+ console.log(` ${e.rel} ${humanSize(e.size)}`)
989
+ }
990
+ if (st.files > 20) console.log(` …还有 ${st.files - 20} 个文件未列出`)
991
+ }
992
+
993
+ const totalFiles = plans.reduce((s, p) => s + p.files, 0)
994
+ const totalBytes = plans.reduce((s, p) => s + p.bytes, 0)
995
+ if (!plans.length) {
996
+ console.log('\n没有可清理的内容。')
997
+ return { target, deleted: false, files: 0, bytes: 0 }
998
+ }
999
+ if (!totalFiles) {
1000
+ console.log('\n目录都是空的,没有可清理的内容。')
1001
+ return { target, deleted: false, files: 0, bytes: 0 }
1002
+ }
1003
+
1004
+ if (!yes) {
1005
+ console.log(`\n将删除 ${totalFiles} 个文件 / ${humanSize(totalBytes)}(dry-run,什么都没删)`)
1006
+ console.log('加 --yes 才会真正删除')
1007
+ return { target, deleted: false, dryRun: true, files: totalFiles, bytes: totalBytes }
1008
+ }
1009
+
1010
+ // 只删每个目录的内容,目录本身保留
1011
+ let removedFiles = 0
1012
+ let failed = 0
1013
+ let freed = 0
1014
+ for (const p of plans) {
1015
+ let ents = []
1016
+ try { ents = fs.readdirSync(p.dir, { withFileTypes: true }) } catch { continue }
1017
+ for (const e of ents) {
1018
+ const abs = path.join(p.dir, e.name)
1019
+ const st = e.isDirectory() ? dirStat(abs) : { files: 1, bytes: (() => { try { return fs.statSync(abs).size } catch { return 0 } })() }
1020
+ try {
1021
+ fs.rmSync(abs, { recursive: true, force: true })
1022
+ removedFiles += st.files
1023
+ freed += st.bytes
1024
+ } catch (err) {
1025
+ failed++
1026
+ console.warn(`[${p.name}] 删除失败:${abs}(${err?.message ?? err})`)
1027
+ }
1028
+ }
1029
+ }
1030
+ console.log(`\n已删除 ${removedFiles} 个文件 / 释放 ${humanSize(freed)}${failed ? `(${failed} 项删除失败)` : ''}`)
1031
+ return { target, deleted: true, files: removedFiles, bytes: freed, failed }
1032
+ }
1033
+
1034
+ // CLI
1035
+
1036
+ export async function stageTranscribe(bvid, opts = {}) {
1037
+ const pageNo = opts.page ?? 1
1038
+ const caps = audioCaps()
1039
+ if (!caps.available) {
1040
+ console.error(`[音频] 转写不可用:${caps.hint ?? caps.reason}`)
1041
+ return null
1042
+ }
1043
+ const mp4 = await ensureMp4(bvid, pageNo)
1044
+ const result = await transcribeAudio(mp4, OUT, { lang: opts.lang ?? 'zh', quiet: false })
1045
+ if (!result?.text) {
1046
+ console.error('[音频] 转写失败或结果为空')
1047
+ return null
1048
+ }
1049
+ const outFile = path.join(OUT, `${bvid}-audio-transcript.txt`)
1050
+ fs.writeFileSync(outFile, result.text, 'utf8')
1051
+ console.log(`[音频] 转写完成:${result.text.length} 字 → ${outFile}`)
1052
+ console.log(result.text.slice(0, 500))
1053
+ return result
1054
+ }
1055
+
821
1056
  export async function stageLocal(inputFile, opts = {}) {
822
1057
  const file = path.resolve(inputFile)
823
1058
  if (!fs.existsSync(file)) throw new Error(`文件不存在: ${file}`)
@@ -855,3 +1090,53 @@ export async function stageLocal(inputFile, opts = {}) {
855
1090
  }
856
1091
 
857
1092
  // CLI
1093
+
1094
+ // OCR:识别一张图片里的文字(截图/画面帧/字幕带)
1095
+ export async function stageOcr(imagePath, opts = {}) {
1096
+ const input = String(imagePath ?? '')
1097
+ if (!input) { console.error('[OCR] 需要图片路径'); return null }
1098
+ if (!fs.existsSync(input)) { console.error('[OCR] 文件不存在: ' + input); return null }
1099
+ const caps = ocrCaps()
1100
+ if (!caps.available) { console.error('[OCR] 不可用:' + (caps.hint ?? caps.reason)); return null }
1101
+ const r = await ocrImage(input, { lang: opts.lang ?? 'chi_sim+eng', timeoutMs: 60000 })
1102
+ if (!r?.text) { console.error('[OCR] 未识别到文字(或识别失败)'); return null }
1103
+ fs.mkdirSync(OUT, { recursive: true })
1104
+ const outFile = path.join(OUT, path.basename(input, path.extname(input)) + '.ocr.txt')
1105
+ fs.writeFileSync(outFile, r.text, 'utf8')
1106
+ console.log('[OCR] ' + r.text.length + ' 字 → ' + outFile)
1107
+ console.log(r.text.slice(0, 1000))
1108
+ return r
1109
+ }
1110
+
1111
+ // URL:用 yt-dlp 下载任意平台视频,再走本地视频流程
1112
+ export async function stageUrl(url, opts = {}) {
1113
+ const link = String(url ?? '').trim()
1114
+ if (!/^https?:\/\//i.test(link)) { console.error('[URL] 只支持 http(s) 链接'); return null }
1115
+ const caps = ytdlpCaps()
1116
+ if (!caps.available) { console.error('[URL] yt-dlp 不可用:' + (caps.hint ?? caps.reason)); return null }
1117
+ fs.mkdirSync(CACHE, { recursive: true })
1118
+ console.log('[URL] 下载中(可能需要几分钟):' + link)
1119
+ const dl = await downloadVideo(link, CACHE, { timeoutMs: 600000 })
1120
+ if (!dl?.file) { console.error('[URL] 下载失败'); return null }
1121
+ console.log('[URL] 已下载 → ' + dl.file)
1122
+ return stageLocal(dl.file, opts)
1123
+ }
1124
+
1125
+ // 批量:从清单(文件或参数列表)逐条处理
1126
+ export async function stageBatch(input, opts = {}) {
1127
+ const { items, errors } = parseBatchList(input)
1128
+ for (const e of errors) console.error(`[批量] 第 ${e.line} 行无法识别:${String(e.raw).slice(0, 60)}`)
1129
+ if (!items.length) { console.error('[批量] 没有可处理的条目'); return null }
1130
+ console.log(`[批量] 共 ${items.length} 条(并发 ${opts.concurrency ?? 1})`)
1131
+ let idx = 0
1132
+ const summary = await runBatch(items, async (item) => {
1133
+ idx++
1134
+ console.log(`\n[批量 ${idx}/${items.length}] ${item.kind}: ${String(item.value).slice(0, 80)}`)
1135
+ if (item.kind === 'bv') return stageAuto(item.value, opts.budget ?? 20000, { page: opts.page ?? 1, audio: opts.audio === true, audioLang: opts.lang ?? null })
1136
+ if (item.kind === 'file') return stageLocal(item.value, { grid: opts.grid ?? '3x3', from: opts.from ?? null, to: opts.to ?? null })
1137
+ if (item.kind === 'url') return stageUrl(item.value, { grid: opts.grid ?? '3x3', from: opts.from ?? null, to: opts.to ?? null })
1138
+ return null
1139
+ }, { concurrency: opts.concurrency ?? 1 })
1140
+ console.log('\n' + formatBatchReport(summary))
1141
+ return summary
1142
+ }
package/lib/util.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // 通用工具:时间格式化、参数解析、文件头探测
2
2
  import fs from 'node:fs'
3
+ import { t } from './i18n.mjs'
3
4
 
4
5
  // 时长格式化:超过 1 小时显示 01:05:30,否则 MM:SS
5
6
 
@@ -37,21 +38,21 @@ export function jpegSize(file) {
37
38
  // 参数校验(在 CLI 分发处用;Number('')===0、Infinity、NaN 都要挡掉)
38
39
 
39
40
  export const fail = (msg) => {
40
- console.error(`参数错误:${msg}`)
41
+ console.error(`${t('err.argPrefix')}${msg}`)
41
42
  process.exit(2)
42
43
  }
43
44
 
44
45
  export const num = (v, name, { min, max } = {}) => {
45
46
  const n = Number(v)
46
- if (v === '' || !Number.isFinite(n)) fail(`--${name} 需要数字,收到:${JSON.stringify(v)}`)
47
- if (min !== undefined && n < min) fail(`--${name} 不能小于 ${min}(收到 ${n})`)
48
- if (max !== undefined && n > max) fail(`--${name} 不能大于 ${max}(收到 ${n})`)
47
+ if (v === '' || !Number.isFinite(n)) fail(t('err.num.notNumber', { name, value: JSON.stringify(v) }))
48
+ if (min !== undefined && n < min) fail(t('err.num.min', { name, min, n }))
49
+ if (max !== undefined && n > max) fail(t('err.num.max', { name, max, n }))
49
50
  return n
50
51
  }
51
52
 
52
53
  export const gridStr = (v) => {
53
54
  const s = String(v ?? '3x3').toLowerCase()
54
- if (!/^[1-9]x[1-9]$/.test(s)) fail(`--grid 格式应为 NxM(如 3x3,行列 1~9),收到:${JSON.stringify(v)}`)
55
+ if (!/^[1-9]x[1-9]$/.test(s)) fail(t('err.grid.format', { value: JSON.stringify(v) }))
55
56
  return s
56
57
  }
57
58
  // 时间参数:支持 330(秒)/ 5:30 / 1:05:30
@@ -61,16 +62,16 @@ export const parseTime = (v, name) => {
61
62
  const s = String(v).trim()
62
63
  if (/^\d+(\.\d+)?$/.test(s)) return Number(s)
63
64
  const parts = s.split(':')
64
- if (parts.length < 2 || parts.length > 3) fail(`--${name} 时间格式应为 330 / 5:30 / 1:05:30,收到:${JSON.stringify(v)}`)
65
+ if (parts.length < 2 || parts.length > 3) fail(t('err.time.format', { name, value: JSON.stringify(v) }))
65
66
  const nums = parts.map(Number)
66
- if (nums.some((n) => !Number.isFinite(n) || n < 0)) fail(`--${name} 时间格式非法:${JSON.stringify(v)}`)
67
+ if (nums.some((n) => !Number.isFinite(n) || n < 0)) fail(t('err.time.invalid', { name, value: JSON.stringify(v) }))
67
68
  return parts.length === 2 ? nums[0] * 60 + nums[1] : nums[0] * 3600 + nums[1] * 60 + nums[2]
68
69
  }
69
70
 
70
71
  export const pageNum = (v) => {
71
72
  if (v === null || v === undefined || v === '') return 1
72
73
  const n = Number(v)
73
- if (!Number.isInteger(n) || n < 1 || n > 200) fail(`--p 分P序号应为 1~200 的整数,收到:${JSON.stringify(v)}`)
74
+ if (!Number.isInteger(n) || n < 1 || n > 200) fail(t('err.page.range', { value: JSON.stringify(v) }))
74
75
  return n
75
76
  }
76
77