@torrent-tv/proxy 2.9.12 → 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 +5 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +298 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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
|
+
|
|
1
6
|
## 2.9.12
|
|
2
7
|
|
|
3
8
|
- **Fix**: Eliminate PTS-gap glitches (stutter/freeze on video while audio keeps playing) at start and after seeking, for both transcode modes:
|
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.
|
|
@@ -619,7 +829,9 @@ export class HlsSessionManager {
|
|
|
619
829
|
// Branch tag for log correlation: A = video re-encode (fixed GOP, grid
|
|
620
830
|
// aligned, ts-offset); B = video copy (cut at source keyframes, copyts).
|
|
621
831
|
`branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
|
|
832
|
+
`seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
|
|
622
833
|
`${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
|
|
834
|
+
`${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
|
|
623
835
|
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
624
836
|
);
|
|
625
837
|
|
|
@@ -647,23 +859,29 @@ export class HlsSessionManager {
|
|
|
647
859
|
* any position immediately — even before the corresponding segment has been
|
|
648
860
|
* transcoded. Segments are produced on demand (see {@link getFileStream}).
|
|
649
861
|
*
|
|
650
|
-
* @param {number}
|
|
651
|
-
*
|
|
862
|
+
* @param {number[]} boundaries - Segment start times (0-based); segment i
|
|
863
|
+
* spans `[boundaries[i], boundaries[i+1])`.
|
|
652
864
|
* @returns {string}
|
|
653
865
|
*/
|
|
654
|
-
#buildVodPlaylist(
|
|
655
|
-
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
|
+
}
|
|
656
875
|
const lines = [
|
|
657
876
|
"#EXTM3U",
|
|
658
877
|
"#EXT-X-VERSION:3",
|
|
659
|
-
`#EXT-X-TARGETDURATION:${Math.ceil(
|
|
878
|
+
`#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`,
|
|
660
879
|
"#EXT-X-MEDIA-SEQUENCE:0",
|
|
661
880
|
"#EXT-X-PLAYLIST-TYPE:VOD",
|
|
662
881
|
"#EXT-X-INDEPENDENT-SEGMENTS"
|
|
663
882
|
];
|
|
664
883
|
for (let index = 0; index < count; index += 1) {
|
|
665
|
-
const
|
|
666
|
-
const duration = index < count - 1 ? segSec : Math.max(0.1, remaining);
|
|
884
|
+
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
667
885
|
lines.push(`#EXTINF:${duration.toFixed(6)},`);
|
|
668
886
|
lines.push(`segment-${String(index).padStart(5, "0")}.ts`);
|
|
669
887
|
}
|
|
@@ -671,6 +889,52 @@ export class HlsSessionManager {
|
|
|
671
889
|
return `${lines.join("\n")}\n`;
|
|
672
890
|
}
|
|
673
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
|
+
|
|
674
938
|
/**
|
|
675
939
|
* Choose the libx264 preset for a software video transcode: the highest
|
|
676
940
|
* quality the startup benchmark says this host can encode at the actual
|
|
@@ -707,7 +971,10 @@ export class HlsSessionManager {
|
|
|
707
971
|
*/
|
|
708
972
|
#startEncodeRun(session, startIndex) {
|
|
709
973
|
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
710
|
-
|
|
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;
|
|
711
978
|
|
|
712
979
|
// Terminate any existing encode process before starting a new one. The
|
|
713
980
|
// old process's exit handler no-ops because session.ffmpeg is reassigned
|
|
@@ -742,10 +1009,14 @@ export class HlsSessionManager {
|
|
|
742
1009
|
if (session.transcodeVideo && Array.isArray(this.videoEncoder.inputArgs)) {
|
|
743
1010
|
args.push(...this.videoEncoder.inputArgs);
|
|
744
1011
|
}
|
|
745
|
-
|
|
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) {
|
|
746
1017
|
// Accurate seek before -i (decodes from the preceding keyframe and trims
|
|
747
|
-
// to the exact point), so the first output frame is exactly at
|
|
748
|
-
args.push("-accurate_seek", "-ss", String(
|
|
1018
|
+
// to the exact point), so the first output frame is exactly at the target.
|
|
1019
|
+
args.push("-accurate_seek", "-ss", String(seekSeconds));
|
|
749
1020
|
}
|
|
750
1021
|
args.push("-i", session.inputUrl);
|
|
751
1022
|
if (session.transcodeVideo) {
|
|
@@ -757,13 +1028,16 @@ export class HlsSessionManager {
|
|
|
757
1028
|
}
|
|
758
1029
|
} else {
|
|
759
1030
|
// Branch B (video copied — only audio is transcoded): we cannot insert
|
|
760
|
-
// keyframes, so segments are cut at the source's own keyframes
|
|
761
|
-
//
|
|
762
|
-
// copied frames stay continuous across
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
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.
|
|
766
1037
|
args.push("-copyts");
|
|
1038
|
+
if (sourceStartTime !== 0) {
|
|
1039
|
+
args.push("-output_ts_offset", String(-sourceStartTime));
|
|
1040
|
+
}
|
|
767
1041
|
}
|
|
768
1042
|
args.push(
|
|
769
1043
|
"-map",
|
|
@@ -950,8 +1224,8 @@ export class HlsSessionManager {
|
|
|
950
1224
|
// request just ahead of the live edge.
|
|
951
1225
|
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
952
1226
|
? session.progress.processedSeconds
|
|
953
|
-
: head
|
|
954
|
-
const currentSeg = Math.max(head,
|
|
1227
|
+
: this.#segmentStartTime(session, head);
|
|
1228
|
+
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
955
1229
|
const withinWindow = index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS;
|
|
956
1230
|
if (withinWindow) {
|
|
957
1231
|
return;
|