@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/CHANGELOG.md +69 -0
- package/README.md +62 -1
- package/fisheye.mjs +88 -39
- package/lib/audio.mjs +646 -0
- package/lib/batch.mjs +191 -0
- package/lib/bili.mjs +86 -30
- package/lib/budget.mjs +117 -0
- package/lib/env.mjs +1 -0
- package/lib/frame.mjs +1 -1
- package/lib/i18n.mjs +147 -0
- package/lib/ocr.mjs +386 -0
- package/lib/progress.mjs +140 -0
- package/lib/resume.mjs +378 -0
- package/lib/retry.mjs +209 -0
- package/lib/stages.mjs +317 -32
- package/lib/util.mjs +9 -8
- 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/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
|
-
|
|
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('
|
|
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 按实际画面比例修正:
|
|
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
|
|
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(基准
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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 ?
|
|
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
|
|
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 +
|
|
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} + 精读预留 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
783
|
-
console.log(`\n精读成本 ≈ ${est} token(${picks.length} 帧 × ${
|
|
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(
|
|
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(
|
|
47
|
-
if (min !== undefined && n < min) fail(
|
|
48
|
-
if (max !== undefined && n > max) fail(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|