@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/i18n.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// 国际化:CLI 文案按语言取用。语言来自环境变量 FISHEYE_LANG
|
|
2
|
+
// 'zh'(默认)/ 'en';也接受 zh-CN、en-US 这类写法(取第一段)。
|
|
3
|
+
// 零依赖:只读 process.env,不引任何模块。
|
|
4
|
+
|
|
5
|
+
const DEFAULT_LANG = 'zh'
|
|
6
|
+
const SUPPORTED = new Set(['zh', 'en'])
|
|
7
|
+
|
|
8
|
+
/** 当前语言:FISHEYE_LANG 不合法或没设时回落到中文 */
|
|
9
|
+
export function getLang() {
|
|
10
|
+
const raw = String(process.env.FISHEYE_LANG ?? '').trim().toLowerCase()
|
|
11
|
+
if (!raw) return DEFAULT_LANG
|
|
12
|
+
const base = raw.split(/[-_]/)[0]
|
|
13
|
+
return SUPPORTED.has(base) ? base : DEFAULT_LANG
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 文案表。占位符写法 {name},由 t() 的 vars 替换;
|
|
17
|
+
// 找不到当前语言的 key 时回落到中文,再找不到就原样返回 key(方便发现漏翻)。
|
|
18
|
+
const MESSAGES = {
|
|
19
|
+
zh: {
|
|
20
|
+
// —— 用法帮助 ——
|
|
21
|
+
usage: `用法:
|
|
22
|
+
node fisheye.mjs auto <BV> [--budget 20000] [--density 6] [--audio] ← 推荐:自动规划并铺网(--audio 无字幕转写 / --resume 断点续跑)
|
|
23
|
+
node fisheye.mjs refine <BV> --template ← 生成精读决策模板
|
|
24
|
+
node fisheye.mjs refine <BV> --decisions <file.json> ← 按模型判断精读
|
|
25
|
+
node fisheye.mjs refine <BV> ← 几何兜底(零 token)
|
|
26
|
+
node fisheye.mjs grid <BV> [--chunk 60] [--grid 3x3]
|
|
27
|
+
node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]
|
|
28
|
+
node fisheye.mjs grab <BV> <t1,t2,t3> ← 手动指定时间点
|
|
29
|
+
node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t] ← 看本地视频(mp4/mkv/...)
|
|
30
|
+
node fisheye.mjs transcribe <BV> [--lang zh] ← 音频转写(需 whisper.cpp)
|
|
31
|
+
node fisheye.mjs ocr <图片路径> [--lang chi_sim+eng] ← OCR 识别图片文字(需 tesseract)
|
|
32
|
+
node fisheye.mjs url <视频链接> [--grid 3x3] ← 任意平台视频(需 yt-dlp)
|
|
33
|
+
node fisheye.mjs clean [--target cache|out|all] [--yes] ← 清理缓存/产物
|
|
34
|
+
node fisheye.mjs batch <清单文件|条目...> [--concurrency 2] ← 批量处理(每行一个 BV/路径/URL)
|
|
35
|
+
node fisheye.mjs selftest [--deep] ← 自检(依赖/凭证/API/缓存/磁盘)`,
|
|
36
|
+
|
|
37
|
+
// —— 依赖缺失 ——
|
|
38
|
+
'err.ffmpeg.missing': `找不到 ffmpeg。
|
|
39
|
+
|
|
40
|
+
安装方式(任选其一):
|
|
41
|
+
1. 装到 PATH:https://ffmpeg.org/download.html (Windows 用 gyan.dev 的 build)
|
|
42
|
+
2. 放项目里:把 ffmpeg 可执行文件放到 {binDir}/
|
|
43
|
+
3. 设环境变量:FFMPEG=<ffmpeg 完整路径>
|
|
44
|
+
4. 写配置:{cfgFile} 里加 {"ffmpeg": "<完整路径>"}`,
|
|
45
|
+
|
|
46
|
+
'err.cred.missing': `找不到 B站登录态文件。
|
|
47
|
+
|
|
48
|
+
创建 {credPath},内容:
|
|
49
|
+
{
|
|
50
|
+
"sessdata": "<B站 SESSDATA>",
|
|
51
|
+
"bili_jct": "<bili_jct>",
|
|
52
|
+
"buvid3": "<buvid3>"
|
|
53
|
+
}
|
|
54
|
+
获取方法:浏览器登录 B站 → F12 → Application → Cookies → bilibili.com,
|
|
55
|
+
复制这三个值。或用环境变量 BILI_CRED=<文件路径> 指向已有文件。
|
|
56
|
+
|
|
57
|
+
(也可以只跑 selftest 先看环境是否就绪:node fisheye.mjs selftest)`,
|
|
58
|
+
|
|
59
|
+
// —— 命令 / 参数错误 ——
|
|
60
|
+
'err.unknownStage': '未知阶段: {stage}',
|
|
61
|
+
'err.bvFormat': '[错误] BV 号格式不对:{raw}(应为 BV + 10 位字母数字,如 BV1ziWP6qENy)',
|
|
62
|
+
'err.local.usage': '用法: node fisheye.mjs local <视频文件> [--grid 3x3] [--from t] [--to t]',
|
|
63
|
+
'err.ocr.usage': '用法: node fisheye.mjs ocr <图片路径> [--lang chi_sim+eng]',
|
|
64
|
+
'err.url.usage': '用法: node fisheye.mjs url <视频链接> [--grid 3x3] [--from t] [--to t]',
|
|
65
|
+
'err.batch.usage': '用法: node fisheye.mjs batch <清单文件|条目...> [--concurrency 2]',
|
|
66
|
+
'err.fatal': '\n错误:{message}',
|
|
67
|
+
'err.local.failed': '[错误] {message}',
|
|
68
|
+
|
|
69
|
+
// —— util 参数校验 ——
|
|
70
|
+
'err.argPrefix': '参数错误:',
|
|
71
|
+
'err.num.notNumber': '--{name} 需要数字,收到:{value}',
|
|
72
|
+
'err.num.min': '--{name} 不能小于 {min}(收到 {n})',
|
|
73
|
+
'err.num.max': '--{name} 不能大于 {max}(收到 {n})',
|
|
74
|
+
'err.grid.format': '--grid 格式应为 NxM(如 3x3,行列 1~9),收到:{value}',
|
|
75
|
+
'err.time.format': '--{name} 时间格式应为 330 / 5:30 / 1:05:30,收到:{value}',
|
|
76
|
+
'err.time.invalid': '--{name} 时间格式非法:{value}',
|
|
77
|
+
'err.page.range': '--p 分P序号应为 1~200 的整数,收到:{value}'
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
en: {
|
|
81
|
+
usage: `Usage:
|
|
82
|
+
node fisheye.mjs auto <BV> [--budget 20000] [--density 6] [--audio] # recommended: auto-plan and build grids (--audio transcribe / --resume resume)
|
|
83
|
+
node fisheye.mjs refine <BV> --template # generate a refine decision template
|
|
84
|
+
node fisheye.mjs refine <BV> --decisions <file.json> # refine using model decisions
|
|
85
|
+
node fisheye.mjs refine <BV> # geometric fallback (zero tokens)
|
|
86
|
+
node fisheye.mjs grid <BV> [--chunk 60] [--grid 3x3]
|
|
87
|
+
node fisheye.mjs subs <BV> [--count 12] [--band auto|0.22]
|
|
88
|
+
node fisheye.mjs grab <BV> <t1,t2,t3> # pick timestamps manually
|
|
89
|
+
node fisheye.mjs local <video file> [--grid 3x3] [--from t] [--to t] # analyze a local video (mp4/mkv/...)
|
|
90
|
+
node fisheye.mjs transcribe <BV> [--lang zh] # transcribe audio (requires whisper.cpp)
|
|
91
|
+
node fisheye.mjs ocr <image path> [--lang chi_sim+eng] # OCR text from an image (requires tesseract)
|
|
92
|
+
node fisheye.mjs url <video link> [--grid 3x3] # any platform video (requires yt-dlp)
|
|
93
|
+
node fisheye.mjs clean [--target cache|out|all] [--yes] # clean cache/out artifacts
|
|
94
|
+
node fisheye.mjs batch <list file|items...> [--concurrency 2] # batch process (one BV/path/URL per line)
|
|
95
|
+
node fisheye.mjs selftest [--deep] # self-check (deps/credentials/API/cache/disk)`,
|
|
96
|
+
|
|
97
|
+
'err.ffmpeg.missing': `ffmpeg not found.
|
|
98
|
+
|
|
99
|
+
Install it in one of these ways:
|
|
100
|
+
1. Put it on PATH: https://ffmpeg.org/download.html (on Windows use a gyan.dev build)
|
|
101
|
+
2. Drop it into the project: place the ffmpeg executable in {binDir}/
|
|
102
|
+
3. Set an env var: FFMPEG=<full path to ffmpeg>
|
|
103
|
+
4. Use the config file: add {"ffmpeg": "<full path>"} to {cfgFile}`,
|
|
104
|
+
|
|
105
|
+
'err.cred.missing': `Bilibili credentials not found.
|
|
106
|
+
|
|
107
|
+
Create {credPath} containing:
|
|
108
|
+
{
|
|
109
|
+
"sessdata": "<Bilibili SESSDATA>",
|
|
110
|
+
"bili_jct": "<bili_jct>",
|
|
111
|
+
"buvid3": "<buvid3>"
|
|
112
|
+
}
|
|
113
|
+
How to get them: log in to Bilibili in your browser → F12 → Application → Cookies → bilibili.com,
|
|
114
|
+
then copy those three values. Or set BILI_CRED=<file path> to point at an existing file.
|
|
115
|
+
|
|
116
|
+
(You can also run selftest first to check whether the environment is ready: node fisheye.mjs selftest)`,
|
|
117
|
+
|
|
118
|
+
'err.unknownStage': 'Unknown stage: {stage}',
|
|
119
|
+
'err.bvFormat': '[error] Invalid BV id: {raw} (expected BV + 10 alphanumeric chars, e.g. BV1ziWP6qENy)',
|
|
120
|
+
'err.local.usage': 'Usage: node fisheye.mjs local <video file> [--grid 3x3] [--from t] [--to t]',
|
|
121
|
+
'err.ocr.usage': 'Usage: node fisheye.mjs ocr <image path> [--lang chi_sim+eng]',
|
|
122
|
+
'err.url.usage': 'Usage: node fisheye.mjs url <video link> [--grid 3x3] [--from t] [--to t]',
|
|
123
|
+
'err.batch.usage': 'Usage: node fisheye.mjs batch <list file|items...> [--concurrency 2]',
|
|
124
|
+
'err.fatal': '\nError: {message}',
|
|
125
|
+
'err.local.failed': '[error] {message}',
|
|
126
|
+
|
|
127
|
+
'err.argPrefix': 'Argument error: ',
|
|
128
|
+
'err.num.notNumber': '--{name} must be a number, got: {value}',
|
|
129
|
+
'err.num.min': '--{name} must be >= {min} (got {n})',
|
|
130
|
+
'err.num.max': '--{name} must be <= {max} (got {n})',
|
|
131
|
+
'err.grid.format': '--grid must be NxM (e.g. 3x3, rows/cols 1-9), got: {value}',
|
|
132
|
+
'err.time.format': '--{name} must be a time like 330 / 5:30 / 1:05:30, got: {value}',
|
|
133
|
+
'err.time.invalid': '--{name} invalid time: {value}',
|
|
134
|
+
'err.page.range': '--p (page number) must be an integer from 1 to 200, got: {value}'
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 取当前语言的一条文案,替换 {name} 占位符。
|
|
140
|
+
* 未提供的占位符保持原样,未知 key 返回 key 本身。
|
|
141
|
+
*/
|
|
142
|
+
export function t(key, vars = {}) {
|
|
143
|
+
const entry = MESSAGES[getLang()]?.[key] ?? MESSAGES[DEFAULT_LANG][key]
|
|
144
|
+
if (entry === undefined) return key
|
|
145
|
+
return entry.replace(/\{(\w+)\}/g, (match, name) =>
|
|
146
|
+
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match)
|
|
147
|
+
}
|
package/lib/ocr.mjs
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// OCR(可选):tesseract 命令行识别画面里的文字(截图标题、PPT、烧进画面的字幕条)
|
|
2
|
+
//
|
|
3
|
+
// 定位:fisheye 的信息源是「官方字幕 → 画面/字幕带 → 弹幕 → 音频」。画面里的文字
|
|
4
|
+
// (弹幕截图、PPT、标题条、烧进画面的字幕)模型只能"看图猜",OCR 能直接给出原文,
|
|
5
|
+
// 让模型对照文本而不是辨认像素。
|
|
6
|
+
// 它是**可选能力**:找不到 tesseract / 语言包一律返回 null,绝不让整个看片流程失败。
|
|
7
|
+
//
|
|
8
|
+
// 设计约束(和项目其他模块一致):
|
|
9
|
+
// · 零 npm 依赖,Node ≥18
|
|
10
|
+
// · 所有子进程用 spawnSync;stdout / stderr 分别重定向到临时文件(沙箱禁管道捕获),
|
|
11
|
+
// 分开落盘是为了不让 stderr 的 "Estimating resolution as ..." 混进正文
|
|
12
|
+
// · Windows 路径全部 path.join,可执行文件补 .exe
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import crypto from 'node:crypto'
|
|
17
|
+
import { spawnSync } from 'node:child_process'
|
|
18
|
+
import { PKG_DIR } from './env.mjs'
|
|
19
|
+
|
|
20
|
+
const IS_WIN = process.platform === 'win32'
|
|
21
|
+
const EXE = (name) => (IS_WIN ? `${name}.exe` : name)
|
|
22
|
+
|
|
23
|
+
// tesseract --version 每次探测都跑一遍太浪费,按 cmd 缓存
|
|
24
|
+
const versionCache = new Map()
|
|
25
|
+
|
|
26
|
+
const stripQuotes = (s) => String(s ?? '').trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1')
|
|
27
|
+
|
|
28
|
+
function readText(file, limit = 65536) {
|
|
29
|
+
try { return fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').slice(0, limit) } catch { return '' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tmpFile(tag) {
|
|
33
|
+
return path.join(os.tmpdir(), `fisheye-ocr-${tag}-${process.pid}-${crypto.randomUUID().slice(0, 8)}.txt`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 跑一个短命令,把 stdout / stderr 分别落到两个临时文件。
|
|
38
|
+
* 沙箱里不能捕获管道,所以不能用 spawnSync 的 'pipe' —— 只能 fd 重定向。
|
|
39
|
+
*/
|
|
40
|
+
function capture(cmd, args, timeoutMs = 8000) {
|
|
41
|
+
const outFile = tmpFile('out')
|
|
42
|
+
const errFile = tmpFile('err')
|
|
43
|
+
let fdOut = null
|
|
44
|
+
let fdErr = null
|
|
45
|
+
try {
|
|
46
|
+
fdOut = fs.openSync(outFile, 'w')
|
|
47
|
+
fdErr = fs.openSync(errFile, 'w')
|
|
48
|
+
const r = spawnSync(cmd, args, {
|
|
49
|
+
stdio: ['ignore', fdOut, fdErr],
|
|
50
|
+
timeout: timeoutMs,
|
|
51
|
+
windowsHide: true,
|
|
52
|
+
})
|
|
53
|
+
return {
|
|
54
|
+
ok: r.status === 0,
|
|
55
|
+
status: r.status,
|
|
56
|
+
error: r.error,
|
|
57
|
+
out: readText(outFile),
|
|
58
|
+
err: readText(errFile),
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { ok: false, status: null, error: e, out: '', err: '' }
|
|
62
|
+
} finally {
|
|
63
|
+
if (fdOut !== null) try { fs.closeSync(fdOut) } catch {}
|
|
64
|
+
if (fdErr !== null) try { fs.closeSync(fdErr) } catch {}
|
|
65
|
+
try { fs.unlinkSync(outFile) } catch {}
|
|
66
|
+
try { fs.unlinkSync(errFile) } catch {}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// where.exe / which 的全部命中行(没有命中返回 [])
|
|
71
|
+
function whereAll(name) {
|
|
72
|
+
const r = capture(IS_WIN ? 'where.exe' : 'which', [name], 5000)
|
|
73
|
+
if (r.status !== 0) return []
|
|
74
|
+
return r.out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 判断一个 where 命中项是否值得 spawn。
|
|
79
|
+
* Windows 上 `where tesseract` 一般命中真 exe;但 PATHEXT 里还有 .cpl/.bat 之类,
|
|
80
|
+
* 直接 spawn 会 ENOENT 或弹控制面板,所以先按扩展名筛掉。
|
|
81
|
+
*/
|
|
82
|
+
function spawnable(p) {
|
|
83
|
+
if (!p) return false
|
|
84
|
+
const ext = path.extname(p).toLowerCase()
|
|
85
|
+
if (IS_WIN && ['.cpl', '.dll', '.msi', '.sys', '.scr', '.bat', '.cmd', '.ps1', '.vbs', '.js'].includes(ext)) return false
|
|
86
|
+
const looksPath = p.includes('/') || p.includes('\\')
|
|
87
|
+
if (looksPath && !fs.existsSync(p)) return false
|
|
88
|
+
try {
|
|
89
|
+
if (fs.existsSync(p)) {
|
|
90
|
+
const st = fs.statSync(p)
|
|
91
|
+
if (!st.isFile() || st.size === 0) return false
|
|
92
|
+
}
|
|
93
|
+
} catch { return false }
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isDir(p) {
|
|
98
|
+
try { return fs.statSync(p).isDirectory() } catch { return false }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** tessdata 目录:TESSDATA_PREFIX → exe 同级 tessdata → 标准 share 布局 */
|
|
102
|
+
function tessdataDir(cmd, env = process.env) {
|
|
103
|
+
const cands = []
|
|
104
|
+
const explicit = stripQuotes(env.TESSDATA_PREFIX)
|
|
105
|
+
if (explicit) {
|
|
106
|
+
if (isDir(explicit)) cands.push(explicit)
|
|
107
|
+
// 少数分发把 TESSDATA_PREFIX 指到 "tessdata 的父目录"
|
|
108
|
+
if (isDir(path.join(explicit, 'tessdata'))) cands.push(path.join(explicit, 'tessdata'))
|
|
109
|
+
}
|
|
110
|
+
if (cmd && (cmd.includes('/') || cmd.includes('\\'))) {
|
|
111
|
+
const binDir = path.dirname(cmd)
|
|
112
|
+
cands.push(
|
|
113
|
+
path.join(binDir, 'tessdata'),
|
|
114
|
+
path.join(binDir, '..', 'share', 'tesseract-ocr', 'tessdata'),
|
|
115
|
+
path.join(binDir, '..', 'share', 'tessdata'),
|
|
116
|
+
path.join(binDir, '..', 'tessdata'),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
for (const d of cands) if (isDir(d)) return path.resolve(d)
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** tessdata 里的语言包(* .traineddata 的文件名,去掉扩展名) */
|
|
124
|
+
function listLangs(dir) {
|
|
125
|
+
if (!dir) return []
|
|
126
|
+
try {
|
|
127
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
128
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.traineddata'))
|
|
129
|
+
.map((e) => e.name.slice(0, -'.traineddata'.length))
|
|
130
|
+
.sort((a, b) => a.localeCompare(b))
|
|
131
|
+
} catch { return [] }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 常见安装路径(不含 PATH);顺序按需求固定:surfcore → LOCALAPPDATA → Program Files → 项目 bin */
|
|
135
|
+
function commonTesseractPaths(env = process.env) {
|
|
136
|
+
const home = os.homedir()
|
|
137
|
+
const localApp = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local')
|
|
138
|
+
return [
|
|
139
|
+
path.join(home, '.dsh', 'surfcore', 'tesseract', EXE('tesseract')),
|
|
140
|
+
path.join(home, '.dsh', 'surfcore', 'tesseract', 'bin', EXE('tesseract')),
|
|
141
|
+
path.join(localApp, 'Programs', 'Tesseract-OCR', EXE('tesseract')),
|
|
142
|
+
path.join(localApp, 'Tesseract-OCR', EXE('tesseract')),
|
|
143
|
+
'C:\\Program Files\\Tesseract-OCR\\tesseract.exe',
|
|
144
|
+
'C:\\Program Files (x86)\\Tesseract-OCR\\tesseract.exe',
|
|
145
|
+
path.join(PKG_DIR, 'bin', EXE('tesseract')),
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// FISHEYE_TESSERACT 可能是 exe 路径 / 安装目录 / PATH 上的命令名
|
|
150
|
+
function resolveEnvCommand(raw, env, notes) {
|
|
151
|
+
const v = stripQuotes(raw)
|
|
152
|
+
if (!v) return null
|
|
153
|
+
if (isDir(v)) {
|
|
154
|
+
const p = path.join(v, EXE('tesseract'))
|
|
155
|
+
if (fs.existsSync(p) && spawnable(p)) return path.resolve(p)
|
|
156
|
+
notes.push(`FISHEYE_TESSERACT=${v} 是目录,但里面没有 ${EXE('tesseract')}`)
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
if (fs.existsSync(v)) {
|
|
160
|
+
if (!spawnable(v)) { notes.push(`FISHEYE_TESSERACT=${v} 不是可执行文件`); return null }
|
|
161
|
+
return path.resolve(v)
|
|
162
|
+
}
|
|
163
|
+
if (path.isAbsolute(v) || v.includes('/') || v.includes('\\')) {
|
|
164
|
+
notes.push(`FISHEYE_TESSERACT 指向的文件不存在:${v}`)
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
const hits = whereAll(v).filter(spawnable)
|
|
168
|
+
if (hits.length) return hits[0]
|
|
169
|
+
notes.push(`FISHEYE_TESSERACT=${v} 在 PATH 上找不到`)
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function describe(cmd, source, env) {
|
|
174
|
+
const abs = path.resolve(cmd)
|
|
175
|
+
return {
|
|
176
|
+
cmd: fs.existsSync(abs) ? abs : cmd,
|
|
177
|
+
binDir: path.dirname(fs.existsSync(abs) ? abs : cmd),
|
|
178
|
+
tessdata: tessdataDir(abs, env),
|
|
179
|
+
source,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function findTesseractDetailed({ env = process.env } = {}) {
|
|
184
|
+
const e = env ?? {}
|
|
185
|
+
const notes = []
|
|
186
|
+
|
|
187
|
+
// 1) 环境变量:显式指定优先级最高
|
|
188
|
+
if (e.FISHEYE_TESSERACT) {
|
|
189
|
+
const cmd = resolveEnvCommand(e.FISHEYE_TESSERACT, e, notes)
|
|
190
|
+
if (cmd) return { tesseract: describe(cmd, '环境变量 FISHEYE_TESSERACT', e), notes }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 2) PATH 上所有 tesseract 命中项
|
|
194
|
+
for (const cmd of whereAll('tesseract').filter(spawnable)) {
|
|
195
|
+
return { tesseract: describe(cmd, 'PATH', e), notes }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 3) 常见安装路径(用户手动解压时它通常不在 PATH 里)
|
|
199
|
+
for (const cmd of commonTesseractPaths(e)) {
|
|
200
|
+
if (!fs.existsSync(cmd) || !spawnable(cmd)) continue
|
|
201
|
+
return { tesseract: describe(cmd, '常见安装路径', e), notes }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { tesseract: null, notes }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 探测可用的 tesseract。
|
|
209
|
+
* 探测顺序:FISHEYE_TESSERACT → PATH 上 tesseract → 常见安装路径(~/.dsh/surfcore/tesseract、
|
|
210
|
+
* %LOCALAPPDATA%\Programs\Tesseract-OCR、C:\Program Files\Tesseract-OCR、项目 bin/)。
|
|
211
|
+
* @param {{ env?: NodeJS.ProcessEnv }} [options]
|
|
212
|
+
* @returns {{ cmd: string, binDir: string, tessdata: string|null } | null}
|
|
213
|
+
*/
|
|
214
|
+
export function findTesseract({ env } = {}) {
|
|
215
|
+
const r = findTesseractDetailed({ env: env ?? process.env })
|
|
216
|
+
if (!r.tesseract) return null
|
|
217
|
+
const { cmd, binDir, tessdata } = r.tesseract
|
|
218
|
+
return { cmd, binDir, tessdata }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --version 输出形如 "tesseract v5.4.0.20240606" / "tesseract 4.1.1"
|
|
222
|
+
function tesseractVersion(cmd) {
|
|
223
|
+
const key = String(cmd).toLowerCase()
|
|
224
|
+
if (versionCache.has(key)) return versionCache.get(key)
|
|
225
|
+
const r = capture(cmd, ['--version'], 8000)
|
|
226
|
+
const m = `${r.out}\n${r.err}`.match(/tesseract\s+v?([0-9][\w.-]*)/i)
|
|
227
|
+
const v = m ? m[1] : null
|
|
228
|
+
versionCache.set(key, v)
|
|
229
|
+
return v
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 请求的语言里有的语言包没有时降级到交集;一个都没有返回 null
|
|
233
|
+
function resolveLang(requested, langs) {
|
|
234
|
+
// osd 只做方向检测,不是识别语言,混进 -l 会让 tesseract 输出空页
|
|
235
|
+
const want = String(requested ?? '').split('+').map((s) => s.trim()).filter(Boolean)
|
|
236
|
+
.filter((l) => l.toLowerCase() !== 'osd')
|
|
237
|
+
if (!want.length) return null
|
|
238
|
+
if (!langs.length) return want.join('+') // 语言包目录没探到,交给 tesseract 自己找
|
|
239
|
+
const have = new Set(langs.map((l) => l.toLowerCase()))
|
|
240
|
+
const kept = want.filter((l) => have.has(l.toLowerCase()))
|
|
241
|
+
return kept.length ? kept.join('+') : null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// chi_sim 会在每个汉字之间插一个空格("你 好 世 界"),合并回"你好世界";
|
|
245
|
+
// 只处理「汉字 + 空格 + 汉字」这一种相邻关系,不会动英文单词之间和中文与数字之间的空格
|
|
246
|
+
const CJK = '\\u3400-\\u4dbf\\u4e00-\\u9fff\\u3005-\\u303f\\uff00-\\uffef'
|
|
247
|
+
const CJK_GAP = new RegExp(`([${CJK}])[ \\t]+(?=[${CJK}])`, 'g')
|
|
248
|
+
|
|
249
|
+
function normalizeText(raw) {
|
|
250
|
+
return String(raw ?? '')
|
|
251
|
+
.replace(/\u000c/g, '\n') // tesseract 每页结尾的 form feed
|
|
252
|
+
.replace(/^\uFEFF/, '')
|
|
253
|
+
.split(/\r?\n/)
|
|
254
|
+
.map((l) => l.trim())
|
|
255
|
+
.join('\n')
|
|
256
|
+
.replace(CJK_GAP, '$1')
|
|
257
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
258
|
+
.trim()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 能力描述(给 CLI / selftest 打印用)。
|
|
263
|
+
* available 为 true 才表示「现在真的能做 OCR」:tesseract + 至少一个语言包。
|
|
264
|
+
* @param {{ env?: NodeJS.ProcessEnv, tesseract?: object }} [options]
|
|
265
|
+
* @returns {{ available: boolean, reason: string, summary: string, hint: string|null,
|
|
266
|
+
* cmd: string|null, binDir: string|null, langs: string[],
|
|
267
|
+
* tessdata: string|null, version: string|null, notes: string[] }}
|
|
268
|
+
*/
|
|
269
|
+
export function ocrCaps(options = {}) {
|
|
270
|
+
const env = options.env ?? process.env
|
|
271
|
+
try {
|
|
272
|
+
const detail = options.tesseract
|
|
273
|
+
? { tesseract: { ...options.tesseract, source: 'opts.tesseract' }, notes: [] }
|
|
274
|
+
: findTesseractDetailed({ env })
|
|
275
|
+
const t = detail.tesseract
|
|
276
|
+
const langs = t ? listLangs(t.tessdata) : []
|
|
277
|
+
const hasLang = langs.some((l) => l.toLowerCase() !== 'osd')
|
|
278
|
+
const version = t ? tesseractVersion(t.cmd) : null
|
|
279
|
+
const caps = {
|
|
280
|
+
available: false,
|
|
281
|
+
reason: '',
|
|
282
|
+
summary: '',
|
|
283
|
+
hint: null,
|
|
284
|
+
cmd: t?.cmd ?? null,
|
|
285
|
+
binDir: t?.binDir ?? null,
|
|
286
|
+
langs,
|
|
287
|
+
tessdata: t?.tessdata ?? null,
|
|
288
|
+
version,
|
|
289
|
+
notes: detail.notes ?? [],
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!t) {
|
|
293
|
+
caps.reason = '未找到 tesseract 可执行文件(已试 FISHEYE_TESSERACT / PATH / 常见安装路径)'
|
|
294
|
+
caps.hint = '装 tesseract 到 ~/.dsh/surfcore/tesseract,或把 tesseract.exe 放进 PATH;'
|
|
295
|
+
+ '中文识别还需要 tessdata/chi_sim.traineddata'
|
|
296
|
+
} else if (!hasLang) {
|
|
297
|
+
caps.reason = `找到 ${path.basename(t.cmd)},但 tessdata 里没有可用的语言包(只有 osd 不算)`
|
|
298
|
+
caps.hint = '把 eng.traineddata / chi_sim.traineddata 放进 tessdata 目录(或用 TESSDATA_PREFIX 指定)'
|
|
299
|
+
} else {
|
|
300
|
+
caps.available = true
|
|
301
|
+
const bits = [
|
|
302
|
+
`tesseract ${version ? `v${version}` : path.basename(t.cmd)}`,
|
|
303
|
+
`语言包 ${langs.join('/')}`,
|
|
304
|
+
]
|
|
305
|
+
if (t.source) bits.push(t.source)
|
|
306
|
+
caps.reason = bits.join(',')
|
|
307
|
+
if (!langs.some((l) => l.toLowerCase() === 'chi_sim')) {
|
|
308
|
+
caps.hint = '没有 chi_sim 语言包,中文画面只能识别英文/数字;把 chi_sim.traineddata 放进 tessdata'
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
caps.summary = `OCR:${caps.available ? '可用' : '不可用'} —— ${caps.reason}`
|
|
313
|
+
return caps
|
|
314
|
+
} catch (e) {
|
|
315
|
+
return {
|
|
316
|
+
available: false,
|
|
317
|
+
reason: `能力探测异常:${String(e?.message ?? e).slice(0, 120)}`,
|
|
318
|
+
summary: 'OCR:不可用 —— 能力探测异常',
|
|
319
|
+
hint: null,
|
|
320
|
+
cmd: null,
|
|
321
|
+
binDir: null,
|
|
322
|
+
langs: [],
|
|
323
|
+
tessdata: null,
|
|
324
|
+
version: null,
|
|
325
|
+
notes: [],
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 识别一张图片里的文字(可选能力,失败不抛异常)。
|
|
332
|
+
*
|
|
333
|
+
* @param {string} imagePath 图片路径(png/jpg/bmp/tiff/webp 等,tesseract 支持的格式)
|
|
334
|
+
* @param {{ lang?: string, timeoutMs?: number, env?: NodeJS.ProcessEnv,
|
|
335
|
+
* tesseract?: object, quiet?: boolean }} [options]
|
|
336
|
+
* @returns {{ text: string, cmd: string, lang: string } | null}
|
|
337
|
+
*/
|
|
338
|
+
export function ocrImage(imagePath, { lang = 'chi_sim+eng', timeoutMs = 30000, env = process.env, tesseract, quiet = false } = {}) {
|
|
339
|
+
const warn = (msg) => { if (!quiet) console.warn(`[OCR] ${msg}`) }
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const file = imagePath ? path.resolve(String(imagePath)) : ''
|
|
343
|
+
if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
344
|
+
warn(`找不到图片:${imagePath ?? '(空)'}`)
|
|
345
|
+
return null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const t = tesseract ?? findTesseract({ env })
|
|
349
|
+
if (!t) {
|
|
350
|
+
warn('没有可用的 tesseract,跳过 OCR(装 tesseract 或设 FISHEYE_TESSERACT)')
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const langs = listLangs(t.tessdata)
|
|
355
|
+
const useLang = resolveLang(lang, langs)
|
|
356
|
+
if (!useLang) {
|
|
357
|
+
warn(`tessdata 里没有 ${lang} 对应的语言包(现有:${langs.join('/') || '无'})`)
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
if (useLang !== lang) warn(`语言包不全:${lang} → 改用 ${useLang}`)
|
|
361
|
+
|
|
362
|
+
// tesseract <img> stdout [--tessdata-dir <dir>] -l <lang>
|
|
363
|
+
const args = [file, 'stdout']
|
|
364
|
+
const tessdata = t.tessdata ? path.resolve(t.tessdata) : null
|
|
365
|
+
if (tessdata) args.push('--tessdata-dir', tessdata)
|
|
366
|
+
args.push('-l', useLang)
|
|
367
|
+
|
|
368
|
+
const r = capture(t.cmd, args, timeoutMs)
|
|
369
|
+
if (!r.ok) {
|
|
370
|
+
const why = r.error?.code === 'ETIMEDOUT' ? '超时' : (r.error?.code ?? `退出码 ${r.status}`)
|
|
371
|
+
const detail = (r.err || r.out).trim().split(/\r?\n/)[0]?.slice(0, 160) ?? ''
|
|
372
|
+
warn(`tesseract 执行失败(${why})${detail ? `:${detail}` : ''}`)
|
|
373
|
+
return null
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const text = normalizeText(r.out)
|
|
377
|
+
if (!text) {
|
|
378
|
+
warn('识别结果为空(画面里可能没有可读文字)')
|
|
379
|
+
return null
|
|
380
|
+
}
|
|
381
|
+
return { text, cmd: t.cmd, lang: useLang }
|
|
382
|
+
} catch (e) {
|
|
383
|
+
warn(`OCR 异常:${String(e?.message ?? e).split('\n')[0].slice(0, 120)}`)
|
|
384
|
+
return null
|
|
385
|
+
}
|
|
386
|
+
}
|
package/lib/progress.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// 进度反馈:长视频抽帧/下载动辄几分钟,不给进度就像卡死。
|
|
2
|
+
//
|
|
3
|
+
// 设计约束:
|
|
4
|
+
// · 默认写 stderr —— stdout 留给机器可读结果(MANIFEST/SHEET/JSON),别混在一起
|
|
5
|
+
// · TTY 单行覆盖(\r),非 TTY(重定向到文件/管道)每次换行,否则日志会变成一坨
|
|
6
|
+
// · --quiet 用 enabled:false 整体静默,但 elapsed() 仍然准确(耗时统计不受影响)
|
|
7
|
+
// · stream 可注入:测试用假 stream 收集输出,不用去 patch process.stderr
|
|
8
|
+
|
|
9
|
+
export const isTTYStream = (stream) => Boolean(stream && stream.isTTY)
|
|
10
|
+
|
|
11
|
+
/** 毫秒 → 人类可读时长:300ms / 1.5s / 2m3s / 1h2m3s */
|
|
12
|
+
export function humanDuration(ms) {
|
|
13
|
+
let n = Number(ms)
|
|
14
|
+
if (!Number.isFinite(n) || n < 0) n = 0
|
|
15
|
+
if (n < 1000) return `${Math.round(n)}ms`
|
|
16
|
+
if (n < 60000) return `${(n / 1000).toFixed(1)}s`
|
|
17
|
+
const totalSec = Math.round(n / 1000)
|
|
18
|
+
const h = Math.floor(totalSec / 3600)
|
|
19
|
+
const m = Math.floor((totalSec % 3600) / 60)
|
|
20
|
+
const s = totalSec % 60
|
|
21
|
+
return h ? `${h}h${m}m${s}s` : `${m}m${s}s`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 安全写入:终端已关闭(EPIPE)不该炸掉整个抽帧流程 */
|
|
25
|
+
function safeWrite(stream, text) {
|
|
26
|
+
if (typeof stream?.write !== 'function') return
|
|
27
|
+
try { stream.write(text) } catch { /* 忽略写失败 */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 进度条。返回 { tick(n=1), set(done), done(), fail(msg), elapsed() }
|
|
32
|
+
*
|
|
33
|
+
* @param {string} label 前缀,如 '抽帧'
|
|
34
|
+
* @param {number} total 总数;<=0 / 非数字表示「总数未知」,只显示已计数
|
|
35
|
+
* @param {{stream?: {write: Function, isTTY?: boolean}, enabled?: boolean}} opts
|
|
36
|
+
*/
|
|
37
|
+
export function createProgress(label, total, { stream = process.stderr, enabled = true } = {}) {
|
|
38
|
+
const t0 = Date.now()
|
|
39
|
+
const rawTotal = Number(total)
|
|
40
|
+
const totalN = Number.isFinite(rawTotal) && rawTotal > 0 ? Math.floor(rawTotal) : 0
|
|
41
|
+
const tty = isTTYStream(stream)
|
|
42
|
+
let done = 0
|
|
43
|
+
let dirty = false // TTY 下屏幕上是否已有一行「没换行」的进度
|
|
44
|
+
let ended = false
|
|
45
|
+
|
|
46
|
+
const clamp = (n) => {
|
|
47
|
+
const v = Number(n)
|
|
48
|
+
if (!Number.isFinite(v) || v < 0) return 0
|
|
49
|
+
return totalN ? Math.min(v, totalN) : v
|
|
50
|
+
}
|
|
51
|
+
const text = () => {
|
|
52
|
+
const base = `[${label}] `
|
|
53
|
+
if (!totalN) return `${base}${done}`
|
|
54
|
+
const pct = Math.min(100, Math.floor((done / totalN) * 100))
|
|
55
|
+
return `${base}${done}/${totalN} (${pct}%)`
|
|
56
|
+
}
|
|
57
|
+
const render = () => {
|
|
58
|
+
if (!enabled) return
|
|
59
|
+
if (tty) {
|
|
60
|
+
safeWrite(stream, `\r${text()}`)
|
|
61
|
+
dirty = true
|
|
62
|
+
} else {
|
|
63
|
+
safeWrite(stream, `${text()}\n`)
|
|
64
|
+
dirty = false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const closeLine = () => {
|
|
68
|
+
if (dirty) {
|
|
69
|
+
safeWrite(stream, '\n')
|
|
70
|
+
dirty = false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const api = {
|
|
75
|
+
tick(n = 1) {
|
|
76
|
+
if (ended) return api
|
|
77
|
+
const step = Number(n)
|
|
78
|
+
done = clamp(done + (Number.isFinite(step) ? step : 1))
|
|
79
|
+
render()
|
|
80
|
+
return api
|
|
81
|
+
},
|
|
82
|
+
set(n) {
|
|
83
|
+
if (ended) return api
|
|
84
|
+
done = clamp(n)
|
|
85
|
+
render()
|
|
86
|
+
return api
|
|
87
|
+
},
|
|
88
|
+
done() {
|
|
89
|
+
if (!ended) {
|
|
90
|
+
done = totalN || done
|
|
91
|
+
if (enabled) {
|
|
92
|
+
render()
|
|
93
|
+
closeLine()
|
|
94
|
+
}
|
|
95
|
+
ended = true
|
|
96
|
+
}
|
|
97
|
+
return api.elapsed()
|
|
98
|
+
},
|
|
99
|
+
fail(msg) {
|
|
100
|
+
if (!ended) {
|
|
101
|
+
if (enabled) {
|
|
102
|
+
if (tty && dirty) {
|
|
103
|
+
// 覆盖当前行,失败原因和停在哪一帧一起显示,方便直接定位
|
|
104
|
+
safeWrite(stream, `\r${text()} 失败:${msg}\n`)
|
|
105
|
+
dirty = false
|
|
106
|
+
} else {
|
|
107
|
+
closeLine()
|
|
108
|
+
safeWrite(stream, `[${label}] 失败:${msg}\n`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
ended = true
|
|
112
|
+
}
|
|
113
|
+
return api.elapsed()
|
|
114
|
+
},
|
|
115
|
+
elapsed() {
|
|
116
|
+
return Number(((Date.now() - t0) / 1000).toFixed(1))
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
return api
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 阶段计时器。stop() 输出 `[label] 用时 3.2s`,返回秒数(重复 stop 只输出一次)。
|
|
124
|
+
* @param {string} label
|
|
125
|
+
* @param {{stream?: {write: Function, isTTY?: boolean}, enabled?: boolean}} opts
|
|
126
|
+
*/
|
|
127
|
+
export function stageTimer(label, { stream = process.stderr, enabled = true } = {}) {
|
|
128
|
+
const t0 = Date.now()
|
|
129
|
+
let stopped = false
|
|
130
|
+
return {
|
|
131
|
+
stop() {
|
|
132
|
+
const ms = Date.now() - t0
|
|
133
|
+
if (!stopped) {
|
|
134
|
+
stopped = true
|
|
135
|
+
if (enabled) safeWrite(stream, `[${label}] 用时 ${humanDuration(ms)}\n`)
|
|
136
|
+
}
|
|
137
|
+
return Number((ms / 1000).toFixed(1))
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|