@pls-compile-owo/fisheye 0.3.0 → 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 +31 -0
- package/README.md +12 -0
- package/fisheye.mjs +9 -34
- package/lib/audio.mjs +645 -0
- package/lib/bili.mjs +19 -18
- package/lib/budget.mjs +117 -0
- package/lib/env.mjs +1 -0
- package/lib/i18n.mjs +131 -0
- package/lib/retry.mjs +209 -0
- package/lib/stages.mjs +34 -13
- package/lib/util.mjs +9 -8
- package/package.json +1 -1
package/lib/stages.mjs
CHANGED
|
@@ -5,11 +5,13 @@ 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 } from './audio.mjs'
|
|
12
13
|
import { AD_SEGMENT_SIGNAL, detectAdSegments, summarizeDanmaku } from './danmaku.mjs'
|
|
14
|
+
import { estimateTokens, tokensPerUnit, modelFactor, COST } from './budget.mjs'
|
|
13
15
|
|
|
14
16
|
export async function stageGrid(bvid, chunkSec, grid, pre = {}) {
|
|
15
17
|
const pageNo = pre.page ?? 1
|
|
@@ -276,7 +278,11 @@ export async function stageSubs(bvid, count, bandArg, pre = {}) {
|
|
|
276
278
|
// 平衡原则:先保「看懂所需的最低帧密度」,再在剩余预算里加张数;
|
|
277
279
|
// 预算不够时诚实降级并报告,而不是悄悄变稀。
|
|
278
280
|
|
|
279
|
-
|
|
281
|
+
// 单张成本不再写死:统一走 lib/budget.mjs 的 estimateTokens(kind, count)。
|
|
282
|
+
// 默认基准仍是 grid 1200 / subs 1000 / fine 1300(与旧版完全一致),
|
|
283
|
+
// 但支持 FISHEYE_TOKENS_PER_* 覆盖与 FISHEYE_TOKEN_MODEL 模型系数。
|
|
284
|
+
// COST 保留导出只为兼容旧的外部引用,内部不再直接乘它(它看不到环境变量)。
|
|
285
|
+
export { COST }
|
|
280
286
|
|
|
281
287
|
export const MAX_GRIDS = 12 // 拼图张数硬上限,防长视频失控
|
|
282
288
|
|
|
@@ -312,7 +318,7 @@ export async function stageSelftest(opts = {}) {
|
|
|
312
318
|
else {
|
|
313
319
|
const r = runCapture(PYTHON, ['--version'], 10000)
|
|
314
320
|
if (r.ok) rec('OK', 'python', r.out.trim())
|
|
315
|
-
else rec('
|
|
321
|
+
else rec('--', 'python', `无法执行(${PYTHON_RES?.from ?? '?'},可能是 Windows 商店占位符)——字幕带功能降级`)
|
|
316
322
|
}
|
|
317
323
|
|
|
318
324
|
// 4) analyze_subs.py
|
|
@@ -356,6 +362,10 @@ export async function stageSelftest(opts = {}) {
|
|
|
356
362
|
const subs = await getOfficialSubs(testBv, cid, null)
|
|
357
363
|
rec(subs ? 'OK' : '--', '官方字幕 API', subs ? `${subs.quality} / ${subs.lines} 行 / ${subs.chars} 字` : '这条视频无字幕轨(不影响,会用字幕带或画面)')
|
|
358
364
|
} catch (e) { rec('!!', '官方字幕 API', String(e.message).slice(0, 50)) }
|
|
365
|
+
try {
|
|
366
|
+
const audio = audioCaps()
|
|
367
|
+
rec(audio.available ? 'OK' : '--', '音频转写', audio.summary)
|
|
368
|
+
} catch (e) { rec('!!', '音频转写', String(e.message).slice(0, 50)) }
|
|
359
369
|
} else {
|
|
360
370
|
rec('--', '弹幕/字幕 API', '视频 API 不可用,跳过')
|
|
361
371
|
}
|
|
@@ -452,16 +462,21 @@ export async function stageAuto(bvid, budget, opts = {}) {
|
|
|
452
462
|
if (range) console.log(`[区间] 只处理 ${fmt(range.from)}-${fmt(range.to)}(${duration.toFixed(0)}s / 全片 ${fullDuration}s)`)
|
|
453
463
|
if (pageCount > 1 && pageNo === 1) console.log(`[提示] 多P 视频,本次只处理 P1;要处理别的分P 用 --p N`)
|
|
454
464
|
|
|
455
|
-
// 拼图 token 按实际画面比例修正:
|
|
465
|
+
// 拼图 token 按实际画面比例修正:estimateTokens('grid', 1) 以 16:9(约 1920x1080)拼图为基准,
|
|
456
466
|
// 方形/竖屏视频的拼图面积大得多(1920x1920 贵 78%,竖屏贵 3 倍+),不算清楚会误导预算决策
|
|
457
467
|
const dim = view.dimension ?? {}
|
|
458
468
|
const vw = Number(dim.width) || 1280
|
|
459
469
|
const vh = Number(dim.height) || 720
|
|
460
470
|
const cellH = Math.round((640 * vh) / Math.max(vw, 1))
|
|
461
471
|
const sheetPx = 640 * 3 * (cellH * 3)
|
|
462
|
-
const
|
|
472
|
+
const gridBase = estimateTokens('grid', 1)
|
|
473
|
+
const gridCost = Math.max(300, Math.round((gridBase * sheetPx) / (1920 * 1080)))
|
|
474
|
+
const factor = modelFactor()
|
|
475
|
+
if (factor !== 1) {
|
|
476
|
+
console.log(`[预算] 模型系数 ×${factor}(FISHEYE_TOKEN_MODEL,按该 tokenizer 的图像成本修正)`)
|
|
477
|
+
}
|
|
463
478
|
if (Math.abs(sheetPx / (1920 * 1080) - 1) > 0.25) {
|
|
464
|
-
console.log(`[比例] 画面 ${vw}x${vh} → 拼图 ${640 * 3}x${cellH * 3},单张约 ${gridCost} token(基准
|
|
479
|
+
console.log(`[比例] 画面 ${vw}x${vh} → 拼图 ${640 * 3}x${cellH * 3},单张约 ${gridCost} token(基准 ${gridBase} 已按面积修正)`)
|
|
465
480
|
}
|
|
466
481
|
|
|
467
482
|
// 1. 内容变化率:只统计所处理区间内的显著变化(score≥0.3)
|
|
@@ -488,6 +503,11 @@ export async function stageAuto(bvid, budget, opts = {}) {
|
|
|
488
503
|
if (officialRaw) {
|
|
489
504
|
console.log(`[字幕] 官方字幕质量差(平均有效 ${officialRaw.avgEff} 字/行、去重率 ${officialRaw.uniqueRatio})——疑为音乐/音效噪声,不采用`)
|
|
490
505
|
}
|
|
506
|
+
try {
|
|
507
|
+
const audio = audioCaps()
|
|
508
|
+
if (audio.available) console.log('[音频] whisper 可用:如需台词文本,可先转写(详见 README 环境变量)')
|
|
509
|
+
else console.log('[音频] 转写不可用:' + audio.hint)
|
|
510
|
+
} catch {}
|
|
491
511
|
// 传 fullDuration 而不是区间长度:字幕位置是视频级属性,采样必须跨全片
|
|
492
512
|
bandResult = detectBand(mp4, fullDuration)
|
|
493
513
|
// 置信度门槛:抖音式动态字幕(位置漂移 / 不是每帧都有)检测出来的「带」其实是画面内容,
|
|
@@ -551,10 +571,11 @@ export async function stageAuto(bvid, budget, opts = {}) {
|
|
|
551
571
|
const framesNeeded = Math.ceil(duration / maxGap)
|
|
552
572
|
const minGrids = Math.max(1, Math.ceil(framesNeeded / 9))
|
|
553
573
|
|
|
554
|
-
const subsCost = official ? Math.round(official.chars) : hasSubs ?
|
|
574
|
+
const subsCost = official ? Math.round(official.chars) : hasSubs ? estimateTokens('subs', 1) : 0
|
|
555
575
|
const subsRows = duration <= 180 ? 14 : duration <= 600 ? 20 : 24
|
|
556
576
|
const minFine = (official || hasSubs) ? 2 : 3 // 字幕已在手,精读需求自然变少
|
|
557
|
-
const
|
|
577
|
+
const fineReserve = estimateTokens('fine', minFine)
|
|
578
|
+
const fixedCost = subsCost + fineReserve
|
|
558
579
|
const maxGridsByBudget = Math.max(1, Math.floor((budget - fixedCost) / gridCost))
|
|
559
580
|
|
|
560
581
|
let gridCount = minGrids
|
|
@@ -573,10 +594,10 @@ export async function stageAuto(bvid, budget, opts = {}) {
|
|
|
573
594
|
const actualGap = duration / (gridCount * 9)
|
|
574
595
|
|
|
575
596
|
// 5. 报告
|
|
576
|
-
const estTotal = subsCost + gridCount * gridCost +
|
|
597
|
+
const estTotal = subsCost + gridCount * gridCost + fineReserve
|
|
577
598
|
console.log(`[张数] 密度需要 ${minGrids} 张,预算允许 ${maxGridsByBudget} 张 → 采用 ${gridCount} 张`)
|
|
578
599
|
console.log(`[参数] chunk=${chunk.toFixed(1)}s(每张覆盖),实际平均 ${actualGap.toFixed(1)}s/帧`)
|
|
579
|
-
console.log(`[预算] ${budget} token ≈ 字幕带 ${subsCost} + 拼图 ${gridCount * gridCost} + 精读预留 ${
|
|
600
|
+
console.log(`[预算] ${budget} token ≈ 字幕带 ${subsCost} + 拼图 ${gridCount * gridCost} + 精读预留 ${fineReserve} = ${estTotal}`)
|
|
580
601
|
if (estTotal > budget) {
|
|
581
602
|
console.log(`[警告] 最低配置已要 ${estTotal} token,超出预算 ${budget}(字幕带和精读预留不可再砍),将超支运行`)
|
|
582
603
|
}
|
|
@@ -775,12 +796,12 @@ export async function stageRefine(bvid, opts) {
|
|
|
775
796
|
console.log(`\n[精读] 选中 ${picks.length} 个时间点:`)
|
|
776
797
|
for (const p of picks) console.log(` ${p.ts} ${p.source} —— ${p.reason}`)
|
|
777
798
|
if (picks.length > 8) {
|
|
778
|
-
console.warn(`[警告] ${picks.length} 帧 ≈ ${picks.length
|
|
799
|
+
console.warn(`[警告] ${picks.length} 帧 ≈ ${estimateTokens('fine', picks.length)} token,建议先只留真正看不清的几帧再跑`)
|
|
779
800
|
}
|
|
780
801
|
console.log('')
|
|
781
802
|
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} 帧 × ${
|
|
803
|
+
const est = estimateTokens('fine', picks.length)
|
|
804
|
+
console.log(`\n精读成本 ≈ ${est} token(${picks.length} 帧 × ${tokensPerUnit('fine')})`)
|
|
784
805
|
return { picks, grabbed }
|
|
785
806
|
}
|
|
786
807
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pls-compile-owo/fisheye",
|
|
3
|
-
"version": "0.
|
|
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": {
|