@torrent-tv/proxy 2.88.6 → 2.89.0

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 (45) hide show
  1. package/AGENTS.md +21 -12
  2. package/CHANGELOG.md +17 -3
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/docs/container-architecture.md +77 -22
  6. package/docs/playback-start-forecast.md +56 -56
  7. package/main2.cpuprofile +1 -0
  8. package/package.json +1 -1
  9. package/scripts/build-audio-samples.mjs +31 -31
  10. package/server.js +42 -27
  11. package/services/encode/OutputOpening.js +5 -4
  12. package/services/encode/audio-calibration.js +67 -67
  13. package/services/encode/audio-work-rate.js +15 -15
  14. package/services/media/ContainerOrchestrator.js +99 -46
  15. package/services/media/KeyframeTables.js +61 -21
  16. package/services/media/SubtitleCues.js +255 -183
  17. package/services/media/container/AviContainer.js +5 -3
  18. package/services/media/container/Container.js +66 -43
  19. package/services/media/container/ContainerFactory.js +8 -26
  20. package/services/media/container/MatroskaContainer.js +719 -1182
  21. package/services/media/container/Mp4Container.js +83 -73
  22. package/services/media/container/ebml-stream.js +174 -0
  23. package/services/media/container/matroska-clusters.js +1165 -0
  24. package/services/media/container/unavailable.js +77 -0
  25. package/services/media/tracks/ContainerTrack.js +9 -0
  26. package/services/media/tracks/TextSubtitleTrack.js +9 -1
  27. package/services/server/playback-readiness.js +516 -516
  28. package/services/torrent/worker/client.js +6 -2
  29. package/services/torrent/worker/held-bytes.js +84 -8
  30. package/services/torrent/worker/worker.js +10 -2
  31. package/services/transport/data-channel-handler.js +7 -3
  32. package/services/viewer/PriorityMap.js +31 -0
  33. package/test/audio-calibration.test.js +23 -23
  34. package/test/coalesce.test.js +39 -0
  35. package/test/held-read-ownership.test.js +77 -0
  36. package/test/helpers/held-file.js +50 -2
  37. package/test/helpers/matroska-file.js +296 -0
  38. package/test/keyframe-table-budget.test.js +21 -0
  39. package/test/matroska-cluster-discovery.test.js +323 -0
  40. package/test/reads-not-yet-arrived.test.js +218 -0
  41. package/test/subtitle-cue-walk.test.js +16 -1
  42. package/test/subtitle-plan-waits.test.js +125 -0
  43. package/test/subtitle-track-numbering.test.js +8 -3
  44. package/test/viewer-starts-on-map.test.js +27 -0
  45. package/utils/coalesce.js +41 -0
package/AGENTS.md CHANGED
@@ -93,17 +93,25 @@ are recorded in `docs/browser-proxy-contract.md`.
93
93
  `ExternalSubtitleFile`. Spec-accurate flags (FlagForced only on subtitles per
94
94
  RFC 9559 §5.1.4.1, FlagOriginal/Commentary only on audio, tkhd
95
95
  track_enabled / alternate_group, elng BCP47).
96
- - `media/` application files: `ContainerOrchestrator` (detect + per-file
97
- cache of CONTAINERS, `getTracks`/`getMediaInfo`/`getKeyframeIndex` — it
98
- caches the container, never the answers, so nothing here is a second store
99
- of a fact somebody else owns), `KeyframeTables`,
96
+ - `media/` application files: `ContainerOrchestrator` (detect + the ONE
97
+ container per file, `containerFor`/`getTracks`/`getMediaInfo`/`getKeyframeIndex`;
98
+ the container keeps its own reading of each element it states — the Segment
99
+ layout, Tracks, Cues, MP4's `moov` — once it has been read), `KeyframeTables`,
100
100
  `SubtitleOrchestrator` (the track list and the cues behind
101
101
  `Container` tracks, warm/push — it is handed the walk, and `server.js` is
102
- what puts the two together), `SubtitleCues` (the walk itself: the plan, the
103
- found-order cursor, one walk of a file at a time). The walk
104
- itself is `MatroskaContainer.walkHeldClusters` / `Mp4Container.readHeldSamples`;
105
- what the torrent supplies is `held-bytes.js` — which ranges are downloaded
106
- whole, and a read of one that never fetches.
102
+ what puts the two together), `SubtitleCues` (the plan, the found-order
103
+ cursor, one walk of a file at a time, taking back withdrawn cues). It uses
104
+ the file's one container — it no longer builds its own. The walk itself is
105
+ `MatroskaContainer.readHeldCues` over `container/matroska-clusters.js` (which
106
+ also finds clusters the Cues table does not name) and
107
+ `Mp4Container.readHeldSamples`; what the torrent supplies is `held-bytes.js`
108
+ — which ranges are downloaded whole, and a read of one that never fetches
109
+ and answers at once with nothing where the range is not whole.
110
+ - **A read whose bytes have not arrived is not an answer about the file.**
111
+ Every container read is strict (`container/unavailable.js`): every byte, or
112
+ `BytesUnavailable`. A value, a proven absence, or a refusal of ours with its
113
+ reason may be kept; "not here yet" never is — not by a container, not by
114
+ `ContainerOrchestrator`, not by `KeyframeTables`, not by `SubtitleCues`.
107
115
  **`KeyframeTables` is where a file's keyframe table lives and the only thing
108
116
  that reads one.** The table is `container/KeyframeTable.js`, ONE object per
109
117
  file, handed out rather than copied — so a read that lands after a session
@@ -113,9 +121,10 @@ are recorded in `docs/browser-proxy-contract.md`.
113
121
  file that must be re-encoded for ever from a passing shortage of bytes off
114
122
  the swarm. One read per file whoever asks, the second asker joins the first,
115
123
  each caller's wait is bounded by the measured `KEYFRAME_TABLE_BUDGET_MS`
116
- while the READ is not, and a read that threw is never recorded as an answer.
117
- It is the ONLY store of the table: the container reads and does not
118
- remember, so there is one object per file and nothing to keep in step. The second reader is the packet probe in
124
+ while the READ is not, and a read that threw — `BytesUnavailable` included —
125
+ is never recorded as an answer; it is read again when pieces of the file
126
+ arrive (`readAgainIfUnanswered`). It is the ONLY store of the keyframe
127
+ answer; the container keeps the Cues reading the times are taken from. The second reader is the packet probe in
119
128
  `media/keyframe-probe.js`, which decodes rather than parsing; the fuller of the
120
129
  two answers is the one that stands (`media/keyframe-probe.js`).
121
130
  - `server/controllers/` — interface layer: `PlaybackController` / `SubtitleController`
package/CHANGELOG.md CHANGED
@@ -1,7 +1,21 @@
1
+ ## 2.89.0
2
+
3
+ - **Fix**: A read whose bytes have not arrived is no longer kept as a statement about the file. Every container read is strict — every byte asked for, or `BytesUnavailable` — and only a value or a proven absence is kept. This applies to the container choice, the track table, Matroska's Cues table, MP4's `moov`, AVI's `idx1`, the media info, the keyframe table and the subtitle plan. An embedded subtitle track whose Cues table was still downloading used to be planned with no clusters for the life of the process and show nothing for a whole session. The keyframe table had the same defect and lost the copy branch for such a file; it is now read again when pieces of the file arrive.
4
+ - **Fix**: One container per file. The subtitle walk no longer builds its own container and reads the head and the Cues table a second time; the Tracks element is parsed once; the Cues table is read once and gives both the keyframe times and the subtitle clusters; MP4's `moov` is read once for every question.
5
+ - **New**: Subtitle clusters are found from the downloaded bytes when the Cues table does not name them (RFC 9559 §22.1 only recommends it) or when there is no Cues table. Positions come from the file (first cluster, every CueClusterPosition) and from a chain of top-level elements. Where the file has no Cues, a search of the bytes finds candidates, and a candidate is used only after its whole structure checks out. A candidate that a cluster established later turns out to contain is taken back together with its cues, and the browser is told which cues to remove.
6
+ - **New**: Matroska elements are read where they lie and by their own size, one portion (the torrent's piece length) at a time. A large Cues table, a Tracks element larger than the head window and a large cluster are no longer cut short by a fixed window; data that is not needed is skipped without being assembled.
7
+ - **New**: The cluster a viewer stands in is read first, then the one before it, and pushed at once; the rest is read in later passes, and a pass stops when a viewer moves.
8
+ - **Fix**: A Matroska track with no `Language` element is English, as RFC 9559 §5.1.4.1.19 and RFC 8794 §11.1.19 require. `LanguageBCP47` still wins, an explicit `und` stays unknown, and where the language came from travels as `languageSource`.
9
+ - **Fix**: A read of what is downloaded answers at once with nothing when the range is not wholly downloaded. It used to open the torrent's own file stream, which selects pieces outside the demand register and waited up to 30 s. At a torrent's open, 36 such reads competed with the file being watched. The bytes it returns are handed to the main thread with their memory transferred instead of copied.
10
+ - **Fix**: A subtitle walk asked for while one is running runs once more afterwards instead of being dropped.
11
+ - **Fix**: The walk reads a cluster's structure — headers and the first bytes of each block — and a subtitle block's data by its own size, so a picture's frames never cross into the main thread, and each read hands the loop back before the next. Measured on the addon host over a 287 MB file: memory growth 10-15 MB instead of 75-95 MB, and the main loop's 99th-percentile delay 13-17 ms against 12-13 ms with no walk (20-32 ms before).
12
+ - **Fix**: The search for clusters in a file with no Cues decides each candidate as it finds it and follows an accepted one by its chain, so a file whose clusters follow one another is no longer searched whole and then read a second time to check each cluster. The first pass on that file took 0.3-0.7 s instead of 4-6 s.
13
+ - **Fix**: A keyframe table whose bytes have not arrived makes an opening re-encode instead of failing it; the table is read again when pieces of the file arrive.
14
+
1
15
  ## 2.88.6
2
16
 
3
- - **Fix**: Validate closed segment coverage against the same cut-time delta passed to FFmpeg. Complete audio segments that close before the requested grid boundary remain usable; truncated segments still fail validation.
4
-
17
+ - **Fix**: Validate closed segment coverage against the same cut-time delta passed to FFmpeg. Complete audio segments that close before the requested grid boundary remain usable; truncated segments still fail validation.
18
+
5
19
  ## 2.88.5
6
20
 
7
21
  - **Fix**: Reject closed non-final fragments that do not reach their next declared cut, using measured coded-frame coverage. Remove such previously cached fragments from the inventory so missing media can be produced again instead of indefinitely blocking startup.
@@ -2267,4 +2281,4 @@ All eight were found by reading the file after the torrent worker was killed by
2267
2281
 
2268
2282
  - **New**: WebRTC P2P tunnel architecture — replaced direct HTTP streaming with a persistent WebSocket tunnel to the server. Video is delivered from the proxy to the browser over a WebRTC data channel; the server acts only as a signalling relay.
2269
2283
  - **New**: `node-datachannel` dependency for server-side WebRTC.
2270
- - **Removed**: `public_base_url` config — no longer needed.
2284
+ - **Removed**: `public_base_url` config — no longer needed.
@@ -1,19 +1,19 @@
1
- # Audio calibration samples
2
-
3
- These files contain deterministic synthetic pink noise, encoded at build time
4
- by `scripts/build-audio-samples.mjs`. They contain no recorded third-party audio.
5
- The manifest records the actual duration, channels, sample rate and byte count
6
- reported by ffprobe. They are distributed under the package's GPL-3.0-or-later
7
- license.
8
-
9
- Startup reads each packaged sample once for packet copying and once for AAC
10
- processing. It neither generates input files nor loops them. The operation
11
- timeout is the existing encoder benchmark timeout; it is not an ETA coefficient.
12
-
13
- Process startup is measured separately from FFmpeg's reported processing time;
14
- its printed clock precision bounds runtime uncertainty. Input reading is
15
- included in processing. For encoding, the
16
- reference work scales by input audio samples (channels multiplied by sample
17
- rate); for copying, a declared source bitrate scales encoded-byte work. These
18
- are approximate initial rates, replaced by actual per-output progress. Unknown
19
- dimensions retain the measured reference rather than fabricated metadata.
1
+ # Audio calibration samples
2
+
3
+ These files contain deterministic synthetic pink noise, encoded at build time
4
+ by `scripts/build-audio-samples.mjs`. They contain no recorded third-party audio.
5
+ The manifest records the actual duration, channels, sample rate and byte count
6
+ reported by ffprobe. They are distributed under the package's GPL-3.0-or-later
7
+ license.
8
+
9
+ Startup reads each packaged sample once for packet copying and once for AAC
10
+ processing. It neither generates input files nor loops them. The operation
11
+ timeout is the existing encoder benchmark timeout; it is not an ETA coefficient.
12
+
13
+ Process startup is measured separately from FFmpeg's reported processing time;
14
+ its printed clock precision bounds runtime uncertainty. Input reading is
15
+ included in processing. For encoding, the
16
+ reference work scales by input audio samples (channels multiplied by sample
17
+ rate); for copying, a declared source bitrate scales encoded-byte work. These
18
+ are approximate initial rates, replaced by actual per-output progress. Unknown
19
+ dimensions retain the measured reference rather than fabricated metadata.
@@ -1,98 +1,98 @@
1
- [
2
- {
3
- "codec": "aac",
4
- "file": "aac.m4a",
5
- "durationSeconds": 4,
6
- "channels": 2,
7
- "samplingFrequency": 48000,
8
- "bytes": 66060
9
- },
10
- {
11
- "codec": "ac3",
12
- "file": "ac3.ac3",
13
- "durationSeconds": 4,
14
- "channels": 2,
15
- "samplingFrequency": 48000,
16
- "bytes": 96000
17
- },
18
- {
19
- "codec": "eac3",
20
- "file": "eac3.eac3",
21
- "durationSeconds": 4,
22
- "channels": 2,
23
- "samplingFrequency": 48000,
24
- "bytes": 96000
25
- },
26
- {
27
- "codec": "dts",
28
- "file": "dts.dts",
29
- "durationSeconds": 4.0051,
30
- "channels": 2,
31
- "samplingFrequency": 48000,
32
- "bytes": 706500
33
- },
34
- {
35
- "codec": "flac",
36
- "file": "flac.flac",
37
- "durationSeconds": 4,
38
- "channels": 2,
39
- "samplingFrequency": 48000,
40
- "bytes": 723158
41
- },
42
- {
43
- "codec": "opus",
44
- "file": "opus.opus",
45
- "durationSeconds": 4.0065,
46
- "channels": 2,
47
- "samplingFrequency": 48000,
48
- "bytes": 32457
49
- },
50
- {
51
- "codec": "vorbis",
52
- "file": "vorbis.ogg",
53
- "durationSeconds": 4,
54
- "channels": 2,
55
- "samplingFrequency": 48000,
56
- "bytes": 42108
57
- },
58
- {
59
- "codec": "truehd",
60
- "file": "truehd.thd",
61
- "durationSeconds": null,
62
- "channels": 2,
63
- "samplingFrequency": 48000,
64
- "bytes": 589492
65
- },
66
- {
67
- "codec": "mp3",
68
- "file": "mp3.mp3",
69
- "durationSeconds": 4,
70
- "channels": 2,
71
- "samplingFrequency": 48000,
72
- "bytes": 64748
73
- },
74
- {
75
- "codec": "mp2",
76
- "file": "mp2.mp2",
77
- "durationSeconds": 4.008,
78
- "channels": 2,
79
- "samplingFrequency": 48000,
80
- "bytes": 192384
81
- },
82
- {
83
- "codec": "alac",
84
- "file": "alac.m4a",
85
- "durationSeconds": 4,
86
- "channels": 2,
87
- "samplingFrequency": 48000,
88
- "bytes": 712871
89
- },
90
- {
91
- "codec": "pcm",
92
- "file": "pcm.wav",
93
- "durationSeconds": 4,
94
- "channels": 2,
95
- "samplingFrequency": 48000,
96
- "bytes": 768078
97
- }
98
- ]
1
+ [
2
+ {
3
+ "codec": "aac",
4
+ "file": "aac.m4a",
5
+ "durationSeconds": 4,
6
+ "channels": 2,
7
+ "samplingFrequency": 48000,
8
+ "bytes": 66060
9
+ },
10
+ {
11
+ "codec": "ac3",
12
+ "file": "ac3.ac3",
13
+ "durationSeconds": 4,
14
+ "channels": 2,
15
+ "samplingFrequency": 48000,
16
+ "bytes": 96000
17
+ },
18
+ {
19
+ "codec": "eac3",
20
+ "file": "eac3.eac3",
21
+ "durationSeconds": 4,
22
+ "channels": 2,
23
+ "samplingFrequency": 48000,
24
+ "bytes": 96000
25
+ },
26
+ {
27
+ "codec": "dts",
28
+ "file": "dts.dts",
29
+ "durationSeconds": 4.0051,
30
+ "channels": 2,
31
+ "samplingFrequency": 48000,
32
+ "bytes": 706500
33
+ },
34
+ {
35
+ "codec": "flac",
36
+ "file": "flac.flac",
37
+ "durationSeconds": 4,
38
+ "channels": 2,
39
+ "samplingFrequency": 48000,
40
+ "bytes": 723158
41
+ },
42
+ {
43
+ "codec": "opus",
44
+ "file": "opus.opus",
45
+ "durationSeconds": 4.0065,
46
+ "channels": 2,
47
+ "samplingFrequency": 48000,
48
+ "bytes": 32457
49
+ },
50
+ {
51
+ "codec": "vorbis",
52
+ "file": "vorbis.ogg",
53
+ "durationSeconds": 4,
54
+ "channels": 2,
55
+ "samplingFrequency": 48000,
56
+ "bytes": 42108
57
+ },
58
+ {
59
+ "codec": "truehd",
60
+ "file": "truehd.thd",
61
+ "durationSeconds": null,
62
+ "channels": 2,
63
+ "samplingFrequency": 48000,
64
+ "bytes": 589492
65
+ },
66
+ {
67
+ "codec": "mp3",
68
+ "file": "mp3.mp3",
69
+ "durationSeconds": 4,
70
+ "channels": 2,
71
+ "samplingFrequency": 48000,
72
+ "bytes": 64748
73
+ },
74
+ {
75
+ "codec": "mp2",
76
+ "file": "mp2.mp2",
77
+ "durationSeconds": 4.008,
78
+ "channels": 2,
79
+ "samplingFrequency": 48000,
80
+ "bytes": 192384
81
+ },
82
+ {
83
+ "codec": "alac",
84
+ "file": "alac.m4a",
85
+ "durationSeconds": 4,
86
+ "channels": 2,
87
+ "samplingFrequency": 48000,
88
+ "bytes": 712871
89
+ },
90
+ {
91
+ "codec": "pcm",
92
+ "file": "pcm.wav",
93
+ "durationSeconds": 4,
94
+ "channels": 2,
95
+ "samplingFrequency": 48000,
96
+ "bytes": 768078
97
+ }
98
+ ]
@@ -58,6 +58,35 @@ So ffmpeg keeps exactly three jobs, and nothing else:
58
58
  That is a final answer about the container, and the point at which a caller may
59
59
  go to the media — not "unknown, ask again".
60
60
 
61
+ ## A read that has not arrived is not an answer
62
+
63
+ Every read a container makes goes through `strictReader` (`container/unavailable.js`):
64
+ it answers with every byte asked for, or throws `BytesUnavailable`. A reading has
65
+ therefore four outcomes — the value; a proven absence ("this file has no Cues");
66
+ a refusal of ours, stated with its reason and size (an MP4 `moov` larger than
67
+ this program reads whole: `moovRefused`); and "not here yet". Only the first
68
+ three are facts about the file, and only they are kept — by the container, by
69
+ `ContainerOrchestrator`, by `KeyframeTables` and by `SubtitleCues`.
70
+
71
+ Until 2026-10-01 "not here yet" came back as `null`, the value the readers
72
+ return for an absent element. A subtitle plan read while its Cues table was
73
+ downloading was kept as "no clusters", and an embedded track showed nothing for
74
+ a whole session; the keyframe table, the container choice, the track table,
75
+ MP4's `moov` and AVI's `idx1` had the same shape
76
+ (`research/subtitles-never-appear-2026-10-01.md`).
77
+
78
+ ## Elements are read where they lie
79
+
80
+ Matroska's top-level elements are found by walking their HEADERS from the
81
+ Segment's start to the first cluster, each costing only its header, and from the
82
+ SeekHead for what lies after the clusters — so a Cues element stored before the
83
+ clusters with no SeekHead entry (RFC 9559 §6.4) is found. Elements are then read
84
+ by their own size through `container/ebml-stream.js`, one portion at a time; the
85
+ portion is the torrent's piece length, handed in, never chosen here. Data that
86
+ is not needed is skipped without being assembled; a needed element larger than
87
+ one portion is refused by name. There is no fixed window for the Cues table,
88
+ the Tracks element or a cluster any more.
89
+
61
90
  ## Where byte access lives, and why not on a track
62
91
 
63
92
  A `ContainerTrack` is a DECLARATION. It carries no `readRange` and no file
@@ -77,26 +106,52 @@ bytes — was reviewed on 2026-09-03 and is **not** the right shape:
77
106
  on the thread where the container is already at hand.
78
107
 
79
108
  **That split is closed.** `SubtitleTrack` carries `clusterPositions` and
80
- `samples` — byte POSITIONS — and the reading of those positions is now the
81
- container's: `MatroskaContainer.walkHeldClusters(plan, walked)` and
82
- `Mp4Container.readHeldSamples(track, harvested)`, beside `readTracks`,
83
- `readKeyframeIndex`, `readMediaInfo` and `cueTextOf`.
84
-
85
- The obstacle was real and this is how it was answered. The two readers want
86
- different read POLICIES over the same file: the track table fetches what is
87
- missing from the swarm, while the cue walk deliberately reads only what is
88
- already downloaded, so that turning subtitles on never pulls bytes the viewer is
89
- not waiting for. A container is now built with BOTH — `readRange` that fetches,
90
- `readHeld` that does not, and `isHeld` that says whether a range can be read
91
- without fetching — so one instance per file serves both readers and the per-file
92
- cache is kept.
93
-
94
- What the container is NOT given is the torrent. Those three functions are the
95
- whole of what the caller knows and the container does not, and reducing the
96
- torrent to them is what lets the reading live where the format is specified.
97
- `torrent-worker/subtitle-cues.js` supplies them (`containerOver`) and keeps only
98
- what is genuinely its own: the found-order cursor a browser follows, the
99
- per-file state, and one walk of a file at a time.
109
+ `samples` — byte POSITIONS — and the reading of those positions is the
110
+ container's: `MatroskaContainer.readHeldCues` (over `container/matroska-clusters.js`)
111
+ and `Mp4Container.readHeldSamples`, beside `readTracks`, `readKeyframeIndex`,
112
+ `readMediaInfo` and `cueTextOf`.
113
+
114
+ The two readers want different read POLICIES over the same file: the track
115
+ table fetches what is missing from the swarm, while the cue walk reads only
116
+ what is already downloaded, so that turning subtitles on never pulls bytes the
117
+ viewer is not waiting for. **One container per file serves both**: it is built
118
+ with the fetching `readRange`, and the walk's reader of downloaded bytes — which
119
+ ranges are whole, a strict read of them, the portion, where viewers stand — is
120
+ handed to `readHeldCues` on every pass (`HeldReader`), because what is
121
+ downloaded changes between passes. The walk used to build a second container of
122
+ its own over the same file and read the head and the Cues table again, under a
123
+ different rule for a read that had not arrived.
124
+
125
+ What the container is NOT given is the torrent. `media/SubtitleCues.js` is
126
+ handed a `HeldFile` of plain functions by `server.js` and keeps only what is
127
+ genuinely its own: the found-order cursor a browser follows, the per-file
128
+ state, one walk of a file at a time, and taking back the cues of a cluster the
129
+ container withdraws.
130
+
131
+ ## Where a cluster is, when the Cues table does not say
132
+
133
+ RFC 9559 §22.1 only recommends that each subtitle frame be referenced by the
134
+ Cues table, and a file may have none. `container/matroska-clusters.js` finds
135
+ clusters from the downloaded bytes: positions the file establishes (the first
136
+ cluster, every `CueClusterPosition` of every track), a chain of top-level
137
+ elements from each established one, and — only where the file has no Cues — a
138
+ search for the Cluster id. What the search finds is a candidate; it is used only
139
+ once every child is one §5.1.3 allows, exactly one Timestamp is present, the
140
+ children fill it exactly, every block names a declared track, and any CRC-32 or
141
+ PrevSize matches. A candidate that an established cluster turns out to contain
142
+ is withdrawn with its cues, and the browser removes them by number. The cluster
143
+ a viewer stands in is read first, then the one before it, and pushed at once.
144
+
145
+ The walk reads a cluster's STRUCTURE — every child's header and the first bytes
146
+ of each block, which name its track — and a subtitle block's data by its own
147
+ size. It never moves a picture's frames into the main thread: measured on the
148
+ addon host 2026-10-01, reading clusters a portion at a time moved every frame
149
+ of the film across the thread boundary, and the collector then took three times
150
+ as long as the walk. Each read also hands the loop back before the walk goes on,
151
+ because the torrent thread replies faster than the message port empties. The
152
+ search decides each candidate as it finds it; one accepted is followed by its
153
+ chain, and the search resumes where the chain ends, so a file whose clusters
154
+ follow one another is searched only up to the first cluster after each gap.
100
155
 
101
156
  ## Layers
102
157
 
@@ -166,10 +221,10 @@ flowchart TB
166
221
  - `ContainerFactory.create({readRange,fileSize})` — sniffs 16 bytes, returns precise `Container` subclass. No torrent knowledge.
167
222
  - `ContainerOrchestrator` — per-file cache (`sourceKey:fileIndex`), `getTracks()` / `getMediaInfo()` / `getKeyframeIndex()`. Transport-agnostic.
168
223
  - **The keyframe table is read once per file, and the container is not what remembers it.** `Container.readKeyframeIndex` parses and returns; each format implements `parseKeyframeIndex`. It used to keep the answer, and that made this the one fact stored in two places — here and in the file's `KeyframeTable` on the main thread. That copy had no reader, and since the parse itself moved to the main thread there is no second thread for a second copy to sit in.
169
- - **`KeyframeTable` / `KeyframeTables` — the answer, and the policy around getting one.** The table is one object per file (`media/container/KeyframeTable.js`), held by everyone who reads that file rather than copied, so a read that lands after a session was made still reaches it. `media/KeyframeTables.js` reads it once per file whoever asks, joins the second asker to the read already running, bounds how long any one caller waits (`KEYFRAME_TABLE_BUDGET_MS`, measured), and never records a read that threw as an answer. Its reader is one function handed in at construction, so it knows nothing of torrents or HTTP. It is not `ContainerOrchestrator` because of the THREAD: that one needs byte ranges and lives in the worker, this one lives beside the sessions and reaches it across the channel.
224
+ - **`KeyframeTable` / `KeyframeTables` — the answer, and the policy around getting one.** The table is one object per file (`media/container/KeyframeTable.js`), held by everyone who reads that file rather than copied, so a read that lands after a session was made still reaches it. `media/KeyframeTables.js` reads it once per file whoever asks, joins the second asker to the read already running, bounds how long any one caller waits (`KEYFRAME_TABLE_BUDGET_MS`, measured), and never records a read that threw as an answer. Its reader is one function handed in at construction, so it knows nothing of torrents or HTTP. It is not `ContainerOrchestrator`: both live on the main thread, but that one keeps the file's container — which keeps its own Cues reading — and this keeps the answer the sessions hold and the wait around it. A read that has not arrived rejects with `BytesUnavailable` and is read again when pieces of the file arrive (`readAgainIfUnanswered`).
170
225
  - **`answered` and `readable` are different questions.** `answered` says a reader came back; `readable` says it came back with times. A file that answered nothing must be re-encoded for ever (MPEG-TS: 669 real keyframes, no index of any kind); a file that has not answered is a shortage of bytes off the swarm. Held as loose fields in a bag of probe results nothing told them apart, and a passing shortage was written onto the file as a property of the bytes.
171
226
  - **The second reader is the packet probe** (`media/keyframe-probe.js`): it finds keyframes by decoding, for containers that state no index. It is never waited for, and `KeyframeTable.learn` keeps the fuller answer whichever arrives second — measured 2026-08-02, a scan found 77 keyframes in 45 s without finishing against all 570 in 0.8 s from the index.
172
- - `SubtitleOrchestrator` — wraps `torrent-worker/subtitle-cues.js` (`planFor`, `cuesHeldFor`, `warmSubtitleCues`) behind the `ContainerTrack` abstraction. Routes depend on this, not on the worker directly. The reading itself is the containers' — that module supplies the torrent's read policy and keeps the cursor.
227
+ - `SubtitleOrchestrator` — wraps `media/SubtitleCues.js` (`cuesHeldFor`, `warmSubtitleCues`, `subtitleTracksOf`) behind the `ContainerTrack` abstraction, handed in by `server.js`. Routes depend on this. The reading itself is the file's one container's; `SubtitleCues` keeps the cursor and the per-file state.
173
228
  - `PlaybackController` / `SubtitleController` — thin interface adapters; `routes/api/*` delegate to them, handle HTTP headers (`X-Subtitle-Language`, `X-Subtitle-Cursor`) only.
174
229
 
175
230
  ## Legacy
@@ -1,56 +1,56 @@
1
- # Playback start forecast
2
-
3
- For resource r, measured service is S_r(t) = rate_r * t. New observations
4
- replace the conditional future rate; polling cannot invent acceleration,
5
- exponential recovery or a new physical measurement.
6
-
7
- Source work uses the file's measured byte density, missing local byte ranges
8
- and the observed torrent download rate. Shared input ranges are downloaded
9
- once. Files on the same torrent share a queue; distinct torrents use distinct
10
- observed services. Local input reading is included in effective processing
11
- speed, rather than charged again as a separate disk or memory delay.
12
-
13
- Each output's remaining processing work subtracts progress only within its
14
- actual live run intervals. Initial video rates use existing codec/size/encoder
15
- calibration. Initial audio rates use short packaged per-codec measurements and
16
- sample work (encoding) or encoded bytes (copying). Actual output progress
17
- replaces these initial rates. Startup is charged once, separately from processing
18
- throughput, and disappears once actual run progress establishes processing.
19
-
20
- Input arrival and processing overlap. With arrival time D(m), processing rate
21
- e and previous completion C, completion for an interval [a,b] is
22
-
23
- max(C + (b-a)/e, sup_m(D(m) + (b-m)/e)).
24
-
25
- Between service boundaries these functions are linear, so only endpoints are
26
- needed. Client delivery follows production and shares the observed link queue.
27
- Measured client ranges and prepared media establish each track's deadlines.
28
-
29
- The minimum start delay for this conditional trajectory is
30
-
31
- max(0, max_j(completion_j - presentation_deadline_j)).
32
-
33
- This covers the remaining finite movie, including rates below real time. Slow
34
- positive service gives a finite preproduction delay; it is not rejected by a
35
- browser-capacity threshold or a capped search. Observed supply-interruption
36
- coverage is held in prepared proxy media and client media, without requiring
37
- the browser to hold it all.
38
-
39
- Critical review: future rates can change, average byte density is an initial
40
- mapping rather than an exact packet index, initial audio sample-work scaling
41
- does not model every decoder's nonlinear cost, and client receive/append time
42
- is not separately measured. These are approximation limits, not hidden weights.
43
- Actual progress, segment sizes and link observations refine the prediction.
44
- The client countdown uses elapsed local monotonic time; expiration cannot
45
- authorize playback. Only the proxy's readiness and real track coverage can.
46
-
47
- Zero service, missing media facts and a genuine timestamp gap remain explicit
48
- noncomputable states. The UI reports their causes instead of an estimating
49
- placeholder or a fabricated duration. Small coded-frame joins follow measured
50
- frame durations and Chromium's continuous-track rule, not a hand-picked margin.
51
-
52
- A closed non-final fragment is reusable only if its measured coded-frame
53
- coverage reaches its next declared cut. Interrupted one-frame fragments are
54
- excluded on publication and when reading an existing cache, so the production
55
- schedule treats their media as unfinished work. The final fragment uses its
56
- actual media end rather than the container's approximate duration.
1
+ # Playback start forecast
2
+
3
+ For resource r, measured service is S_r(t) = rate_r * t. New observations
4
+ replace the conditional future rate; polling cannot invent acceleration,
5
+ exponential recovery or a new physical measurement.
6
+
7
+ Source work uses the file's measured byte density, missing local byte ranges
8
+ and the observed torrent download rate. Shared input ranges are downloaded
9
+ once. Files on the same torrent share a queue; distinct torrents use distinct
10
+ observed services. Local input reading is included in effective processing
11
+ speed, rather than charged again as a separate disk or memory delay.
12
+
13
+ Each output's remaining processing work subtracts progress only within its
14
+ actual live run intervals. Initial video rates use existing codec/size/encoder
15
+ calibration. Initial audio rates use short packaged per-codec measurements and
16
+ sample work (encoding) or encoded bytes (copying). Actual output progress
17
+ replaces these initial rates. Startup is charged once, separately from processing
18
+ throughput, and disappears once actual run progress establishes processing.
19
+
20
+ Input arrival and processing overlap. With arrival time D(m), processing rate
21
+ e and previous completion C, completion for an interval [a,b] is
22
+
23
+ max(C + (b-a)/e, sup_m(D(m) + (b-m)/e)).
24
+
25
+ Between service boundaries these functions are linear, so only endpoints are
26
+ needed. Client delivery follows production and shares the observed link queue.
27
+ Measured client ranges and prepared media establish each track's deadlines.
28
+
29
+ The minimum start delay for this conditional trajectory is
30
+
31
+ max(0, max_j(completion_j - presentation_deadline_j)).
32
+
33
+ This covers the remaining finite movie, including rates below real time. Slow
34
+ positive service gives a finite preproduction delay; it is not rejected by a
35
+ browser-capacity threshold or a capped search. Observed supply-interruption
36
+ coverage is held in prepared proxy media and client media, without requiring
37
+ the browser to hold it all.
38
+
39
+ Critical review: future rates can change, average byte density is an initial
40
+ mapping rather than an exact packet index, initial audio sample-work scaling
41
+ does not model every decoder's nonlinear cost, and client receive/append time
42
+ is not separately measured. These are approximation limits, not hidden weights.
43
+ Actual progress, segment sizes and link observations refine the prediction.
44
+ The client countdown uses elapsed local monotonic time; expiration cannot
45
+ authorize playback. Only the proxy's readiness and real track coverage can.
46
+
47
+ Zero service, missing media facts and a genuine timestamp gap remain explicit
48
+ noncomputable states. The UI reports their causes instead of an estimating
49
+ placeholder or a fabricated duration. Small coded-frame joins follow measured
50
+ frame durations and Chromium's continuous-track rule, not a hand-picked margin.
51
+
52
+ A closed non-final fragment is reusable only if its measured coded-frame
53
+ coverage reaches its next declared cut. Interrupted one-frame fragments are
54
+ excluded on publication and when reading an existing cache, so the production
55
+ schedule treats their media as unfinished work. The final fragment uses its
56
+ actual media end rather than the container's approximate duration.