@pls-compile-owo/fisheye 0.8.3 → 0.8.5

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 CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 规范与 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.8.5] - 2026-11-14
6
+
7
+ ### Added
8
+
9
+ - **片头广告识别**:视频开头 90s 内出现「接广/金主/恰饭/商单/赞助」等强信号弹幕(哪怕 1 条)时,判定 0-60s 为片头广告段(high)。第四个人工真值样本 BV1j6G16dEBF(0-57s 片头广告)暴露的漏报。
10
+
11
+ ## [0.8.4] - 2026-11-14
12
+
13
+ ### Changed
14
+
15
+ - **广告段检测增强**(三条人工真值样本校准):
16
+ - 新增「关键词聚类」信号:广告口播段观众常刷「金主/恰饭/商单」,不依赖密度低谷(42s 短广告常被漏)。
17
+ - 低置信度段最小长度 60s → 45s(覆盖 40-60s 短广告)。
18
+ - 密度低谷段延伸进片头/片尾时改为**截断**(原逻辑整段丢弃会连带丢掉中间真实广告)。
19
+ - 低置信度段向两侧各扩 15s(广告开始前观众还没反应,低密度段偏短)。
20
+ - golden 广告真值样本:BV1WhaA6hE7s / BV1LD421H7u4 / BV1qdb16eEF5(覆盖率分别 100%/90%/100%,0 误报)。
21
+
5
22
  ## [0.8.3] - 2026-11-14
6
23
 
7
24
  ### Fixed
package/lib/danmaku.mjs CHANGED
@@ -27,18 +27,50 @@ export function detectAdSegments(danmaku, duration, bucketSec = 30, stepSec = 10
27
27
  if (last && d.s - last.to <= stepSec * 2) last.to = d.s + bucketSec
28
28
  else spans.push({ from: d.s, to: d.s + bucketSec, count: 0 })
29
29
  }
30
- const long = spans.filter((x) => x.to - x.from >= 60)
31
- if (!long.length) return []
32
-
33
30
  const hits = danmaku.filter((d) => AD_SEGMENT_SIGNAL.test(d.text))
34
- const out = long.map((sp) => {
31
+ // 关键词聚类(补充信号):广告口播段观众常刷「金主/恰饭/商单」等,且此时弹幕密度
32
+ // 未必低谷(观众在吐槽广告)——只看密度会漏掉 40-60s 的短广告。
33
+ const hitTimes = hits.map((h) => h.t).sort((a, b) => a - b)
34
+ const kwSpans = []
35
+ for (const tt of hitTimes) {
36
+ const last = kwSpans[kwSpans.length - 1]
37
+ if (last && tt - last.to <= 60) last.to = tt
38
+ else kwSpans.push({ from: tt, to: tt })
39
+ }
40
+ const kwCandidates = kwSpans.filter(
41
+ (s) => s.to - s.from >= 10 && hitTimes.filter((x) => x >= s.from && x <= s.to).length >= 2)
42
+
43
+ // 低置信度段的最小长度:45s(覆盖 40-60s 的短广告;更短的段不判,宁漏勿错)
44
+ const long = spans.filter((x) => x.to - x.from >= 45)
45
+ if (!long.length && !kwCandidates.length) return []
46
+
47
+ // 合并候选:密度低谷段 ∪ 关键词聚类段(重叠或相近的并成一段)
48
+ const candidates = []
49
+ for (const sp of [...long, ...kwCandidates]) {
50
+ const last = candidates.find((c) => sp.from <= c.to + 30 && sp.to >= c.from - 30)
51
+ if (last) {
52
+ last.from = Math.min(last.from, sp.from)
53
+ last.to = Math.max(last.to, sp.to)
54
+ } else candidates.push({ from: sp.from, to: sp.to })
55
+ }
56
+ // 片头广告:视频开头 90s 内出现「接广/金主/恰饭」等强信号弹幕(哪怕只有 1 条),
57
+ // 视为片头口播广告候选(0 到信号时间+20s)。片头广告常无密度低谷(观众刚到还在刷弹幕)。
58
+ const STRONG_AD = /金主|恰饭|商单|接广|硬广|软广|赞助/i
59
+ const headStrong = danmaku.filter((d) => d.t < 90 && STRONG_AD.test(d.text))
60
+ if (headStrong.length) {
61
+ // 片头广告通常覆盖整个片头(30-60s 口播),固定 0-60s 段
62
+ const headEnd = 60
63
+ if (headEnd > 0) candidates.push({ from: 0, to: headEnd, headAd: true })
64
+ }
65
+
66
+ const out = candidates.map((sp) => {
35
67
  const near = hits.filter((h) => h.t >= sp.from - 20 && h.t <= sp.to + 20)
36
68
  // dens 是「30s 窗、10s 步长」的滑窗,同一条弹幕会被相邻 3 个桶各计一次。
37
69
  // 分母必须用「实际桶数」而不是 (时长/bucketSec),否则 ratio 虚高约 3 倍
38
70
  // (出现 lowDensity = -25% 的负值,因为 ratio > 1)
39
71
  const segBuckets = dens.filter((d) => d.s >= sp.from && d.s < sp.to)
40
72
  const total = segBuckets.reduce((s, d) => s + d.n, 0)
41
- const ratio = total / Math.max(1, segBuckets.length) / median
73
+ const ratio = segBuckets.length ? total / segBuckets.length / median : 0
42
74
  return {
43
75
  from: sp.from,
44
76
  to: sp.to,
@@ -51,7 +83,16 @@ export function detectAdSegments(danmaku, duration, bucketSec = 30, stepSec = 10
51
83
  })
52
84
  // 片头/片尾 60s 内的「低置信度」段丢弃:开头结尾弹幕本来就少,不是广告信号。
53
85
  // 高置信度(有广告关键词)不受此限——片头口播广告仍是广告。
54
- return out.filter((s) => s.confidence === 'high' || (s.from >= 60 && s.to <= duration - 60))
86
+ // 低置信度段截断到 [60s, duration-60s] 再判定:密度低谷可能延伸进片头/片尾的正常低弹幕区,
87
+ // 整段丢弃会连带丢掉中间的真实广告(首次真值样本 BV1qdb16eEF5 暴露)。
88
+ const clamped = out.map((s) => {
89
+ if (s.confidence === 'high') return s
90
+ // 向两侧各扩 15s:广告口播开始前观众还没反应,低密度段会比真实广告短
91
+ const from = Math.max(s.from - 15, 60)
92
+ const to = Math.min(s.to + 15, duration - 60)
93
+ return { ...s, from, to, dur: to - from, ts: `${fmt(from)}-${fmt(to)}` }
94
+ })
95
+ return clamped.filter((s) => s.confidence === 'high' || s.dur >= 45)
55
96
  }
56
97
 
57
98
  // 按时间桶聚合,找「观众最激动」的时段。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pls-compile-owo/fisheye",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
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": {