@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/CHANGELOG.md +48 -0
- package/README.en.md +17 -0
- package/README.md +137 -2
- package/fisheye.mjs +79 -5
- package/lib/audio.mjs +1 -0
- package/lib/batch.mjs +191 -0
- package/lib/bili.mjs +69 -14
- package/lib/frame.mjs +1 -1
- package/lib/i18n.mjs +18 -2
- package/lib/ocr.mjs +386 -0
- package/lib/progress.mjs +140 -0
- package/lib/resume.mjs +378 -0
- package/lib/stages.mjs +290 -26
- 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
|
@@ -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
|
-
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|