@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/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
- export const COST = { grid: 1200, subs: 1000, fine: 1300 } // 单张成本估算(token)
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('!!', 'python', `无法执行(来源 ${PYTHON_RES?.from ?? '?'}):${r.err.slice(0, 50)}`)
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 按实际画面比例修正:COST.grid 以 1920x1080 拼图为基准,
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 gridCost = Math.max(300, Math.round((COST.grid * sheetPx) / (1920 * 1080)))
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(基准 1200 已按面积修正)`)
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 ? COST.subs : 0
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 fixedCost = subsCost + minFine * COST.fine
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 + minFine * COST.fine
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} + 精读预留 ${minFine * COST.fine} = ${estTotal}`)
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 * COST.fine} token,建议先只留真正看不清的几帧再跑`)
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 * COST.fine
783
- console.log(`\n精读成本 ≈ ${est} token(${picks.length} 帧 × ${COST.fine})`)
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(`参数错误:${msg}`)
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(`--${name} 需要数字,收到:${JSON.stringify(v)}`)
47
- if (min !== undefined && n < min) fail(`--${name} 不能小于 ${min}(收到 ${n})`)
48
- if (max !== undefined && n > max) fail(`--${name} 不能大于 ${max}(收到 ${n})`)
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(`--grid 格式应为 NxM(如 3x3,行列 1~9),收到:${JSON.stringify(v)}`)
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(`--${name} 时间格式应为 330 / 5:30 / 1:05:30,收到:${JSON.stringify(v)}`)
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(`--${name} 时间格式非法:${JSON.stringify(v)}`)
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(`--p 分P序号应为 1~200 的整数,收到:${JSON.stringify(v)}`)
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.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": {