@pls-compile-owo/fisheye 0.2.1 → 0.4.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 +60 -0
- package/README.en.md +4 -0
- package/README.md +33 -5
- package/fisheye.mjs +18 -1437
- package/lib/audio.mjs +645 -0
- package/lib/bili.mjs +195 -0
- package/lib/budget.mjs +117 -0
- package/lib/danmaku.mjs +75 -0
- package/lib/env.mjs +61 -0
- package/lib/frame.mjs +192 -0
- package/lib/i18n.mjs +131 -0
- package/lib/retry.mjs +209 -0
- package/lib/stages.mjs +878 -0
- package/lib/util.mjs +78 -0
- package/package.json +6 -2
- package/test/danmaku.test.mjs +70 -0
- package/test/util.test.mjs +71 -0
package/lib/util.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// 通用工具:时间格式化、参数解析、文件头探测
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { t } from './i18n.mjs'
|
|
4
|
+
|
|
5
|
+
// 时长格式化:超过 1 小时显示 01:05:30,否则 MM:SS
|
|
6
|
+
|
|
7
|
+
export const fmt = (s) => {
|
|
8
|
+
const h = Math.floor(s / 3600)
|
|
9
|
+
const m = Math.floor((s % 3600) / 60)
|
|
10
|
+
const sec = Math.floor(s % 60)
|
|
11
|
+
const mm = String(m).padStart(2, '0')
|
|
12
|
+
const ss = String(sec).padStart(2, '0')
|
|
13
|
+
return h ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
|
|
14
|
+
}
|
|
15
|
+
// Windows 文件名不能带冒号,落盘用秒数
|
|
16
|
+
|
|
17
|
+
export const fsec = (s) => String(Math.round(s)).padStart(4, '0')
|
|
18
|
+
|
|
19
|
+
// 从 JPEG 头读尺寸(沙箱禁管道,不能用 ffprobe 捕获输出)
|
|
20
|
+
|
|
21
|
+
export function jpegSize(file) {
|
|
22
|
+
try {
|
|
23
|
+
const b = fs.readFileSync(file)
|
|
24
|
+
let i = 2
|
|
25
|
+
while (i < b.length - 8) {
|
|
26
|
+
if (b[i] !== 0xFF) { i++; continue }
|
|
27
|
+
const marker = b[i + 1]
|
|
28
|
+
const len = (b[i + 2] << 8) | b[i + 3]
|
|
29
|
+
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
|
|
30
|
+
return { h: (b[i + 5] << 8) | b[i + 6], w: (b[i + 7] << 8) | b[i + 8] }
|
|
31
|
+
}
|
|
32
|
+
i += 2 + len
|
|
33
|
+
}
|
|
34
|
+
} catch {}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 参数校验(在 CLI 分发处用;Number('')===0、Infinity、NaN 都要挡掉)
|
|
39
|
+
|
|
40
|
+
export const fail = (msg) => {
|
|
41
|
+
console.error(`${t('err.argPrefix')}${msg}`)
|
|
42
|
+
process.exit(2)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const num = (v, name, { min, max } = {}) => {
|
|
46
|
+
const n = Number(v)
|
|
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 }))
|
|
50
|
+
return n
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const gridStr = (v) => {
|
|
54
|
+
const s = String(v ?? '3x3').toLowerCase()
|
|
55
|
+
if (!/^[1-9]x[1-9]$/.test(s)) fail(t('err.grid.format', { value: JSON.stringify(v) }))
|
|
56
|
+
return s
|
|
57
|
+
}
|
|
58
|
+
// 时间参数:支持 330(秒)/ 5:30 / 1:05:30
|
|
59
|
+
|
|
60
|
+
export const parseTime = (v, name) => {
|
|
61
|
+
if (v === null || v === undefined || v === '') return null
|
|
62
|
+
const s = String(v).trim()
|
|
63
|
+
if (/^\d+(\.\d+)?$/.test(s)) return Number(s)
|
|
64
|
+
const parts = s.split(':')
|
|
65
|
+
if (parts.length < 2 || parts.length > 3) fail(t('err.time.format', { name, value: JSON.stringify(v) }))
|
|
66
|
+
const nums = parts.map(Number)
|
|
67
|
+
if (nums.some((n) => !Number.isFinite(n) || n < 0)) fail(t('err.time.invalid', { name, value: JSON.stringify(v) }))
|
|
68
|
+
return parts.length === 2 ? nums[0] * 60 + nums[1] : nums[0] * 3600 + nums[1] * 60 + nums[2]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const pageNum = (v) => {
|
|
72
|
+
if (v === null || v === undefined || v === '') return 1
|
|
73
|
+
const n = Number(v)
|
|
74
|
+
if (!Number.isInteger(n) || n < 1 || n > 200) fail(t('err.page.range', { value: JSON.stringify(v) }))
|
|
75
|
+
return n
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 统一取视频元信息(带超时 + code 校验;之前三处各自 fetch,有的没查 code)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pls-compile-owo/fisheye",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Token-budget-aware video understanding for AI agents: official subtitles -> burned-in subtitle band -> danmaku highlights -> adaptive keyframe grids -> targeted refine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"fisheye.mjs",
|
|
11
|
+
"lib/",
|
|
11
12
|
"danmaku-filter.mjs",
|
|
12
13
|
"analyze_subs.py",
|
|
13
14
|
"test-danmaku-filter.js",
|
|
15
|
+
"test/",
|
|
14
16
|
"credentials.example.json",
|
|
15
17
|
"README.md",
|
|
18
|
+
"README.en.md",
|
|
19
|
+
"CHANGELOG.md",
|
|
16
20
|
"LICENSE",
|
|
17
21
|
"docs/"
|
|
18
22
|
],
|
|
@@ -20,7 +24,7 @@
|
|
|
20
24
|
"node": ">=18"
|
|
21
25
|
},
|
|
22
26
|
"scripts": {
|
|
23
|
-
"test": "node test-danmaku-filter.js",
|
|
27
|
+
"test": "node --test test/ && node test-danmaku-filter.js",
|
|
24
28
|
"selftest": "node fisheye.mjs selftest --deep"
|
|
25
29
|
},
|
|
26
30
|
"keywords": [
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// danmaku 模块单测:广告段检测 / 热区摘要 / 过滤规则
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { AD_SEGMENT_SIGNAL, detectAdSegments, summarizeDanmaku } from '../lib/danmaku.mjs'
|
|
5
|
+
import { spamReason, filterDanmaku } from '../danmaku-filter.mjs'
|
|
6
|
+
|
|
7
|
+
test('AD_SEGMENT_SIGNAL:命中广告关键词', () => {
|
|
8
|
+
assert.ok(AD_SEGMENT_SIGNAL.test('感谢金主爸爸'))
|
|
9
|
+
assert.ok(AD_SEGMENT_SIGNAL.test('本期恰饭'))
|
|
10
|
+
assert.ok(AD_SEGMENT_SIGNAL.test('优惠码在评论区'))
|
|
11
|
+
assert.ok(!AD_SEGMENT_SIGNAL.test('这个视频真好看'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('detectAdSegments:空 / 短视频返回空数组', () => {
|
|
15
|
+
assert.deepEqual(detectAdSegments([], 300), [])
|
|
16
|
+
assert.deepEqual(detectAdSegments([{ t: 1, text: 'x' }], 60), [])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('detectAdSegments:密度低谷 + 广告词 → 识别出段', () => {
|
|
20
|
+
// 300 秒视频:中段 100~160s 弹幕稀少,其余密集
|
|
21
|
+
const danmaku = []
|
|
22
|
+
for (let t = 0; t < 300; t += 2) {
|
|
23
|
+
if (t >= 100 && t < 160) continue
|
|
24
|
+
danmaku.push({ t, text: '666' })
|
|
25
|
+
}
|
|
26
|
+
danmaku.push({ t: 120, text: '恰饭时间' })
|
|
27
|
+
const segs = detectAdSegments(danmaku, 300)
|
|
28
|
+
assert.ok(Array.isArray(segs))
|
|
29
|
+
assert.ok(segs.length >= 1)
|
|
30
|
+
for (const s of segs) {
|
|
31
|
+
assert.ok(s.to > s.from)
|
|
32
|
+
assert.ok(s.ts.includes('-'))
|
|
33
|
+
assert.ok(['high', 'low'].includes(s.confidence))
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('summarizeDanmaku:空输入返回 null', () => {
|
|
38
|
+
assert.equal(summarizeDanmaku([]), null)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('summarizeDanmaku:按时间桶聚合,返回时间与样本', () => {
|
|
42
|
+
const list = [
|
|
43
|
+
{ t: 1, text: '666' },
|
|
44
|
+
{ t: 2, text: '666' },
|
|
45
|
+
{ t: 3, text: '哈哈' },
|
|
46
|
+
{ t: 11, text: '好' }
|
|
47
|
+
]
|
|
48
|
+
const out = summarizeDanmaku(list, 5, 6)
|
|
49
|
+
assert.ok(Array.isArray(out))
|
|
50
|
+
assert.equal(out.length, 2)
|
|
51
|
+
assert.equal(out[0].start, 0)
|
|
52
|
+
assert.equal(out[0].count, 3)
|
|
53
|
+
assert.ok(out[0].samples.includes('666×2'))
|
|
54
|
+
assert.equal(out[1].start, 10)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('danmaku-filter:只过滤明确引流,不碰梗', () => {
|
|
58
|
+
assert.ok(spamReason('加微信领取福利'))
|
|
59
|
+
assert.ok(spamReason('私信我'))
|
|
60
|
+
assert.ok(!spamReason('666'))
|
|
61
|
+
assert.ok(!spamReason('awsl'))
|
|
62
|
+
assert.ok(!spamReason('哈哈哈哈'))
|
|
63
|
+
const { kept, dropped } = filterDanmaku([
|
|
64
|
+
{ t: 1, text: '666' },
|
|
65
|
+
{ t: 2, text: '加微信' },
|
|
66
|
+
{ t: 3, text: '前面高能' }
|
|
67
|
+
])
|
|
68
|
+
assert.equal(kept.length, 2)
|
|
69
|
+
assert.ok(dropped.ad >= 1)
|
|
70
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// util 模块单测:时间格式化 / 参数解析(非法输入走 fail() 会 exit,用子进程断言退出码)
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import assert from 'node:assert/strict'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { fmt, fsec, parseTime, num, gridStr, pageNum } from '../lib/util.mjs'
|
|
8
|
+
|
|
9
|
+
const utilPath = fileURLToPath(new URL('../lib/util.mjs', import.meta.url))
|
|
10
|
+
|
|
11
|
+
// 在子进程里执行一段 import util 的表达式,返回退出码(fail() 会 exit(2))
|
|
12
|
+
function runInChild(fn, argExpr) {
|
|
13
|
+
const code = `import(${JSON.stringify(pathToFileURL(utilPath).href)}).then(({ ${fn} }) => { ${fn}(${argExpr}) })`
|
|
14
|
+
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8' })
|
|
15
|
+
return r.status
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('fmt:超过 1 小时显示 H:MM:SS,否则 MM:SS', () => {
|
|
19
|
+
assert.equal(fmt(0), '00:00')
|
|
20
|
+
assert.equal(fmt(65), '01:05')
|
|
21
|
+
assert.equal(fmt(600), '10:00')
|
|
22
|
+
assert.equal(fmt(3930), '1:05:30')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('fsec:落盘文件名用 4 位秒数', () => {
|
|
26
|
+
assert.equal(fsec(0), '0000')
|
|
27
|
+
assert.equal(fsec(59.6), '0060')
|
|
28
|
+
assert.equal(fsec(3600), '3600')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('parseTime:数字 / M:SS / H:MM:SS', () => {
|
|
32
|
+
assert.equal(parseTime(90, 't'), 90)
|
|
33
|
+
assert.equal(parseTime('90', 't'), 90)
|
|
34
|
+
assert.equal(parseTime('1:30', 't'), 90)
|
|
35
|
+
assert.equal(parseTime('1:05:30', 't'), 3930)
|
|
36
|
+
assert.equal(parseTime(null, 't'), null)
|
|
37
|
+
assert.equal(parseTime('', 't'), null)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('parseTime:非法格式退出码为 2', () => {
|
|
41
|
+
assert.equal(runInChild('parseTime', "'abc', 't'"), 2)
|
|
42
|
+
assert.equal(runInChild('parseTime', "'1:2:3:4', 't'"), 2)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('num:数字与边界', () => {
|
|
46
|
+
assert.equal(num('5', 'x'), 5)
|
|
47
|
+
assert.equal(num(5, 'x'), 5)
|
|
48
|
+
assert.equal(num(0, 'x'), 0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('num:非法 / 越界退出码为 2', () => {
|
|
52
|
+
assert.equal(runInChild('num', "'abc', 'x'"), 2)
|
|
53
|
+
assert.equal(runInChild('num', "'', 'x'"), 2)
|
|
54
|
+
assert.equal(runInChild('num', "999, 'x', { min: 0, max: 10 }"), 2)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('gridStr:归一化与校验', () => {
|
|
58
|
+
assert.equal(gridStr('3x3'), '3x3')
|
|
59
|
+
assert.equal(gridStr('4X4'), '4x4')
|
|
60
|
+
assert.equal(gridStr(undefined), '3x3')
|
|
61
|
+
assert.equal(runInChild('gridStr', "'10x10'"), 2)
|
|
62
|
+
assert.equal(runInChild('gridStr', "'abc'"), 2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('pageNum:分P序号', () => {
|
|
66
|
+
assert.equal(pageNum(null), 1)
|
|
67
|
+
assert.equal(pageNum('2'), 2)
|
|
68
|
+
assert.equal(runInChild('pageNum', '0'), 2)
|
|
69
|
+
assert.equal(runInChild('pageNum', '201'), 2)
|
|
70
|
+
assert.equal(runInChild('pageNum', "'x'"), 2)
|
|
71
|
+
})
|