@torrent-tv/proxy 2.9.11 → 2.9.13
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 +13 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +312 -22
- package/services/hwaccel.js +11 -1
- package/utils/logger.js +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## 2.9.13
|
|
2
|
+
|
|
3
|
+
- **Fix**: Video-copy path (`video=copy`, audio transcoded or copied) no longer drops video / desyncs audio at the start. The output timeline is now forced 0-based: the container `start_time` (parsed from the probe; many MKVs report ~0.1 s) is subtracted via `-output_ts_offset -start_time` together with `-copyts`, so segment 0 begins exactly at 0 with audio and video aligned (previously `-copyts` preserved the non-zero start, leaving a hole at the beginning where video was blank but audio played).
|
|
4
|
+
- **New**: Unified segment-boundary model. The synthetic VOD playlist and all seek math now come from a boundary table: a uniform grid for re-encoded video, and the source's **real keyframe positions** (probed once with ffprobe, normalized to 0) for copied video — so the declared segment boundaries match where a copied stream actually cuts, eliminating seek gaps. The keyframe probe is time-bounded (~6 s); on slow containers it falls back to the uniform grid (start still 0-based). Session log shows `seg=keyframe|uniform` and `start=…`.
|
|
5
|
+
|
|
6
|
+
## 2.9.12
|
|
7
|
+
|
|
8
|
+
- **Fix**: Eliminate PTS-gap glitches (stutter/freeze on video while audio keeps playing) at start and after seeking, for both transcode modes:
|
|
9
|
+
- **Branch A — video re-encoded** (`video=libx264`): use a fixed GOP (`-g`/`-keyint_min` = segmentDuration × fps, `-sc_threshold 0`) instead of `-force_key_frames expr:gte(t,n_forced*SEG)`. The old expression broke after a seek because `t` is shifted by `-output_ts_offset`, forcing keyframes at the wrong places and producing segments that did not line up with the playlist grid. A frame-count GOP is offset-independent → every segment is exactly segmentDuration and starts on a keyframe.
|
|
10
|
+
- **Branch B — video copied** (`video=copy`, only audio transcoded): keep the source's real timestamps with `-copyts` (and accurate seek) instead of relabelling onto a 4 s grid that does not match the source's own keyframe positions. Relabelling was the source of the holes in this mode.
|
|
11
|
+
- **Chore**: Session-start log tags the active branch (`branch=A(reencode,fixed-gop)` / `branch=B(copy,copyts)`) so glitches can be attributed to the right mode.
|
|
12
|
+
- **Fix**: Log timestamps reverted to UTC (`HH:MM:SS.mmm`) so the proxy and browser logs share one timezone and line up exactly when correlated.
|
|
13
|
+
|
|
1
14
|
## 2.9.11
|
|
2
15
|
|
|
3
16
|
- **New**: Seek-aware torrent piece prioritization. On every `/stream` range request the proxy now marks the torrent pieces at the read position **critical** (`TorrentPool.prioritizeByteRange` → `torrent.critical`, ~8 MB window). After a seek, ffmpeg opens the input at a new byte offset; previously those pieces waited behind the sequential download backlog, so seeking into an undownloaded region stalled ~15-18 s while the proxy fetched data. Now the seek position jumps the download queue.
|
package/package.json
CHANGED
|
@@ -200,6 +200,27 @@ function parseFfmpegDurationSeconds(stderrText) {
|
|
|
200
200
|
return hours * 3600 + minutes * 60 + seconds;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Parse the container start time (seconds) from ffmpeg's "Duration: …, start:
|
|
205
|
+
* X, …" line. Many MKVs report a small non-zero start (e.g. 0.1 s); preserving
|
|
206
|
+
* it via `-copyts` would put a hole at the beginning, so we normalize it away.
|
|
207
|
+
* Returns 0 when absent.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} stderrText
|
|
210
|
+
* @returns {number}
|
|
211
|
+
*/
|
|
212
|
+
function parseFfmpegStartTimeSeconds(stderrText) {
|
|
213
|
+
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
const match = stderrText.match(/Duration:[^\n]*?start:\s*(-?\d+(?:\.\d+)?)/i);
|
|
217
|
+
if (!match) {
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
const value = Number(match[1]);
|
|
221
|
+
return Number.isFinite(value) ? value : 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
203
224
|
/**
|
|
204
225
|
* Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
|
|
205
226
|
*
|
|
@@ -298,7 +319,12 @@ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
|
|
|
298
319
|
}
|
|
299
320
|
settled = true;
|
|
300
321
|
const dims = parseFfmpegVideoDimensions(stderr);
|
|
301
|
-
resolve({
|
|
322
|
+
resolve({
|
|
323
|
+
durationSeconds: parseFfmpegDurationSeconds(stderr),
|
|
324
|
+
width: dims.width,
|
|
325
|
+
height: dims.height,
|
|
326
|
+
startTime: parseFfmpegStartTimeSeconds(stderr)
|
|
327
|
+
});
|
|
302
328
|
};
|
|
303
329
|
const timeoutId = setTimeout(() => {
|
|
304
330
|
if (!ffmpeg.killed) {
|
|
@@ -360,6 +386,156 @@ function computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceH
|
|
|
360
386
|
return { w: Math.max(2, w), h: Math.max(2, h) };
|
|
361
387
|
}
|
|
362
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Resolve the ffprobe binary path from the ffmpeg path (same directory / name).
|
|
391
|
+
*
|
|
392
|
+
* @param {string} ffmpegBin
|
|
393
|
+
* @returns {string}
|
|
394
|
+
*/
|
|
395
|
+
function ffprobeBinFor(ffmpegBin) {
|
|
396
|
+
if (typeof ffmpegBin !== "string" || ffmpegBin.length === 0) {
|
|
397
|
+
return "ffprobe";
|
|
398
|
+
}
|
|
399
|
+
if (/ffmpeg(\.exe)?$/i.test(ffmpegBin)) {
|
|
400
|
+
return ffmpegBin.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
|
|
401
|
+
}
|
|
402
|
+
return "ffprobe";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Probe the source video stream's keyframe timestamps (seconds, in the source
|
|
407
|
+
* timeline) via ffprobe packet flags. Used for the video-copy path, where we
|
|
408
|
+
* cannot insert keyframes: the synthetic playlist's segment boundaries must
|
|
409
|
+
* match the source's real keyframe positions or the player sees gaps on seek.
|
|
410
|
+
*
|
|
411
|
+
* Time-bounded; returns `null` on failure/timeout (caller falls back to a
|
|
412
|
+
* uniform grid). NOTE: reading all video packets streams much of the file from
|
|
413
|
+
* the torrent, so for large files this may time out and fall back.
|
|
414
|
+
*
|
|
415
|
+
* @param {string} ffmpegBin
|
|
416
|
+
* @param {string | URL} inputUrl
|
|
417
|
+
* @param {number} [timeoutMs]
|
|
418
|
+
* @returns {Promise<number[] | null>} Sorted keyframe times, or null.
|
|
419
|
+
*/
|
|
420
|
+
async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000) {
|
|
421
|
+
return new Promise((resolve) => {
|
|
422
|
+
let proc;
|
|
423
|
+
try {
|
|
424
|
+
proc = spawn(
|
|
425
|
+
ffprobeBinFor(ffmpegBin),
|
|
426
|
+
[
|
|
427
|
+
"-v", "error",
|
|
428
|
+
"-select_streams", "v:0",
|
|
429
|
+
"-show_entries", "packet=pts_time,flags",
|
|
430
|
+
"-of", "csv=p=0",
|
|
431
|
+
String(inputUrl)
|
|
432
|
+
],
|
|
433
|
+
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
|
|
434
|
+
);
|
|
435
|
+
} catch {
|
|
436
|
+
resolve(null);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
let stdout = "";
|
|
440
|
+
let settled = false;
|
|
441
|
+
const finish = (value) => {
|
|
442
|
+
if (settled) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
settled = true;
|
|
446
|
+
resolve(value);
|
|
447
|
+
};
|
|
448
|
+
const timer = setTimeout(() => {
|
|
449
|
+
try {
|
|
450
|
+
if (!proc.killed) {
|
|
451
|
+
proc.kill("SIGTERM");
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
// ignore
|
|
455
|
+
}
|
|
456
|
+
finish(null);
|
|
457
|
+
}, timeoutMs);
|
|
458
|
+
proc.stdout.on("data", (chunk) => {
|
|
459
|
+
stdout += String(chunk);
|
|
460
|
+
});
|
|
461
|
+
proc.on("error", () => {
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
finish(null);
|
|
464
|
+
});
|
|
465
|
+
proc.on("exit", (code) => {
|
|
466
|
+
clearTimeout(timer);
|
|
467
|
+
if (code !== 0) {
|
|
468
|
+
finish(null);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const times = [];
|
|
472
|
+
for (const line of stdout.split("\n")) {
|
|
473
|
+
// Each line: "<pts_time>,<flags>" e.g. "12.345000,K__"
|
|
474
|
+
const comma = line.indexOf(",");
|
|
475
|
+
if (comma < 0) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const flags = line.slice(comma + 1);
|
|
479
|
+
if (!flags.includes("K")) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const t = Number(line.slice(0, comma));
|
|
483
|
+
if (Number.isFinite(t)) {
|
|
484
|
+
times.push(t);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
times.sort((a, b) => a - b);
|
|
488
|
+
finish(times.length > 0 ? times : null);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Compute segment START times (a 0-based timeline) for a session.
|
|
495
|
+
*
|
|
496
|
+
* - Re-encoded video: a uniform grid (0, segDur, 2·segDur, …) — ffmpeg's fixed
|
|
497
|
+
* GOP makes the real cuts land exactly here.
|
|
498
|
+
* - Copied video: the source's real keyframes, normalized to 0 (start time
|
|
499
|
+
* subtracted) and greedily grouped to ≥ segDur — these are exactly where
|
|
500
|
+
* `-hls_time segDur` cuts a copied stream, so the playlist matches reality.
|
|
501
|
+
*
|
|
502
|
+
* The returned array starts at 0 and ends at `durationSeconds` (so segment i
|
|
503
|
+
* spans `[boundaries[i], boundaries[i+1])`). Falls back to a uniform grid when
|
|
504
|
+
* keyframes are unavailable.
|
|
505
|
+
*
|
|
506
|
+
* @param {{ transcodeVideo: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
|
|
507
|
+
* @returns {number[]}
|
|
508
|
+
*/
|
|
509
|
+
function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, keyframeTimes, startTime }) {
|
|
510
|
+
const total = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 0;
|
|
511
|
+
const step = Number.isFinite(segDur) && segDur > 0 ? segDur : 4;
|
|
512
|
+
const uniform = () => {
|
|
513
|
+
const boundaries = [];
|
|
514
|
+
for (let t = 0; t < total - 0.001; t += step) {
|
|
515
|
+
boundaries.push(Number(t.toFixed(6)));
|
|
516
|
+
}
|
|
517
|
+
boundaries.push(total);
|
|
518
|
+
return boundaries;
|
|
519
|
+
};
|
|
520
|
+
if (transcodeVideo || !Array.isArray(keyframeTimes) || keyframeTimes.length === 0 || total <= 0) {
|
|
521
|
+
return uniform();
|
|
522
|
+
}
|
|
523
|
+
const base = Number.isFinite(startTime) ? startTime : 0;
|
|
524
|
+
const norm = keyframeTimes
|
|
525
|
+
.map((t) => t - base)
|
|
526
|
+
.filter((t) => t >= -0.001 && t < total - 0.05)
|
|
527
|
+
.sort((a, b) => a - b);
|
|
528
|
+
const boundaries = [0];
|
|
529
|
+
for (const kf of norm) {
|
|
530
|
+
if (kf >= boundaries[boundaries.length - 1] + step - 0.05) {
|
|
531
|
+
boundaries.push(Number(kf.toFixed(6)));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
boundaries.push(total);
|
|
535
|
+
// Guard against a degenerate probe (e.g. a single keyframe) — fall back.
|
|
536
|
+
return boundaries.length >= 2 ? boundaries : uniform();
|
|
537
|
+
}
|
|
538
|
+
|
|
363
539
|
function isWarmupTimeoutError(error) {
|
|
364
540
|
if (!(error instanceof Error)) {
|
|
365
541
|
return false;
|
|
@@ -538,6 +714,7 @@ export class HlsSessionManager {
|
|
|
538
714
|
const durationSeconds = mediaInfo.durationSeconds;
|
|
539
715
|
const sourceWidth = mediaInfo.width;
|
|
540
716
|
const sourceHeight = mediaInfo.height;
|
|
717
|
+
const sourceStartTime = Number.isFinite(mediaInfo.startTime) ? mediaInfo.startTime : 0;
|
|
541
718
|
const hasDuration = Number.isFinite(durationSeconds) && durationSeconds > 0;
|
|
542
719
|
const logName = normalizeLogFileName(fileName, fileIndex);
|
|
543
720
|
if (!hasDuration) {
|
|
@@ -546,9 +723,36 @@ export class HlsSessionManager {
|
|
|
546
723
|
`ffmpeg-managed (growing) playlist for "${logName}"`
|
|
547
724
|
);
|
|
548
725
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
726
|
+
|
|
727
|
+
// For the video-copy path we cannot insert keyframes, so the playlist's
|
|
728
|
+
// segment boundaries must match the source's real keyframes (otherwise the
|
|
729
|
+
// player sees gaps on seek). Probe them; on failure we fall back to a
|
|
730
|
+
// uniform grid (current behaviour). Re-encoded video uses a uniform grid
|
|
731
|
+
// (its fixed GOP makes the cuts land there).
|
|
732
|
+
let keyframeTimes = null;
|
|
733
|
+
if (hasDuration && !transcodeVideo) {
|
|
734
|
+
// Short timeout: mp4 keyframes come from the moov index (fast); containers
|
|
735
|
+
// that force a full packet scan time out and fall back to a uniform grid,
|
|
736
|
+
// so this never adds more than ~6 s to session start.
|
|
737
|
+
keyframeTimes = await probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 6_000);
|
|
738
|
+
if (!keyframeTimes) {
|
|
739
|
+
logger.warn(
|
|
740
|
+
`transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
|
|
741
|
+
`for copied video "${logName}" (seek precision may be reduced)`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const segmentBoundaries = hasDuration
|
|
746
|
+
? computeSegmentBoundaries({
|
|
747
|
+
transcodeVideo,
|
|
748
|
+
durationSeconds,
|
|
749
|
+
segDur: this.segmentDurationSec,
|
|
750
|
+
keyframeTimes,
|
|
751
|
+
startTime: sourceStartTime
|
|
752
|
+
})
|
|
753
|
+
: [];
|
|
754
|
+
const usingKeyframeBoundaries = hasDuration && !transcodeVideo && Array.isArray(keyframeTimes);
|
|
755
|
+
const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
|
|
552
756
|
|
|
553
757
|
// Pick the highest-quality software preset that still encodes the actual
|
|
554
758
|
// (source-capped) output resolution faster than realtime. Null for hardware
|
|
@@ -583,14 +787,20 @@ export class HlsSessionManager {
|
|
|
583
787
|
targetHeight: normalizedTargetHeight,
|
|
584
788
|
sourceWidth,
|
|
585
789
|
sourceHeight,
|
|
790
|
+
// Container start time (seconds); subtracted on the copy path so the
|
|
791
|
+
// output timeline is 0-based even when the source starts at e.g. 0.1 s.
|
|
792
|
+
sourceStartTime,
|
|
586
793
|
// Chosen libx264 preset for this stream (software only), or null.
|
|
587
794
|
softwarePreset,
|
|
588
795
|
inputUrl: inputUrl.toString(),
|
|
589
796
|
// VOD playlist bookkeeping.
|
|
590
797
|
useSyntheticPlaylist: hasDuration,
|
|
591
798
|
totalDurationSeconds: hasDuration ? durationSeconds : null,
|
|
799
|
+
// Segment start times (0-based). Uniform grid for re-encoded video; real
|
|
800
|
+
// keyframe positions for copied video. Drives the playlist and seeking.
|
|
801
|
+
segmentBoundaries,
|
|
592
802
|
segmentCount,
|
|
593
|
-
playlistText: hasDuration ? this.#buildVodPlaylist(
|
|
803
|
+
playlistText: hasDuration ? this.#buildVodPlaylist(segmentBoundaries) : "",
|
|
594
804
|
// Segment index the current ffmpeg run started producing from.
|
|
595
805
|
encodeStartIndex: 0,
|
|
596
806
|
// Guards against repeatedly restarting to the same seek position.
|
|
@@ -616,7 +826,12 @@ export class HlsSessionManager {
|
|
|
616
826
|
`transcode ${sessionId} start "${logName}" ` +
|
|
617
827
|
`video=${transcodeVideo ? `${this.videoEncoder.name}${softwarePreset ? `/${softwarePreset}` : ""}` : "copy"} ` +
|
|
618
828
|
`audio=${transcodeAudio ? "aac" : "copy"} ` +
|
|
829
|
+
// Branch tag for log correlation: A = video re-encode (fixed GOP, grid
|
|
830
|
+
// aligned, ts-offset); B = video copy (cut at source keyframes, copyts).
|
|
831
|
+
`branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
|
|
832
|
+
`seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
|
|
619
833
|
`${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
|
|
834
|
+
`${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
|
|
620
835
|
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
621
836
|
);
|
|
622
837
|
|
|
@@ -644,23 +859,29 @@ export class HlsSessionManager {
|
|
|
644
859
|
* any position immediately — even before the corresponding segment has been
|
|
645
860
|
* transcoded. Segments are produced on demand (see {@link getFileStream}).
|
|
646
861
|
*
|
|
647
|
-
* @param {number}
|
|
648
|
-
*
|
|
862
|
+
* @param {number[]} boundaries - Segment start times (0-based); segment i
|
|
863
|
+
* spans `[boundaries[i], boundaries[i+1])`.
|
|
649
864
|
* @returns {string}
|
|
650
865
|
*/
|
|
651
|
-
#buildVodPlaylist(
|
|
652
|
-
const count = Math.max(
|
|
866
|
+
#buildVodPlaylist(boundaries) {
|
|
867
|
+
const count = Math.max(0, boundaries.length - 1);
|
|
868
|
+
let maxDuration = 0;
|
|
869
|
+
for (let index = 0; index < count; index += 1) {
|
|
870
|
+
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
871
|
+
if (duration > maxDuration) {
|
|
872
|
+
maxDuration = duration;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
653
875
|
const lines = [
|
|
654
876
|
"#EXTM3U",
|
|
655
877
|
"#EXT-X-VERSION:3",
|
|
656
|
-
`#EXT-X-TARGETDURATION:${Math.ceil(
|
|
878
|
+
`#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`,
|
|
657
879
|
"#EXT-X-MEDIA-SEQUENCE:0",
|
|
658
880
|
"#EXT-X-PLAYLIST-TYPE:VOD",
|
|
659
881
|
"#EXT-X-INDEPENDENT-SEGMENTS"
|
|
660
882
|
];
|
|
661
883
|
for (let index = 0; index < count; index += 1) {
|
|
662
|
-
const
|
|
663
|
-
const duration = index < count - 1 ? segSec : Math.max(0.1, remaining);
|
|
884
|
+
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
664
885
|
lines.push(`#EXTINF:${duration.toFixed(6)},`);
|
|
665
886
|
lines.push(`segment-${String(index).padStart(5, "0")}.ts`);
|
|
666
887
|
}
|
|
@@ -668,6 +889,52 @@ export class HlsSessionManager {
|
|
|
668
889
|
return `${lines.join("\n")}\n`;
|
|
669
890
|
}
|
|
670
891
|
|
|
892
|
+
/**
|
|
893
|
+
* Start time (seconds, 0-based) of segment `index`, from the session's
|
|
894
|
+
* boundary table. Clamped to valid range.
|
|
895
|
+
*
|
|
896
|
+
* @param {HlsSession} session
|
|
897
|
+
* @param {number} index
|
|
898
|
+
* @returns {number}
|
|
899
|
+
*/
|
|
900
|
+
#segmentStartTime(session, index) {
|
|
901
|
+
const boundaries = Array.isArray(session.segmentBoundaries) ? session.segmentBoundaries : [];
|
|
902
|
+
if (boundaries.length === 0) {
|
|
903
|
+
return index * this.segmentDurationSec;
|
|
904
|
+
}
|
|
905
|
+
const clamped = Math.max(0, Math.min(index, boundaries.length - 1));
|
|
906
|
+
return boundaries[clamped];
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Segment index whose span contains time `t` (0-based), via the boundary
|
|
911
|
+
* table.
|
|
912
|
+
*
|
|
913
|
+
* @param {HlsSession} session
|
|
914
|
+
* @param {number} t
|
|
915
|
+
* @returns {number}
|
|
916
|
+
*/
|
|
917
|
+
#segmentIndexForTime(session, t) {
|
|
918
|
+
const boundaries = Array.isArray(session.segmentBoundaries) ? session.segmentBoundaries : [];
|
|
919
|
+
if (boundaries.length < 2) {
|
|
920
|
+
return Math.max(0, Math.floor(t / this.segmentDurationSec));
|
|
921
|
+
}
|
|
922
|
+
// boundaries is sorted ascending; find the last boundary <= t.
|
|
923
|
+
let lo = 0;
|
|
924
|
+
let hi = boundaries.length - 1;
|
|
925
|
+
let result = 0;
|
|
926
|
+
while (lo <= hi) {
|
|
927
|
+
const mid = (lo + hi) >> 1;
|
|
928
|
+
if (boundaries[mid] <= t) {
|
|
929
|
+
result = mid;
|
|
930
|
+
lo = mid + 1;
|
|
931
|
+
} else {
|
|
932
|
+
hi = mid - 1;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return Math.min(result, boundaries.length - 2);
|
|
936
|
+
}
|
|
937
|
+
|
|
671
938
|
/**
|
|
672
939
|
* Choose the libx264 preset for a software video transcode: the highest
|
|
673
940
|
* quality the startup benchmark says this host can encode at the actual
|
|
@@ -704,7 +971,10 @@ export class HlsSessionManager {
|
|
|
704
971
|
*/
|
|
705
972
|
#startEncodeRun(session, startIndex) {
|
|
706
973
|
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
707
|
-
|
|
974
|
+
// 0-based output time of this segment, from the boundary table (uniform for
|
|
975
|
+
// re-encode, real keyframe for copy).
|
|
976
|
+
const startSeconds = this.#segmentStartTime(session, safeIndex);
|
|
977
|
+
const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
|
|
708
978
|
|
|
709
979
|
// Terminate any existing encode process before starting a new one. The
|
|
710
980
|
// old process's exit handler no-ops because session.ffmpeg is reassigned
|
|
@@ -739,15 +1009,35 @@ export class HlsSessionManager {
|
|
|
739
1009
|
if (session.transcodeVideo && Array.isArray(this.videoEncoder.inputArgs)) {
|
|
740
1010
|
args.push(...this.videoEncoder.inputArgs);
|
|
741
1011
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1012
|
+
// Seek position in SOURCE time. For copy we seek to the real keyframe
|
|
1013
|
+
// (startSeconds is already a real-keyframe offset from 0, so add back the
|
|
1014
|
+
// container start time); for re-encode startSeconds is a plain grid offset.
|
|
1015
|
+
const seekSeconds = session.transcodeVideo ? startSeconds : startSeconds + sourceStartTime;
|
|
1016
|
+
if (seekSeconds > 0) {
|
|
1017
|
+
// Accurate seek before -i (decodes from the preceding keyframe and trims
|
|
1018
|
+
// to the exact point), so the first output frame is exactly at the target.
|
|
1019
|
+
args.push("-accurate_seek", "-ss", String(seekSeconds));
|
|
745
1020
|
}
|
|
746
1021
|
args.push("-i", session.inputUrl);
|
|
747
|
-
if (
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
1022
|
+
if (session.transcodeVideo) {
|
|
1023
|
+
// Branch A (re-encode): fixed GOP makes keyframes land exactly on the
|
|
1024
|
+
// segment grid; relabel output onto the original timeline so segment N
|
|
1025
|
+
// carries PTS = N × segmentDuration.
|
|
1026
|
+
if (startSeconds > 0) {
|
|
1027
|
+
args.push("-output_ts_offset", String(startSeconds));
|
|
1028
|
+
}
|
|
1029
|
+
} else {
|
|
1030
|
+
// Branch B (video copied — only audio is transcoded): we cannot insert
|
|
1031
|
+
// keyframes, so segments are cut at the source's own keyframes (the
|
|
1032
|
+
// playlist boundaries were built from those keyframes). Keep the source's
|
|
1033
|
+
// real timestamps (`-copyts`) so copied frames stay continuous across
|
|
1034
|
+
// boundaries/seeks, and shift by -startTime so the output timeline is
|
|
1035
|
+
// 0-based (a non-zero container start otherwise puts a hole at the very
|
|
1036
|
+
// beginning and desyncs audio/video). Audio is transcoded on this timeline.
|
|
1037
|
+
args.push("-copyts");
|
|
1038
|
+
if (sourceStartTime !== 0) {
|
|
1039
|
+
args.push("-output_ts_offset", String(-sourceStartTime));
|
|
1040
|
+
}
|
|
751
1041
|
}
|
|
752
1042
|
args.push(
|
|
753
1043
|
"-map",
|
|
@@ -934,8 +1224,8 @@ export class HlsSessionManager {
|
|
|
934
1224
|
// request just ahead of the live edge.
|
|
935
1225
|
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
936
1226
|
? session.progress.processedSeconds
|
|
937
|
-
: head
|
|
938
|
-
const currentSeg = Math.max(head,
|
|
1227
|
+
: this.#segmentStartTime(session, head);
|
|
1228
|
+
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
939
1229
|
const withinWindow = index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS;
|
|
940
1230
|
if (withinWindow) {
|
|
941
1231
|
return;
|
package/services/hwaccel.js
CHANGED
|
@@ -90,7 +90,17 @@ export function softwareDescriptor() {
|
|
|
90
90
|
"-crf", SOFTWARE_CRF,
|
|
91
91
|
"-threads", String(CPU_THREADS),
|
|
92
92
|
"-pix_fmt", "yuv420p",
|
|
93
|
-
|
|
93
|
+
// Fixed GOP: a keyframe exactly every (segmentDurationSec × fps) frames,
|
|
94
|
+
// scene-cut keyframes disabled. This is frame-count based, so it is
|
|
95
|
+
// independent of the PTS offset used on seek-restart — every HLS segment
|
|
96
|
+
// is exactly segmentDurationSec long and starts on a keyframe, so segment
|
|
97
|
+
// boundaries line up with the synthetic playlist with no gaps. (The old
|
|
98
|
+
// `-force_key_frames expr:gte(t,n_forced*SEG)` broke after a seek because
|
|
99
|
+
// `t` is offset by `-output_ts_offset`, forcing keyframes at the wrong
|
|
100
|
+
// places.)
|
|
101
|
+
"-g", String(segmentDurationSec * TRANSCODE_FPS),
|
|
102
|
+
"-keyint_min", String(segmentDurationSec * TRANSCODE_FPS),
|
|
103
|
+
"-sc_threshold", "0"
|
|
94
104
|
];
|
|
95
105
|
}
|
|
96
106
|
};
|
package/utils/logger.js
CHANGED
|
@@ -10,13 +10,14 @@ import chalk from "chalk";
|
|
|
10
10
|
const PREFIX = "[proxy-client]";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Return the current time as a compact ISO-8601 string, e.g.
|
|
14
|
-
*
|
|
13
|
+
* Return the current time as a compact ISO-8601 (UTC) string, e.g.
|
|
14
|
+
* `12:34:56.789`. UTC is used deliberately so proxy and browser logs share the
|
|
15
|
+
* same timezone and line up exactly when correlating them.
|
|
15
16
|
*
|
|
16
17
|
* @returns {string}
|
|
17
18
|
*/
|
|
18
19
|
function ts() {
|
|
19
|
-
return new Date().toISOString().slice(11, 23); // "HH:MM:SS.mmm"
|
|
20
|
+
return new Date().toISOString().slice(11, 23); // "HH:MM:SS.mmm" (UTC)
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|