@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.
package/lib/stages.mjs CHANGED
@@ -9,7 +9,11 @@ import { PKG_DIR, WS, CACHE, OUT, CFG_FILE, LOCAL_CFG, onPath, resolveTool, FFMP
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 } from './audio.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'
13
17
  import { AD_SEGMENT_SIGNAL, detectAdSegments, summarizeDanmaku } from './danmaku.mjs'
14
18
  import { estimateTokens, tokensPerUnit, modelFactor, COST } from './budget.mjs'
15
19
 
@@ -366,6 +370,14 @@ export async function stageSelftest(opts = {}) {
366
370
  const audio = audioCaps()
367
371
  rec(audio.available ? 'OK' : '--', '音频转写', audio.summary)
368
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)) }
369
381
  } else {
370
382
  rec('--', '弹幕/字幕 API', '视频 API 不可用,跳过')
371
383
  }
@@ -435,6 +447,21 @@ export async function stageSelftest(opts = {}) {
435
447
  }
436
448
 
437
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 {}
438
465
  const pageNo = opts.page ?? 1
439
466
  const mp4 = await ensureMp4(bvid, pageNo)
440
467
  const view = await getView(bvid)
@@ -492,6 +519,7 @@ export async function stageAuto(bvid, budget, opts = {}) {
492
519
  const official = officialRaw?.quality === 'ok' ? officialRaw : null
493
520
  let bandResult = null
494
521
  let hasSubs = false
522
+ let transcript = '' // --audio 转写出的台词文本(有它就不再烧字幕带)
495
523
  if (official) {
496
524
  console.log(`[字幕] 官方${official.lanDoc ?? ''} ${official.lines} 行 / ${official.chars} 字 ≈ ${official.chars} token → 跳过字幕带`)
497
525
  console.log(` 文本: ${official.txt}`)
@@ -499,32 +527,52 @@ export async function stageAuto(bvid, budget, opts = {}) {
499
527
  console.log(` ⚠️ 字幕较长(${official.chars} 字 ≈ ${official.chars} token),建议只读关心的时段而不是全文`)
500
528
  }
501
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
+ }
502
533
  } else {
503
534
  if (officialRaw) {
504
535
  console.log(`[字幕] 官方字幕质量差(平均有效 ${officialRaw.avgEff} 字/行、去重率 ${officialRaw.uniqueRatio})——疑为音乐/音效噪声,不采用`)
505
536
  }
506
- try {
507
- const audio = audioCaps()
508
- if (audio.available) console.log('[音频] whisper 可用:如需台词文本,可先转写(详见 README 环境变量)')
509
- else console.log('[音频] 转写不可用:' + audio.hint)
510
- } catch {}
511
- // 传 fullDuration 而不是区间长度:字幕位置是视频级属性,采样必须跨全片
512
- bandResult = detectBand(mp4, fullDuration)
513
- // 置信度门槛:抖音式动态字幕(位置漂移 / 不是每帧都有)检测出来的「带」其实是画面内容,
514
- // 拼出来一堆非字幕行。低于 0.5 就不用字幕带,只靠画面拼图。
515
- const conf = bandResult?.confidence
516
- const confOk = conf === undefined || conf >= 0.5
517
- hasSubs = !!(bandResult?.ok && bandResult.bandRatio >= 0.015 && bandResult.bandRatio <= 0.35 && confOk)
518
- if (bandResult?.ok && !confOk) {
519
- console.log(`[字幕] 检出疑似字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%)但置信度仅 ${Number(conf).toFixed(2)}`)
520
- console.log(` —— 字幕位置漂移或不是每帧都有(抖音式动态字幕常见),跳过字幕带,只靠画面`)
521
- } else {
522
- console.log(`[字幕] ${hasSubs
523
- ? `无可用官方字幕,检出烧录字幕带(${(bandResult.topRatio * 100).toFixed(0)}%-${(bandResult.botRatio * 100).toFixed(0)}%,置信度 ${Number(conf).toFixed(2)})→ 字幕带 1 张`
524
- : '无可用字幕 → 只靠画面'}`)
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
+ }
525
575
  }
526
- }
527
-
528
576
  // 2.5 弹幕(第三个信息源:观众反应)
529
577
  // 不参与「能不能看懂」的判定,只用来找高光——观众在哪一段最激动,那里通常最值得精读
530
578
  const danmakuRaw = await getDanmaku(cid, range)
@@ -571,7 +619,7 @@ export async function stageAuto(bvid, budget, opts = {}) {
571
619
  const framesNeeded = Math.ceil(duration / maxGap)
572
620
  const minGrids = Math.max(1, Math.ceil(framesNeeded / 9))
573
621
 
574
- const subsCost = official ? Math.round(official.chars) : hasSubs ? estimateTokens('subs', 1) : 0
622
+ const subsCost = official ? Math.round(official.chars) : transcript ? transcript.length : hasSubs ? estimateTokens('subs', 1) : 0
575
623
  const subsRows = duration <= 180 ? 14 : duration <= 600 ? 20 : 24
576
624
  const minFine = (official || hasSubs) ? 2 : 3 // 字幕已在手,精读需求自然变少
577
625
  const fineReserve = estimateTokens('fine', minFine)
@@ -609,13 +657,19 @@ export async function stageAuto(bvid, budget, opts = {}) {
609
657
  const pre = {
610
658
  mp4, view, scenes, bandResult, subsSheet: hasSubs, range, page: pageNo,
611
659
  subsCovered: !!(official || hasSubs), // 字幕信息是否已被覆盖(官方文本或烧录字幕带)
612
- subsSource: official ? 'official' : hasSubs ? 'burned-in' : null,
660
+ subsSource: official ? 'official' : transcript ? 'audio' : hasSubs ? 'burned-in' : null,
613
661
  danmakuHot: dmHot, // 写进 manifest,让 refine 也能用弹幕热区挑点
614
662
  danmakuTotal: danmaku?.length ?? 0,
615
663
  adSegments: adSegs, // 视频内疑似广告段(精读会避开 high 置信度的)
616
664
  useDanmakuFrames: opts.useDanmakuFrames !== false, // 弹幕热区是否也参与铺网取帧(默认是)
617
665
  }
618
- 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
+ }
619
673
  if (hasSubs) await stageSubs(bvid, subsRows, 'auto', { mp4, view, bandResult, scenes, range, page: pageNo })
620
674
 
621
675
  // 7. 精读建议 = 几何规则(画面变化剧烈)+ 弹幕热区(观众反应最激烈)
@@ -654,7 +708,14 @@ export async function stageAuto(bvid, budget, opts = {}) {
654
708
  } else {
655
709
  console.log(`\n[精读建议] 两个信号都没命中;读图后如需精读:node fisheye.mjs refine ${bvid} --template`)
656
710
  }
657
- 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 }
658
719
  }
659
720
 
660
721
  // refine:决定哪些格需要精读,然后自动截原分辨率单帧
@@ -839,6 +900,159 @@ export async function stageGrab(bvid, timeStr, opts = {}) {
839
900
 
840
901
  // 本地视频:不依赖 B站,直接用 ffmpeg 抽帧拼图(fisheye local <文件>)
841
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
+
842
1056
  export async function stageLocal(inputFile, opts = {}) {
843
1057
  const file = path.resolve(inputFile)
844
1058
  if (!fs.existsSync(file)) throw new Error(`文件不存在: ${file}`)
@@ -876,3 +1090,53 @@ export async function stageLocal(inputFile, opts = {}) {
876
1090
  }
877
1091
 
878
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
+ }