@torrent-tv/proxy 2.9.122 → 2.9.124
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
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.124
|
|
2
|
+
|
|
3
|
+
- **Fix**: Subtitles no longer drift away from the picture. A segment was stamped with the time the PLAYLIST assigned it, and the playlist is built from the container keyframe index — which can be wrong. Measured 2026-08-06 on a Matroska file whose index claimed a keyframe at 157.99 s where the real ones were 153.820 and 164.247: ffmpeg cut at 153.820, the stamp said 157.99, and the player was told that picture belonged 4.17 s later than it did. Subtitles, extracted straight from the source with no offset of any kind, kept the true times, so speech and text sat 4.17 s apart for the whole stretch. The stamp now comes from the piece itself — the muxer records its position as an empty edit at the head of the track edit list, read before the header is stripped — and falls back to the playlist only when the piece does not say. Identical to the old figure whenever the index is honest, so a well-formed file is unaffected. When the two disagree by more than a quarter of a second the log names both, so an index that lies is visible rather than merely felt. Verified against ffmpeg on pieces cut the same way (12/30/36 s read back exactly), and covered by tests including a 64-bit edit list and a non-default movie timescale.
|
|
4
|
+
|
|
5
|
+
## 2.9.123
|
|
6
|
+
|
|
7
|
+
- **Fix**: The check added in 2.9.121 was deleting the file an encoder was writing into. A segment short of a track means one of two very different things — left behind by a run that was killed, or simply not finished yet — and treating them alike removed the file mid-write, after which ffmpeg went on writing to something nobody could open and the segment never appeared. Measured 2026-08-06: segment #225 was deleted 14 s into the run producing it and answered 404 thirty-three seconds later. The readiness rule could not prevent it, because it waves a segment through once the NEXT one exists and that next one had been left by an older run. Ownership decides it now: the current run writes from its start index upwards, so a file at or above that index while the run is alive is unfinished and is waited for, and only a file below it, or any file once no run is producing, is a leftover worth removing.
|
|
8
|
+
|
|
1
9
|
## 2.9.122
|
|
2
10
|
|
|
3
11
|
- **Fix**: A session created with a start position begins encoding there, instead of at the top of the file. The position was honoured everywhere except the one place that mattered: it went into the session key and into the log line, and then the first run started at index 0 regardless. Measured 2026-08-06 on a Retry after the proxy had restarted — the session was created with `start=1580s`, the encoder began at #0, the player asked for #152, and 45 s later the browser gave up with "no data arrived from the proxy" while the transcode ran happily at 9.9x through the opening credits.
|
package/package.json
CHANGED
|
@@ -3418,19 +3418,69 @@ export class HlsSessionManager {
|
|
|
3418
3418
|
typeof session.segmentFormat.hasEveryTrack === "function" &&
|
|
3419
3419
|
!session.segmentFormat.hasEveryTrack(bytes, session.initBytes ?? null)
|
|
3420
3420
|
) {
|
|
3421
|
+
// Short of a track means one of two very different things, and the
|
|
3422
|
+
// first version of this check treated them alike — deleting the file
|
|
3423
|
+
// an encoder was writing INTO, so it went on writing to something
|
|
3424
|
+
// nobody could open and the segment never appeared. Measured
|
|
3425
|
+
// 2026-08-06: segment #225 was deleted 14 s into the run producing
|
|
3426
|
+
// it, and answered 404 thirty-three seconds later.
|
|
3427
|
+
//
|
|
3428
|
+
// The run that is producing this segment right now has simply not
|
|
3429
|
+
// finished it: wait, exactly as for a segment that does not exist
|
|
3430
|
+
// yet. Only a segment the CURRENT run has already moved past — the
|
|
3431
|
+
// next one exists, or no run is producing at all — is a leftover, and
|
|
3432
|
+
// only that one is worth removing so it can be made again.
|
|
3433
|
+
// Whose file is this? The current run writes from
|
|
3434
|
+
// `encodeStartIndex` upwards, so anything at or above that index
|
|
3435
|
+
// while the run is alive may simply be unfinished — the readiness
|
|
3436
|
+
// rule can wave it through on the strength of a NEXT segment left by
|
|
3437
|
+
// an older run, which is exactly how the file being written came to
|
|
3438
|
+
// be read. Anything below it, or any file at all once no run is
|
|
3439
|
+
// producing, belongs to a run that has ended.
|
|
3440
|
+
const stale = session.ffmpeg === null || index < (session.encodeStartIndex ?? 0);
|
|
3421
3441
|
logger.warn(
|
|
3422
3442
|
`transcode ${session.id} segment #${index} is short of a track — ` +
|
|
3423
|
-
|
|
3443
|
+
(stale
|
|
3444
|
+
? "left behind by a run that was terminated; producing it again"
|
|
3445
|
+
: "still being written; waiting for it")
|
|
3424
3446
|
);
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3447
|
+
if (stale) {
|
|
3448
|
+
try {
|
|
3449
|
+
await unlink(filePath);
|
|
3450
|
+
} catch {
|
|
3451
|
+
// Already gone, or being rewritten: either way nothing to do.
|
|
3452
|
+
}
|
|
3429
3453
|
}
|
|
3430
3454
|
return { kind: "warming-up" };
|
|
3431
3455
|
}
|
|
3456
|
+
// Where this segment REALLY begins, taken from the piece itself, and
|
|
3457
|
+
// only from the playlist when the piece does not say.
|
|
3458
|
+
//
|
|
3459
|
+
// The playlist's own answer is built from the container's keyframe
|
|
3460
|
+
// index, and an index can be wrong: measured 2026-08-06 on a Matroska
|
|
3461
|
+
// file whose index claimed a keyframe at 157.99 s where the real ones
|
|
3462
|
+
// were 153.82 and 164.247. ffmpeg cut at 153.82, and stamping that
|
|
3463
|
+
// picture with 157.99 told the player it belonged four seconds later
|
|
3464
|
+
// than it did — while subtitles, extracted straight from the source,
|
|
3465
|
+
// kept the true times. Speech and text drifted apart by 4.17 s.
|
|
3466
|
+
//
|
|
3467
|
+
// Read from `raw`, before the header is stripped: the position lives
|
|
3468
|
+
// in an empty edit in the piece's own `moov`, which `stripInit`
|
|
3469
|
+
// removes. Identical to the playlist's figure whenever the index is
|
|
3470
|
+
// honest, so nothing changes for a well-formed file.
|
|
3471
|
+
const trueStart = session.usesExplicitCuts
|
|
3472
|
+
? session.segmentFormat.readSegmentStartSeconds?.(raw) ?? null
|
|
3473
|
+
: null;
|
|
3474
|
+
const declaredStart = this.#segmentStartTime(session, index);
|
|
3475
|
+
if (trueStart !== null && Math.abs(trueStart - declaredStart) > SEGMENT_START_DISAGREEMENT_SEC) {
|
|
3476
|
+
logger.warn(
|
|
3477
|
+
`transcode ${session.id} segment #${index} really starts at ` +
|
|
3478
|
+
`${trueStart.toFixed(3)}s, the playlist says ${declaredStart.toFixed(3)}s — ` +
|
|
3479
|
+
"the container's keyframe index disagrees with the file; using the file"
|
|
3480
|
+
);
|
|
3481
|
+
}
|
|
3432
3482
|
const prepared = session.segmentFormat.prepareSegmentBytes(bytes, {
|
|
3433
|
-
startSeconds:
|
|
3483
|
+
startSeconds: trueStart ?? declaredStart,
|
|
3434
3484
|
initBytes: session.initBytes ?? null
|
|
3435
3485
|
});
|
|
3436
3486
|
return {
|
|
@@ -189,6 +189,21 @@ export const fmp4Format = {
|
|
|
189
189
|
return match ? Number(match[1]) : -1;
|
|
190
190
|
},
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Where a self-contained piece says it begins, in seconds, or null.
|
|
194
|
+
*
|
|
195
|
+
* Only the pieces the `segment` muxer writes carry this — they have their
|
|
196
|
+
* own `moov` — which is exactly the path where the playlist's own answer
|
|
197
|
+
* can be wrong. Must be given the piece BEFORE {@link stripInit}, since that
|
|
198
|
+
* removes the header the position lives in.
|
|
199
|
+
*
|
|
200
|
+
* @param {Buffer} piece
|
|
201
|
+
* @returns {number | null}
|
|
202
|
+
*/
|
|
203
|
+
readSegmentStartSeconds(piece) {
|
|
204
|
+
return readSelfContainedStartSeconds(piece);
|
|
205
|
+
},
|
|
206
|
+
|
|
192
207
|
/**
|
|
193
208
|
* fMP4 segments must be read into memory and corrected before being served —
|
|
194
209
|
* see {@link stampSegmentStartTime} for the full reasoning. Without this a
|
|
@@ -183,3 +183,65 @@ export function stampSegmentStartTime(segment, startSeconds, trackTimescales) {
|
|
|
183
183
|
});
|
|
184
184
|
return stamped;
|
|
185
185
|
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Where a self-contained piece really begins, in seconds, or null.
|
|
189
|
+
*
|
|
190
|
+
* The `segment` muxer writes each piece with its own `moov`, and puts the
|
|
191
|
+
* piece's position on the source timeline into an EMPTY EDIT at the head of the
|
|
192
|
+
* track's edit list: an entry whose `media_time` is -1 and whose duration, in
|
|
193
|
+
* the movie timescale, is the offset. That is the piece's own account of where
|
|
194
|
+
* it sits, and it is the only honest one available.
|
|
195
|
+
*
|
|
196
|
+
* It matters because the alternative — the time the playlist ASSIGNED to that
|
|
197
|
+
* segment — can be wrong. The playlist is built from the container's keyframe
|
|
198
|
+
* index, and an index can list times that are not keyframes: measured
|
|
199
|
+
* 2026-08-06 on a Matroska file whose index claimed one at 157.99 s while the
|
|
200
|
+
* real keyframes were at 153.82 and 164.247. The cut therefore produced a piece
|
|
201
|
+
* starting at 153.82, and stamping it with the playlist's 157.99 told the
|
|
202
|
+
* player that picture belonged four seconds later than it did — while the
|
|
203
|
+
* subtitles, extracted straight from the source, kept the true times. The
|
|
204
|
+
* result was a steady 4.17 s desync between speech and text.
|
|
205
|
+
*
|
|
206
|
+
* @param {Buffer} piece
|
|
207
|
+
* @returns {number | null} Seconds, or null when the piece carries no edit list.
|
|
208
|
+
*/
|
|
209
|
+
export function readSelfContainedStartSeconds(piece) {
|
|
210
|
+
let movieTimescale = 0;
|
|
211
|
+
let startSeconds = null;
|
|
212
|
+
walkBoxes(piece, (type, bodyStart, bodyEnd) => {
|
|
213
|
+
if (type === "mvhd" && movieTimescale === 0) {
|
|
214
|
+
const version = piece[bodyStart];
|
|
215
|
+
const offset = version === 1 ? bodyStart + 20 : bodyStart + 12;
|
|
216
|
+
if (offset + 4 <= piece.length) {
|
|
217
|
+
movieTimescale = piece.readUInt32BE(offset);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (type !== "elst" || startSeconds !== null || movieTimescale === 0) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const version = piece[bodyStart];
|
|
225
|
+
const entryStart = bodyStart + 8;
|
|
226
|
+
if (version === 1) {
|
|
227
|
+
if (entryStart + 16 > bodyEnd) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const duration = Number(piece.readBigUInt64BE(entryStart));
|
|
231
|
+
const mediaTime = piece.readBigInt64BE(entryStart + 8);
|
|
232
|
+
if (mediaTime === -1n) {
|
|
233
|
+
startSeconds = duration / movieTimescale;
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (entryStart + 8 > bodyEnd) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const duration = piece.readUInt32BE(entryStart);
|
|
241
|
+
const mediaTime = piece.readInt32BE(entryStart + 4);
|
|
242
|
+
if (mediaTime === -1) {
|
|
243
|
+
startSeconds = duration / movieTimescale;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return startSeconds;
|
|
247
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A segment must be stamped with where it really begins.
|
|
3
|
+
*
|
|
4
|
+
* The playlist's answer comes from the container's keyframe index, and an index
|
|
5
|
+
* can be wrong. Measured 2026-08-06 on a Matroska file whose index claimed a
|
|
6
|
+
* keyframe at 157.99 s: the real ones around there were 153.820 and 164.247, so
|
|
7
|
+
* ffmpeg cut at 153.820 and the segment was stamped 157.99 — telling the player
|
|
8
|
+
* that picture belonged 4.17 s later than it did, while the subtitles, taken
|
|
9
|
+
* straight from the source, kept the true times. Speech and text drifted apart
|
|
10
|
+
* by exactly that much.
|
|
11
|
+
*
|
|
12
|
+
* The piece itself knows better: the `segment` muxer records its position as an
|
|
13
|
+
* empty edit at the head of the track's edit list.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { readSelfContainedStartSeconds } from "../services/segment-formats/mp4-boxes.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A minimal MP4 carrying `moov > mvhd` and `moov > trak > edts > elst`, with a
|
|
22
|
+
* leading empty edit of `offsetSeconds`.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ offsetSeconds: number, timescale?: number, version?: 0 | 1 }} shape
|
|
25
|
+
* @returns {Buffer}
|
|
26
|
+
*/
|
|
27
|
+
function pieceWithEmptyEdit({ offsetSeconds, timescale = 1000, version = 0 }) {
|
|
28
|
+
const box = (type, body) => {
|
|
29
|
+
const head = Buffer.alloc(8);
|
|
30
|
+
head.writeUInt32BE(8 + body.length, 0);
|
|
31
|
+
head.write(type, 4, "latin1");
|
|
32
|
+
return Buffer.concat([head, body]);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mvhd = Buffer.alloc(100);
|
|
36
|
+
mvhd.writeUInt8(0, 0); // version 0
|
|
37
|
+
mvhd.writeUInt32BE(timescale, 12); // timescale
|
|
38
|
+
|
|
39
|
+
const duration = Math.round(offsetSeconds * timescale);
|
|
40
|
+
let elstBody;
|
|
41
|
+
if (version === 1) {
|
|
42
|
+
elstBody = Buffer.alloc(8 + 20);
|
|
43
|
+
elstBody.writeUInt8(1, 0);
|
|
44
|
+
elstBody.writeUInt32BE(1, 4); // one entry
|
|
45
|
+
elstBody.writeBigUInt64BE(BigInt(duration), 8);
|
|
46
|
+
elstBody.writeBigInt64BE(-1n, 16); // empty edit
|
|
47
|
+
} else {
|
|
48
|
+
elstBody = Buffer.alloc(8 + 12);
|
|
49
|
+
elstBody.writeUInt8(0, 0);
|
|
50
|
+
elstBody.writeUInt32BE(1, 4); // one entry
|
|
51
|
+
elstBody.writeUInt32BE(duration, 8);
|
|
52
|
+
elstBody.writeInt32BE(-1, 12); // empty edit
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return Buffer.concat([
|
|
56
|
+
box("ftyp", Buffer.alloc(16)),
|
|
57
|
+
box("moov", Buffer.concat([
|
|
58
|
+
box("mvhd", mvhd),
|
|
59
|
+
box("trak", box("edts", box("elst", elstBody)))
|
|
60
|
+
]))
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("the position is read from the empty edit, in seconds", () => {
|
|
65
|
+
assert.equal(readSelfContainedStartSeconds(pieceWithEmptyEdit({ offsetSeconds: 153.82 })), 153.82);
|
|
66
|
+
assert.equal(readSelfContainedStartSeconds(pieceWithEmptyEdit({ offsetSeconds: 0 })), 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a 64-bit edit list is read the same way", () => {
|
|
70
|
+
const piece = pieceWithEmptyEdit({ offsetSeconds: 4321.5, version: 1 });
|
|
71
|
+
assert.equal(readSelfContainedStartSeconds(piece), 4321.5);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the movie timescale is honoured, not assumed", () => {
|
|
75
|
+
const piece = pieceWithEmptyEdit({ offsetSeconds: 12.5, timescale: 90_000 });
|
|
76
|
+
assert.equal(
|
|
77
|
+
readSelfContainedStartSeconds(piece),
|
|
78
|
+
12.5,
|
|
79
|
+
"reading the duration without dividing by the file's own timescale would give 1 125 000"
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a piece with no edit list says nothing rather than guessing", () => {
|
|
84
|
+
const bare = Buffer.concat([Buffer.alloc(8), Buffer.from("ftyp", "latin1")]);
|
|
85
|
+
assert.equal(readSelfContainedStartSeconds(bare), null);
|
|
86
|
+
});
|