@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.11",
3
+ "version": "2.9.13",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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({ durationSeconds: parseFfmpegDurationSeconds(stderr), width: dims.width, height: dims.height });
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
- const segmentCount = hasDuration
550
- ? Math.max(1, Math.ceil(durationSeconds / this.segmentDurationSec))
551
- : 0;
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(durationSeconds, this.segmentDurationSec) : "",
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} totalSeconds
648
- * @param {number} segSec
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(totalSeconds, segSec) {
652
- const count = Math.max(1, Math.ceil(totalSeconds / segSec));
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(segSec)}`,
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 remaining = totalSeconds - index * segSec;
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
- const startSeconds = safeIndex * this.segmentDurationSec;
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
- if (startSeconds > 0) {
743
- // Fast keyframe-level seek before -i (skips decoding earlier frames).
744
- args.push("-ss", String(startSeconds));
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 (startSeconds > 0) {
748
- // Keep output timestamps on the original timeline so video.currentTime
749
- // matches the requested position.
750
- args.push("-output_ts_offset", String(startSeconds));
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 * this.segmentDurationSec;
938
- const currentSeg = Math.max(head, Math.floor(processed / this.segmentDurationSec));
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;
@@ -90,7 +90,17 @@ export function softwareDescriptor() {
90
90
  "-crf", SOFTWARE_CRF,
91
91
  "-threads", String(CPU_THREADS),
92
92
  "-pix_fmt", "yuv420p",
93
- ...keyFrameArgs(segmentDurationSec)
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. `12:34:56.789`.
14
- * Uses only the time portion to keep log lines short.
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
  /**