flowocr 0.1.0__py3-none-any.whl

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.
Files changed (91) hide show
  1. flowocr/__init__.py +15 -0
  2. flowocr/analyze/__init__.py +8 -0
  3. flowocr/analyze/align.py +151 -0
  4. flowocr/analyze/build_tracks.py +2886 -0
  5. flowocr/analyze/cluster_layers.py +155 -0
  6. flowocr/analyze/game_align.py +652 -0
  7. flowocr/analyze/gamescript.py +1220 -0
  8. flowocr/analyze/gtdbundle.py +306 -0
  9. flowocr/analyze/match.py +67 -0
  10. flowocr/analyze/matchers/__init__.py +5 -0
  11. flowocr/analyze/matchers/gametext.py +70 -0
  12. flowocr/analyze/merge_nameplate.py +178 -0
  13. flowocr/analyze/models/slot_pair.json +148 -0
  14. flowocr/analyze/nameplate.py +148 -0
  15. flowocr/analyze/pair_features.py +168 -0
  16. flowocr/analyze/pair_model.py +127 -0
  17. flowocr/analyze/refine_boundaries.py +409 -0
  18. flowocr/analyze/script_align.py +641 -0
  19. flowocr/analyze/scriptmatch.py +1018 -0
  20. flowocr/analyze/slot_learned.py +306 -0
  21. flowocr/analyze/slot_lines.py +251 -0
  22. flowocr/analyze/slot_modes.py +143 -0
  23. flowocr/analyze/slot_pairs.py +562 -0
  24. flowocr/analyze/slot_veto.py +104 -0
  25. flowocr/analyze/uigate.py +351 -0
  26. flowocr/artifacts/__init__.py +5 -0
  27. flowocr/artifacts/evalkit.py +123 -0
  28. flowocr/artifacts/matchedio.py +75 -0
  29. flowocr/artifacts/srtio.py +166 -0
  30. flowocr/artifacts/tracksio.py +345 -0
  31. flowocr/extensions.py +77 -0
  32. flowocr/extract/__init__.py +8 -0
  33. flowocr/extract/childproc.py +118 -0
  34. flowocr/extract/decode_proc.py +511 -0
  35. flowocr/extract/decode_shards.py +574 -0
  36. flowocr/extract/detpost.py +215 -0
  37. flowocr/extract/edge_proc.py +314 -0
  38. flowocr/extract/edge_refine.py +598 -0
  39. flowocr/extract/fast_det.py +214 -0
  40. flowocr/extract/ffcheck.py +87 -0
  41. flowocr/extract/framegrid.py +265 -0
  42. flowocr/extract/framesource.py +1264 -0
  43. flowocr/extract/ocr_args.py +754 -0
  44. flowocr/extract/ocr_complete.py +231 -0
  45. flowocr/extract/ocr_parallel.py +208 -0
  46. flowocr/extract/ort_server.py +632 -0
  47. flowocr/extract/ortclient.py +363 -0
  48. flowocr/extract/ptsclock.py +150 -0
  49. flowocr/extract/recdecode.py +78 -0
  50. flowocr/extract/recort.py +162 -0
  51. flowocr/extract/recpack.py +94 -0
  52. flowocr/extract/recpool.py +206 -0
  53. flowocr/extract/recprep.py +71 -0
  54. flowocr/extract/refine_video.py +271 -0
  55. flowocr/extract/regions.py +387 -0
  56. flowocr/extract/reuse_v2.py +593 -0
  57. flowocr/extract/run_groups.py +247 -0
  58. flowocr/extract/run_ocr2.py +1605 -0
  59. flowocr/extract/supervisor.py +261 -0
  60. flowocr/extract/timeline.py +84 -0
  61. flowocr/extract/typewriter_fuse.py +394 -0
  62. flowocr/models.py +263 -0
  63. flowocr/output/__init__.py +3 -0
  64. flowocr/output/export.py +205 -0
  65. flowocr/output/layout.py +210 -0
  66. flowocr/output/presets/__init__.py +6 -0
  67. flowocr/output/presets/_overlay.py +40 -0
  68. flowocr/output/presets/default.py +21 -0
  69. flowocr/output/presets/default_all.py +20 -0
  70. flowocr/output/presets/dev.py +21 -0
  71. flowocr/output/presets/matched_srt.py +32 -0
  72. flowocr/output/presets/script.py +20 -0
  73. flowocr/output/presets/srt_main.py +33 -0
  74. flowocr/output/render.py +82 -0
  75. flowocr/output/run_srt.py +83 -0
  76. flowocr/output/script.py +541 -0
  77. flowocr/paths.py +168 -0
  78. flowocr/provenance.py +119 -0
  79. flowocr/typeset/__init__.py +9 -0
  80. flowocr/typeset/__main__.py +38 -0
  81. flowocr/typeset/assfile.py +216 -0
  82. flowocr/typeset/core.py +821 -0
  83. flowocr/typeset/fx/__init__.py +11 -0
  84. flowocr-0.1.0.dist-info/METADATA +109 -0
  85. flowocr-0.1.0.dist-info/RECORD +91 -0
  86. flowocr-0.1.0.dist-info/WHEEL +5 -0
  87. flowocr-0.1.0.dist-info/entry_points.txt +10 -0
  88. flowocr-0.1.0.dist-info/licenses/LICENSE +674 -0
  89. flowocr-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +201 -0
  90. flowocr-0.1.0.dist-info/licenses/LICENSES/PP-OCRv6-NOTICE.md +18 -0
  91. flowocr-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,574 @@
1
+ """解码分片(`run_ocr2 --decode-shards`,decode-buffer):几路 ffmpeg 交替解**相邻的时间块**,按帧号顺序交帧。
2
+
3
+ 为什么:硬解之后每一帧(60 fps 的辅助流)都要 ffmpeg 自己的 CUDA 上下文跑一次 `scale_cuda` 核函数;只解码实测负载下一路慢 2 倍、
4
+ 3 路 −55%,管线里 quick 六段 −4.9%。**病因是推测**(Windows 上不同进程的 CUDA 上下文分时、小核排在 det / rec 后面),
5
+ 按上下文的 GPU 时间线没量过;分片有效是实测。
6
+
7
+ 形状(原型 a1-mask-reuse 实验里的 `shard_proto.py` 量过):
8
+
9
+ * **块**:窗口按约 `block_sec` 切,块边界对齐**关键帧**(`ffprobe` 只读包头,**边探边切**——整片 f4 探完要 41 s,起流不能等它),
10
+ 所以每块从关键帧起解、不多解一帧。第一块从窗口起点走普通路径(文件头有前摇的 av1 要 `-ignore_editlist` 平移,§8.6);
11
+ 剩下不到半块时不再切(末块并进上一块,免得为几帧起一个 ffmpeg)。每块一个 `FfmpegSource(block_seek=…)`:
12
+ 身份核对(showinfo 的 `n` / pts 帧号 / 帧号唯一 / 硬解的格子核对)**原样逐块做**;块和块之间不重不漏靠帧号区间 `[lo, hi)`。
13
+ * **几路"谁空谁领下一块"**:每路各自选硬解(`hw`)或软解(`sw`,CPU 那一路);快慢自动配平。
14
+ * **按序、边解边交**:同时在解 / 解完没交完的块最多 `ahead` 个(默认 = 路数);当前那一块**解出一帧就交一帧**。
15
+ 采样帧按块序从 `__iter__` 交出,辅助流由转发线程按块序写给回抠进程(它看到的和一路 ffmpeg 直连时一样:一条 TCP 流 + 逐帧 pts)。
16
+ * **内存按字节封顶**(2026-09-24 Codex 审计 P2:块数有界不等于内存有界——长 GOP 能切出 150 s 的块、4K 上一块 20 s 就是 2 GB):
17
+ **没有消费者在等**的块,各路往里放东西前先看总缓冲过没过 `budget_mb`,过了就停着(ffmpeg 跟着阻塞在管道上)。
18
+ **有消费者在等的块永远放行**——于是不会两路互等:等着的那一路总有东西来,它拿走之后缓冲就降下去。
19
+ 代价:超预算之后预取变浅,几路并行的好处打折;上限约是预算 + 消费者等待时顺带解出来的那几帧。
20
+ * **每块收尾核一次这一块的尾巴**:块自己核了"块起点 -> 最后一帧"之间的缺格,块尾(最后一帧 -> 块终点)由这里核——
21
+ 整块一帧没交(Codex 审计 P1:首块空时原来整段放过去、`complete` 照样为真)也落在这一步。缺格问 `probe_source_frames`:
22
+ 源里有却没交付 = 解码出错(硬解那路抛 `HwaccelMismatch` -> 整段改软解重跑;软解那路抛 RuntimeError);源里没有 = 素材缺口、放行;
23
+ 探不出来:硬解那路保守当吞帧,软解那路放行、记 `probe_unknown`。
24
+ 最后一块的块尾只核"源里有没有"、不计缺格(单路也不数窗口尾,那一截归覆盖率那道门)。
25
+ * **失败一处、全停**:块自己的错按块序抛(同单路);辅助流转发自己的错(连不上 / 写不进 / pts 送不出)和规划失败进**共享的失败状态**,
26
+ 唤醒所有等待方、停掉在解的 ffmpeg,由 `__iter__` 抛出(原来转发的错被吞掉:块数 ≤ 路数时无声少了辅助流,多一块就永远卡住)。
27
+ 辅助流少交(半路断了 / 不被预算挡着却 60 s 没前进 / pts 对不上)也是这一块的错,不当成"读完了"。
28
+
29
+ 没 GPU 时由调用方把各路都换成 `sw`(owner:每个操作都要有能跑的 CPU 版,不求调度一样)。
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import socket
34
+ import subprocess
35
+ import threading
36
+ import time
37
+ from collections.abc import Iterable, Iterator
38
+
39
+ from flowocr.extract import framesource
40
+ from flowocr.extract import timeline as _tl
41
+ from flowocr.extract.framegrid import AuxGrid, TimeGrid
42
+
43
+ BUDGET_MB = 1024
44
+ """纯预取(没有消费者在等的块)最多攒这么多字节。1080p60、采样 2 fps + 0.35 辅助流约 28 MB / 秒素材,即约 37 s。"""
45
+
46
+
47
+ class KeyframeStream:
48
+ """`[t0, t1)` 里关键帧的 pts 秒,**边读边给**(ffprobe 只读包头,不解码)。只认递增的(解码序里偶有回头的不算)。
49
+ `close()` 杀掉 ffprobe;读完后 `rc` 是它的退出码。"""
50
+
51
+ def __init__(self, video: str, t0: float, t1: float) -> None:
52
+ import tempfile
53
+
54
+ self.video, self.t0, self.t1 = video, t0, t1
55
+ self.rc: int | None = None
56
+ self.err = tempfile.TemporaryFile() # stderr 落临时文件(管道不读会满;DEVNULL 会把出错原因丢掉)
57
+ self.proc = subprocess.Popen(
58
+ ["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals", framesource.probe_interval(t0, t1),
59
+ "-show_entries", "packet=pts_time,flags", "-of", "csv=p=0", video],
60
+ stdout=subprocess.PIPE, stderr=self.err, text=True)
61
+
62
+ def __iter__(self) -> Iterator[float]:
63
+ last = None
64
+ for ln in self.proc.stdout: # type: ignore[union-attr]
65
+ parts = ln.strip().split(",")
66
+ if len(parts) < 2 or "K" not in parts[1]:
67
+ continue
68
+ try:
69
+ t = float(parts[0])
70
+ except ValueError:
71
+ continue
72
+ if self.t0 <= t < self.t1 and (last is None or t > last):
73
+ last = t
74
+ yield t
75
+ self.rc = self.proc.wait()
76
+
77
+ def stderr_tail(self) -> str:
78
+ self.err.seek(0)
79
+ return self.err.read().decode("utf-8", "replace").strip()[-300:]
80
+
81
+ def close(self) -> None:
82
+ if self.proc.poll() is None:
83
+ self.proc.kill()
84
+ try:
85
+ self.proc.wait(timeout=10)
86
+ except subprocess.TimeoutExpired:
87
+ pass
88
+
89
+
90
+ def iter_blocks(kfs: Iterable[float], start_idx: int, end_idx: int, src_fps: float,
91
+ block_sec: float) -> Iterator[tuple[int, int, float | None]]:
92
+ """`(lo, hi, 关键帧秒或 None)`,关键帧来一个判一个(**纯函数**,守卫直接测):第一块从窗口起点起(None = 普通路径),
93
+ 之后在"上一块起点 + block_sec"之后的第一个关键帧处切——**切完剩下的不到半块就不切**(末块并进上一块)。
94
+ 关键帧秒加 0.5 ms 给 `-ss`:seek 找的是"不晚于目标"的关键帧,ffprobe 印出来的秒数往下舍入时会落到前一个关键帧上(多解一段,结果仍对)。"""
95
+ span = block_sec * src_fps
96
+ lo, seek = start_idx, None
97
+ for kf in kfs:
98
+ k = round(kf * src_fps)
99
+ if lo + span <= k and k + span / 2 <= end_idx:
100
+ yield lo, k, seek
101
+ lo, seek = k, kf + 0.0005
102
+ yield lo, end_idx, seek
103
+
104
+
105
+ def plan_blocks(video: str, start_idx: int, end_idx: int, src_fps: float, block_sec: float,
106
+ kfs: list[float] | None = None) -> list[tuple[int, int, float | None]]:
107
+ """整张块表(给了 `kfs` 就不探)。管线里用 `BlockPlan`(边探边给)。"""
108
+ if kfs is None:
109
+ ks = KeyframeStream(video, start_idx / src_fps, end_idx / src_fps)
110
+ try:
111
+ return list(iter_blocks(ks, start_idx, end_idx, src_fps, block_sec))
112
+ finally:
113
+ ks.close()
114
+ return list(iter_blocks(kfs, start_idx, end_idx, src_fps, block_sec))
115
+
116
+
117
+ class BlockPlan:
118
+ """管线用的块表:ffprobe 边探关键帧、边吐块(`ShardedSource` 的规划线程读它)。`close()` 停掉 ffprobe。"""
119
+
120
+ def __init__(self, video: str, start_idx: int, end_idx: int, src_fps: float, block_sec: float) -> None:
121
+ self.ks = KeyframeStream(video, start_idx / src_fps, end_idx / src_fps)
122
+ self.args = (start_idx, end_idx, src_fps, block_sec)
123
+ self.error = "" # 非空 = ffprobe 出错、后面没切开的部分由一路整段解(进 `_meta.decode_shards.plan_error`)
124
+
125
+ def __iter__(self) -> Iterator[tuple[int, int, float | None]]:
126
+ yield from iter_blocks(self.ks, *self.args)
127
+ if self.ks.rc:
128
+ self.error = f"ffprobe 探关键帧退出码 {self.ks.rc}:{self.ks.stderr_tail()}"
129
+ print(f"[解码分片] ⚠ {self.error}——后面没切开的部分由一路整段解", flush=True)
130
+
131
+ def close(self) -> None:
132
+ self.ks.close()
133
+
134
+
135
+ class TcpAux:
136
+ """辅助流写给**一个**回抠进程:像素走本机 TCP(它 listen 的 `url`),逐帧 pts 走 `pts_put`(同一路 ffmpeg 直连时的形状)。
137
+ `ShardedSource` 的 `aux_remote` 也可以是任何带 `open()` 的对象(返回同样有 `send` / `close` 的写端)——
138
+ 多组共用解码时是 `decode_proc.AuxFanout`,一份辅助流按订阅者各一条有界队列转出去。"""
139
+
140
+ def __init__(self, remote) -> None:
141
+ host, port = remote.url.removeprefix("tcp://").rsplit(":", 1)
142
+ self.remote = remote
143
+ self.sock = socket.create_connection((host, int(port)), timeout=60)
144
+ self.sock.settimeout(None)
145
+
146
+ def send(self, frame, n: int, t_sec: float) -> None:
147
+ self.sock.sendall(memoryview(frame).cast("B"))
148
+ self.remote.pts_put((n, t_sec))
149
+
150
+ def close(self) -> None:
151
+ try:
152
+ self.remote.pts_put(framesource.QUEUE_SENTINEL)
153
+ except Exception: # noqa: BLE001
154
+ pass
155
+ try:
156
+ self.sock.close()
157
+ except OSError:
158
+ pass
159
+
160
+
161
+ def _nbytes(item) -> int:
162
+ return getattr(item[-1], "nbytes", 0)
163
+
164
+
165
+ class _Block:
166
+ """一块的交付状态:两路各自按到达顺序追加,`done` 之后不再长;读的人按下标往后拿(`ShardedSource` 的条件变量通知)。"""
167
+
168
+ def __init__(self, owner: "ShardedSource", k: int, kind: str, hi: int = 1 << 62) -> None:
169
+ self.owner, self.k, self.kind, self.hi = owner, k, kind, hi
170
+ self.main: list = []
171
+ self.aux: list = []
172
+ self.main_done = self.aux_done = False
173
+ self.read_main = self.read_aux = False
174
+ self.err: BaseException | None = None
175
+ self.src = None
176
+ self.nbytes = 0 # 这一块还攒着没被拿走的字节
177
+ self.waiting = 0 # 正在等这一块的消费者数(> 0 时各路往里放不受预算限制)
178
+ self.last_main: int | None = None
179
+ self.last_aux: int | None = None
180
+ self.throttled = False # 有一路正被字节预算挡着(它不前进是在等下游,不算卡住)
181
+
182
+ # `AuxReader` 的 sink 接口:辅助流一帧一帧推进来
183
+ def push(self, key, frame) -> None:
184
+ o = self.owner
185
+ with o._cv:
186
+ o._admit(self, getattr(frame, "nbytes", 0))
187
+ self.aux.append((key, frame))
188
+ self.last_aux = key
189
+ o._cv.notify_all()
190
+
191
+ def close(self) -> None:
192
+ with self.owner._cv:
193
+ self.aux_done = True
194
+ self.owner._cv.notify_all()
195
+
196
+
197
+ class ShardedSource:
198
+ """和 `FfmpegSource` 同接口的取帧层:`__iter__` 按帧号顺序吐 `(帧号, 时刻, BGR 帧)`,结束后 `stopped_early` / `advanced` /
199
+ `grid_off` / `grid_skipped` / `stderr_tail` 有效。`aux_remote`(回抠进程的 `url` + `pts_put`)给了就按块序转发辅助流。
200
+ `blocks`:块表(列表),或边探边给的可迭代对象(`BlockPlan`,有 `close` 的话收摊时调)。"""
201
+
202
+ def __init__(self, video: str, *, lanes: list[str], blocks: Iterable[tuple[int, int, float | None]],
203
+ start_idx: int, end_idx: int, grid: TimeGrid, src_fps: float, width: int, height: int,
204
+ pix: str = "nv12", select_by: str = "pts", hwaccel: str | None = None, aux_remote=None,
205
+ aux_grid: list[int] | None = None, aux_scale: float = 0.35, ahead: int | None = None,
206
+ budget_mb: float = BUDGET_MB) -> None:
207
+ if not lanes or any(k not in ("hw", "sw") for k in lanes):
208
+ raise ValueError(f"解码分片的路只认 hw / sw:{lanes}")
209
+ if "hw" in lanes and not hwaccel:
210
+ raise ValueError("有 hw 路却没给 hwaccel(没 GPU 时调用方要把各路换成 sw)")
211
+ if select_by != "pts":
212
+ raise ValueError("解码分片只配按 pts 选帧(块和块之间按帧号区间接缝)")
213
+ self.video, self.lanes, self.grid, self.src_fps = video, lanes, grid, src_fps
214
+ self.W, self.H, self.pix, self.select_by, self.hw = width, height, pix, select_by, hwaccel
215
+ # 辅助流取哪些帧(`AuxGrid.as_list()`;没给 = 每帧都要)
216
+ self.aux_grid = AuxGrid.from_list(aux_grid) if aux_grid else AuxGrid(TimeGrid(1, 1), grid)
217
+ self.aux_remote, self.aux_scale = aux_remote, aux_scale
218
+ self.ahead = max(1, ahead or len(lanes))
219
+ self.budget = int(budget_mb * (1 << 20))
220
+ self.start_idx, self.end_idx = start_idx, end_idx
221
+ self.hwaccel = hwaccel if "hw" in lanes else None
222
+ self.preroll_ticks = 0
223
+ self.aux = None # 辅助流不挂在这边(转发给回抠进程)
224
+ self.stopped_early = False
225
+ self.advanced = 0
226
+ self.grid_off: dict[int, int] = {}
227
+ self.grid_skipped = 0
228
+ self.stderr_tail: list[str] = []
229
+ self.lane_blocks = [0] * len(lanes)
230
+ self.lane_sec = [0.0] * len(lanes)
231
+ self.peak_mb = 0.0 # 缓冲的峰值(报表用)
232
+ self.probe_unknown = 0 # 软解路块尾缺格、源探不出来、放行了的次数
233
+ self._cv = threading.Condition()
234
+ if isinstance(blocks, list):
235
+ self.blocks, self._plan, self._planned = list(blocks), None, True
236
+ else:
237
+ self.blocks, self._plan, self._planned = [], blocks, False
238
+ self._blk: dict[int, _Block] = {} # 块号 -> 交付状态(领到时建,两路都读完时删)
239
+ self._next = 0
240
+ self._sem = threading.Semaphore(self.ahead)
241
+ self._closed = False
242
+ self._fail: BaseException | None = None # 共享的失败状态(转发 / 规划的错;块自己的错在 `_Block.err`)
243
+ self._buffered = 0
244
+ self._live: dict[int, framesource.FfmpegSource] = {}
245
+
246
+ @property
247
+ def plan_error(self) -> str:
248
+ return getattr(self._plan, "error", "")
249
+
250
+ def describe(self) -> str:
251
+ n = f"{len(self.blocks)} 块" if self._planned else "边探关键帧边切块"
252
+ return (f"解码分片:{n}、{len(self.lanes)} 路({'/'.join(self.lanes)})、最多 {self.ahead} 块在途、"
253
+ f"预取封顶 {self.budget >> 20} MB、边解边交" + ("、辅助流按块序转发给回抠进程" if self.aux_remote is not None else ""))
254
+
255
+ # ---------- 共享状态(调用方持锁的标 `# 持锁`) ----------
256
+ def _block_ready(self, k: int) -> bool:
257
+ """块 k 规划出来了没有(持锁;等规划线程)。规划完了还没有 / 收摊 / 已失败 = False。"""
258
+ while k >= len(self.blocks) and not self._planned:
259
+ if self._closed or self._fail is not None:
260
+ return False
261
+ self._cv.wait(0.5)
262
+ return k < len(self.blocks) and not self._closed and self._fail is None
263
+
264
+ def _admit(self, b: _Block, n: int) -> None:
265
+ """往块 b 里放 n 字节之前(持锁):没人在等 b、而总缓冲会过预算,就等。
266
+ **主流已经交到这块最后一个格**时不挡(2026-09-24 审核第三轮):那之后 `FfmpegSource` 会自己收摊(关管道、等 10 s、不退就杀),
267
+ ffmpeg 还没写完的辅助帧要是被预算挡在这里,它就卡在写上被杀掉、辅助流真的被截断。块尾这几帧(不到一个采样间隔)不是预取。"""
268
+ def main_eof() -> bool: # 每轮重算:等的时候主流可能正好交到块尾(审计 2026-09-25,Codex P1)
269
+ return b.last_main is not None and self.grid.next(b.last_main) >= b.hi
270
+ while (self._buffered + n > self.budget and b.waiting == 0 and not main_eof()
271
+ and not self._closed and self._fail is None):
272
+ b.throttled = True
273
+ self._cv.wait(0.5)
274
+ b.throttled = False
275
+ self._buffered += n
276
+ b.nbytes += n
277
+ self.peak_mb = max(self.peak_mb, self._buffered / (1 << 20))
278
+
279
+ def _set_fail(self, exc: BaseException) -> None:
280
+ with self._cv:
281
+ if self._fail is None:
282
+ self._fail = exc
283
+ self._cv.notify_all()
284
+ self._stop_live()
285
+
286
+ def _stop_live(self) -> None:
287
+ for src in list(self._live.values()):
288
+ try:
289
+ src.close()
290
+ except Exception: # noqa: BLE001
291
+ pass
292
+
293
+ # ---------- 规划 ----------
294
+ def _planner(self) -> None:
295
+ """把边探边给的块表搬进 `self.blocks`,顺带核"首尾相接、盖满窗口"(不满足就是规划错,不往下解)。"""
296
+ try:
297
+ prev_hi = self.start_idx
298
+ for lo, hi, seek in self._plan: # type: ignore[union-attr]
299
+ if lo != prev_hi or hi <= lo:
300
+ raise RuntimeError(f"块表不连续:上一块到 {prev_hi},这一块 [{lo}, {hi})")
301
+ prev_hi = hi
302
+ with self._cv:
303
+ if self._closed or self._fail is not None:
304
+ return
305
+ self.blocks.append((lo, hi, seek))
306
+ self._cv.notify_all()
307
+ if prev_hi != self.end_idx:
308
+ raise RuntimeError(f"块表只盖到 {prev_hi},窗口到 {self.end_idx}")
309
+ except BaseException as exc: # noqa: BLE001
310
+ self._set_fail(RuntimeError(f"解码分片的块规划失败:{exc!r}"))
311
+ finally:
312
+ with self._cv:
313
+ self._planned = True
314
+ self._cv.notify_all()
315
+
316
+ # ---------- 各路 ----------
317
+ def _lane(self, i: int, kind: str) -> None:
318
+ while True:
319
+ self._sem.acquire()
320
+ with self._cv:
321
+ k = self._next
322
+ self._next += 1 # 先占号再等规划:几路不会领到同一块
323
+ if not self._block_ready(k):
324
+ self._sem.release()
325
+ return
326
+ lo, hi, seek = self.blocks[k]
327
+ b = self._blk[k] = _Block(self, k, kind, hi)
328
+ if self.aux_remote is None:
329
+ b.aux_done = b.read_aux = True
330
+ self._cv.notify_all()
331
+ t0 = time.perf_counter()
332
+ try:
333
+ src = framesource.FfmpegSource(
334
+ self.video, grid=self.grid, start_idx=lo, end_idx=hi, src_fps=self.src_fps,
335
+ width=self.W, height=self.H, pix=self.pix, select_by=self.select_by,
336
+ hwaccel=self.hw if kind == "hw" else None, block_seek=seek, block=True,
337
+ preroll_ticks=None if seek is None else 0,
338
+ aux=({"sink": b, "grid": self.aux_grid.as_list(), "scale": self.aux_scale}
339
+ if self.aux_remote is not None else None))
340
+ b.src = src
341
+ with self._cv: # 和 close() 同一把锁:要么这里看见收摊,要么 close() 看见这一路
342
+ if self._closed or self._fail is not None:
343
+ src.close()
344
+ raise RuntimeError(f"第 {k} 块没开解:已经收摊")
345
+ self._live[i] = src
346
+ for item in src:
347
+ with self._cv:
348
+ self._admit(b, _nbytes(item))
349
+ b.main.append(item)
350
+ b.last_main = item[0]
351
+ self._cv.notify_all()
352
+ if self.aux_remote is not None and src.aux is not None:
353
+ self._join_aux(k, b, src.aux)
354
+ if src.aux.misaligned:
355
+ raise RuntimeError(f"第 {k} 块的辅助流 pts 对不上:{src.aux.misaligned}")
356
+ if b.last_main is not None and (b.last_aux is None or b.last_aux < self.aux_grid.cover_floor(b.last_main)):
357
+ # 同一个 ffmpeg 出两路:辅助流至少要盖到这一块最后一个采样帧(`AuxGrid.cover_floor`)
358
+ raise RuntimeError(f"第 {k} 块的辅助流只到帧 {b.last_aux}、采样流到了 {b.last_main}(辅助流半路断了)")
359
+ except BaseException as exc: # noqa: BLE001 交给读的人按块序抛
360
+ b.err = exc
361
+ finally:
362
+ self._live.pop(i, None)
363
+ dt = _tl.span("shard_block", t0, a=k) - t0
364
+ with self._cv:
365
+ self.lane_blocks[i] += 1
366
+ self.lane_sec[i] += dt
367
+ b.main_done = True
368
+ if b.err is not None:
369
+ b.aux_done = True
370
+ self._cv.notify_all()
371
+
372
+ STALL_SEC = 60.0
373
+
374
+ def _join_aux(self, k: int, b: _Block, aux) -> None:
375
+ """等这一块的辅助流收完(ffmpeg 这时已经退了,只剩 socket 里的)。**不拿固定超时当正确性信号**(2026-09-24 审核):
376
+ 被字节预算挡着是在等下游(回抠进程 / 管线慢,CPU 冒烟时一块能等几分钟),只有**不被挡、又 STALL_SEC 秒没前进**才算卡住。"""
377
+ last, t_last = b.last_aux, time.perf_counter()
378
+ while aux.th.is_alive():
379
+ aux.join(1.0)
380
+ if self._closed or self._fail is not None:
381
+ return
382
+ now = time.perf_counter()
383
+ if b.last_aux != last or b.throttled:
384
+ last, t_last = b.last_aux, now
385
+ elif now - t_last > self.STALL_SEC:
386
+ raise RuntimeError(f"第 {k} 块的辅助流 {self.STALL_SEC:.0f} s 没前进(ffmpeg 已退,停在帧 {b.last_aux})")
387
+
388
+ def _items(self, k: int, which: str):
389
+ """块 k 的一路(`main` / `aux`)按到达顺序往外拿,拿过的位置置空(不留内存);块出错 / 共享失败就抛。"""
390
+ t0 = time.perf_counter()
391
+ with self._cv:
392
+ while k not in self._blk:
393
+ if self._fail is not None:
394
+ raise self._fail
395
+ if self._closed:
396
+ return
397
+ self._cv.wait(0.5)
398
+ b = self._blk[k]
399
+ _tl.span("shard_wait", t0, a=k)
400
+ seq = b.main if which == "main" else b.aux
401
+ j = 0
402
+ while True:
403
+ with self._cv:
404
+ while True:
405
+ if self._fail is not None: # 先报根因:共享失败会停掉在解的 ffmpeg,那几块跟着出的错是次生的
406
+ raise self._fail
407
+ if b.err is not None:
408
+ raise b.err
409
+ if j < len(seq):
410
+ item, seq[j] = seq[j], None
411
+ n = _nbytes(item)
412
+ self._buffered -= n
413
+ b.nbytes -= n
414
+ self._cv.notify_all() # 缓冲降了:被预算挡着的那几路可以接着放
415
+ break
416
+ if b.main_done if which == "main" else b.aux_done:
417
+ return
418
+ if self._closed:
419
+ return
420
+ b.waiting += 1
421
+ self._cv.notify_all() # 被预算挡着的那一路:有人在等你了(不等它 0.5 s 轮询)
422
+ self._cv.wait(0.5)
423
+ b.waiting -= 1
424
+ j += 1
425
+ yield item
426
+
427
+ def _release(self, k: int, which: str) -> None:
428
+ """块 k 的一路读完了;两路都读完就把块删掉、放出一个名额给下一块。"""
429
+ with self._cv:
430
+ b = self._blk.get(k)
431
+ if b is None:
432
+ return
433
+ if which == "main":
434
+ b.read_main = True
435
+ else:
436
+ b.read_aux = True
437
+ if b.read_main and b.read_aux:
438
+ self._buffered -= b.nbytes
439
+ del self._blk[k]
440
+ self._sem.release()
441
+ self._cv.notify_all()
442
+
443
+ # ---------- 辅助流转发(按块序) ----------
444
+ def _forward(self) -> None:
445
+ n, k = 0, 0
446
+ w = None
447
+ try:
448
+ try:
449
+ w = self.aux_remote.open() if hasattr(self.aux_remote, "open") else TcpAux(self.aux_remote)
450
+ except OSError as exc:
451
+ self._set_fail(RuntimeError(f"辅助流转发连不上回抠进程:{exc!r}"))
452
+ return
453
+ while True:
454
+ with self._cv:
455
+ if not self._block_ready(k):
456
+ return
457
+ it = self._items(k, "aux")
458
+ while True:
459
+ try:
460
+ key, frame = next(it)
461
+ except StopIteration:
462
+ break
463
+ except BaseException: # noqa: BLE001 块自己的错 / 已经失败:主路按块序抛同一个
464
+ return
465
+ try:
466
+ w.send(frame, n, key / self.src_fps)
467
+ except BaseException as exc: # noqa: BLE001 转发自己的错:主路不知道,必须进共享失败状态
468
+ self._set_fail(RuntimeError(f"辅助流转发失败(第 {k} 块、第 {n} 帧):{exc!r}"))
469
+ return
470
+ n += 1
471
+ self._release(k, "aux")
472
+ k += 1
473
+ finally:
474
+ if w is not None:
475
+ w.close()
476
+ elif not hasattr(self.aux_remote, "open"):
477
+ try:
478
+ self.aux_remote.pts_put(framesource.QUEUE_SENTINEL) # 连都没连上:也要让那边的 pts 队列收尾
479
+ except Exception: # noqa: BLE001
480
+ pass
481
+
482
+ # ---------- 块尾核对 ----------
483
+ def _check_tail(self, k: int, is_last: bool) -> None:
484
+ """块 k 的"最后一帧 -> 块终点"之间(整块没交就是整块)有没有该交没交的格。块自己核过起点到最后一帧,这里不重数。"""
485
+ lo, hi, _ = self.blocks[k]
486
+ b = self._blk[k]
487
+ frm = lo if b.last_main is None else b.last_main + 1
488
+ tail = self.grid.frames(frm, hi - 1)
489
+ if not tail:
490
+ return
491
+ eaten = framesource.probe_source_frames(self.video, tail, self.src_fps)
492
+ if eaten is None: # 探不出来:硬解路保守(当吞帧、整段退软解),软解路放行 + 记一笔
493
+ if b.kind == "hw":
494
+ eaten = tail
495
+ else:
496
+ self.probe_unknown += 1
497
+ eaten = []
498
+ if eaten:
499
+ what = (f"第 {k} 块({b.kind})源里有、却没交付的格 {len(eaten)} 个(首个帧 {eaten[0]};"
500
+ f"这一块交到 {b.last_main},块是 [{lo}, {hi}))")
501
+ if b.kind == "hw":
502
+ raise framesource.HwaccelMismatch(f"解码分片:{what}")
503
+ raise RuntimeError(f"解码分片:{what}。ffmpeg 最后几行:{list(b.src.stderr_tail) if b.src else []}")
504
+ if not is_last: # 窗口尾那一截同单路:不数缺格,归覆盖率那道门
505
+ self.grid_skipped += len(tail)
506
+
507
+ # ---------- 消费者 ----------
508
+ def __iter__(self):
509
+ ths = [threading.Thread(target=self._lane, args=(i, k), daemon=True, name=f"shard-{i}-{k}")
510
+ for i, k in enumerate(self.lanes)]
511
+ if self._plan is not None:
512
+ ths.append(threading.Thread(target=self._planner, daemon=True, name="shard-plan"))
513
+ fwd = None
514
+ if self.aux_remote is not None:
515
+ fwd = threading.Thread(target=self._forward, daemon=True, name="shard-aux-fwd")
516
+ ths.append(fwd)
517
+ for th in ths:
518
+ th.start()
519
+ prev_idx: int | None = None
520
+ try:
521
+ k = 0
522
+ while True:
523
+ with self._cv:
524
+ if not self._block_ready(k):
525
+ break
526
+ for idx, t, frame in self._items(k, "main"):
527
+ if prev_idx is not None and idx <= prev_idx:
528
+ raise RuntimeError(f"解码分片的帧号不单调:第 {k} 块的帧 {idx} 不大于上一帧 {prev_idx}(块边界重叠)")
529
+ prev_idx = idx
530
+ self.advanced = self.grid.next(idx) - self.start_idx
531
+ yield idx, t, frame
532
+ with self._cv:
533
+ if self._fail is not None:
534
+ raise self._fail
535
+ if self._closed:
536
+ break
537
+ is_last = not self._block_ready(k + 1)
538
+ if self._fail is not None:
539
+ raise self._fail
540
+ self._check_tail(k, is_last)
541
+ src = self._blk[k].src
542
+ for off, c in src.grid_off.items():
543
+ self.grid_off[off] = self.grid_off.get(off, 0) + c
544
+ self.grid_skipped += src.grid_skipped
545
+ if is_last: # 同单路:读到头之前断了。中间块按 -t 收口、有缺口时凑不满数,不算
546
+ self.stopped_early = src.stopped_early
547
+ self.stderr_tail = (self.stderr_tail + list(src.stderr_tail))[-8:]
548
+ if k == 0:
549
+ self.preroll_ticks = src.preroll_ticks
550
+ self._release(k, "main")
551
+ k += 1
552
+ with self._cv:
553
+ if self._fail is not None:
554
+ raise self._fail
555
+ if fwd is not None:
556
+ # 辅助流转发完再收摊(回抠进程要读到 EOF 和 pts 的结束标记);转发线程自己出错会进共享失败状态
557
+ while fwd.is_alive():
558
+ fwd.join(1.0)
559
+ if self._fail is not None:
560
+ raise self._fail
561
+ finally:
562
+ self.close()
563
+
564
+ def close(self) -> None:
565
+ """收摊:不再领新块,正在解的那几块的 ffmpeg 收掉,边探的 ffprobe 停掉。"""
566
+ with self._cv:
567
+ self._closed = True
568
+ self._cv.notify_all()
569
+ self._stop_live()
570
+ closer = getattr(self._plan, "close", None)
571
+ if closer is not None:
572
+ closer()
573
+ for _ in range(len(self.lanes) + 1):
574
+ self._sem.release()