@flow-player/test-streams 0.14.1 → 0.15.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.
package/dist/index.js CHANGED
@@ -224,6 +224,7 @@ function summarizeCatalog(fixtureDir) {
224
224
  }
225
225
  let total = 0;
226
226
  for (const item of manifest.fixtures) total += item.entries.length;
227
+ for (const item of manifest.windowFixtures ?? []) total += item.entries.length;
227
228
  return {
228
229
  codecs: ALL_CODECS,
229
230
  formats: ALL_FORMATS,
@@ -247,6 +248,375 @@ function verifyEntryOnDisk(fixtureDir, entry) {
247
248
  return { ok: true, size };
248
249
  }
249
250
 
251
+ // src/fixtures/window.ts
252
+ import { join as join3 } from "node:path";
253
+ var DEFAULT_DRAWTEXT_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
254
+ var DRAWTEXT_FONT_CANDIDATES = [
255
+ "/System/Library/Fonts/Supplemental/Verdana.ttf",
256
+ "/System/Library/Fonts/Hiragino Sans GB.ttc",
257
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
258
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
259
+ ];
260
+ var WINDOW_CONTAINER_DIR = "window";
261
+ var WINDOW_SPECS = [
262
+ {
263
+ id: "h264-shortgop-bframes",
264
+ codec: "h264",
265
+ width: 1280,
266
+ height: 720,
267
+ fps: 25,
268
+ durationSeconds: 6,
269
+ gopFrames: 25,
270
+ gopMode: "short",
271
+ bframes: 3,
272
+ startPtsSeconds: 5,
273
+ sparse: false,
274
+ moovTrailing: true,
275
+ delayedTail: true,
276
+ usageP3: "\u77ED GOP \u6709 B \u5E27\u57FA\u7EBF\uFF1A\u7A97\u53E3\u9010 GOP \u524D\u8FDB\u65F6\u7684\u89E3\u7801\u8D77\u70B9\u5BF9\u9F50",
277
+ usageP5: "\u591A\u8DEF\u5E76\u53D1\u4E0B\u7684\u5E38\u89C4\u89E3\u7801\u8D1F\u8F7D\u57FA\u7EBF"
278
+ },
279
+ {
280
+ id: "h264-shortgop-nobframes",
281
+ codec: "h264",
282
+ width: 1280,
283
+ height: 720,
284
+ fps: 25,
285
+ durationSeconds: 6,
286
+ gopFrames: 25,
287
+ gopMode: "short",
288
+ bframes: 0,
289
+ startPtsSeconds: 5,
290
+ sparse: false,
291
+ moovTrailing: true,
292
+ delayedTail: false,
293
+ usageP3: "\u65E0 B \u5E27\u5BF9\u7167\uFF1A\u5E27\u8F93\u51FA\u65E0\u91CD\u6392\u5EF6\u8FDF\uFF0C\u6821\u9A8C PTS \u5355\u8C03\u6027",
294
+ usageP5: "\u4F4E\u5EF6\u8FDF\u94FE\u8DEF\u7684\u5BB9\u91CF\u5BF9\u7167\u7EC4"
295
+ },
296
+ {
297
+ id: "hevc-shortgop-bframes",
298
+ codec: "hevc",
299
+ width: 1280,
300
+ height: 720,
301
+ fps: 25,
302
+ durationSeconds: 6,
303
+ gopFrames: 25,
304
+ gopMode: "short",
305
+ bframes: 3,
306
+ startPtsSeconds: 5,
307
+ sparse: false,
308
+ moovTrailing: true,
309
+ delayedTail: true,
310
+ usageP3: "HEVC \u77ED GOP \u6709 B \u5E27\u57FA\u7EBF\uFF1Ahvc1 \u5C01\u88C5\u4E0B\u7A97\u53E3\u63A8\u8FDB",
311
+ usageP5: "HEVC \u8F6F\u89E3 / \u786C\u89E3\u5BB9\u91CF\u57FA\u7EBF"
312
+ },
313
+ {
314
+ id: "hevc-shortgop-nobframes",
315
+ codec: "hevc",
316
+ width: 1280,
317
+ height: 720,
318
+ fps: 25,
319
+ durationSeconds: 6,
320
+ gopFrames: 25,
321
+ gopMode: "short",
322
+ bframes: 0,
323
+ startPtsSeconds: 5,
324
+ sparse: false,
325
+ moovTrailing: true,
326
+ delayedTail: false,
327
+ usageP3: "HEVC \u65E0 B \u5E27\u5BF9\u7167\uFF1A\u91CD\u6392\u5173\u95ED\u65F6\u7684\u7A97\u53E3\u884C\u4E3A",
328
+ usageP5: "HEVC \u4F4E\u5EF6\u8FDF\u5BB9\u91CF\u5BF9\u7167\u7EC4"
329
+ },
330
+ {
331
+ id: "h264-longgop-bframes",
332
+ codec: "h264",
333
+ width: 1280,
334
+ height: 720,
335
+ fps: 25,
336
+ durationSeconds: 10,
337
+ gopFrames: 125,
338
+ gopMode: "long",
339
+ bframes: 3,
340
+ startPtsSeconds: 10,
341
+ sparse: false,
342
+ moovTrailing: true,
343
+ delayedTail: true,
344
+ usageP3: "\u957F GOP\uFF085s\uFF09\u8DE8\u7A97 seek\uFF1A\u7A97\u53E3\u843D\u5728 GOP \u4E2D\u90E8\u65F6\u7684\u56DE\u8DF3\u8DDD\u79BB",
345
+ usageP5: "\u957F GOP \u4F4E\u7801\u7387\u6863\u7684\u89E3\u7801\u5BB9\u91CF"
346
+ },
347
+ {
348
+ id: "hevc-longgop-bframes",
349
+ codec: "hevc",
350
+ width: 1280,
351
+ height: 720,
352
+ fps: 25,
353
+ durationSeconds: 10,
354
+ gopFrames: 125,
355
+ gopMode: "long",
356
+ bframes: 3,
357
+ startPtsSeconds: 10,
358
+ sparse: false,
359
+ moovTrailing: true,
360
+ delayedTail: true,
361
+ usageP3: "HEVC \u957F GOP \u8DE8\u7A97 seek \u5BF9\u7167",
362
+ usageP5: "HEVC \u957F GOP \u5BB9\u91CF\u6863"
363
+ },
364
+ {
365
+ id: "h264-singlegop",
366
+ codec: "h264",
367
+ width: 1280,
368
+ height: 720,
369
+ fps: 25,
370
+ durationSeconds: 8,
371
+ gopFrames: 200,
372
+ gopMode: "single",
373
+ bframes: 3,
374
+ startPtsSeconds: 20,
375
+ sparse: false,
376
+ moovTrailing: true,
377
+ delayedTail: true,
378
+ usageP3: "\u5355 GOP \u6781\u9650\uFF1A\u7A97\u53E3\u4EFB\u610F\u70B9 seek \u5FC5\u987B\u56DE\u8DF3\u5230\u6BB5\u9996 IDR",
379
+ usageP5: "\u5355 GOP \u6781\u4F4E\u7801\u7387\u6863\u5BB9\u91CF"
380
+ },
381
+ {
382
+ id: "hevc-singlegop",
383
+ codec: "hevc",
384
+ width: 1280,
385
+ height: 720,
386
+ fps: 25,
387
+ durationSeconds: 8,
388
+ gopFrames: 200,
389
+ gopMode: "single",
390
+ bframes: 3,
391
+ startPtsSeconds: 20,
392
+ sparse: false,
393
+ moovTrailing: true,
394
+ delayedTail: true,
395
+ usageP3: "HEVC \u5355 GOP \u6781\u9650\u5BF9\u7167",
396
+ usageP5: "HEVC \u5355 GOP \u5BB9\u91CF\u6863"
397
+ },
398
+ {
399
+ id: "h264-sparse-1fps",
400
+ codec: "h264",
401
+ width: 1280,
402
+ height: 720,
403
+ fps: 1,
404
+ durationSeconds: 8,
405
+ gopFrames: 4,
406
+ gopMode: "short",
407
+ bframes: 0,
408
+ startPtsSeconds: 30,
409
+ sparse: true,
410
+ moovTrailing: true,
411
+ delayedTail: false,
412
+ usageP3: "1fps \u7A00\u758F\u6D41\uFF1A\u7A97\u53E3\u6309\u79D2\u6B65\u8FDB\u65F6\u7684\u5E27\u53F7 / \u5A92\u4F53\u65F6\u95F4\u5BF9\u5E94",
413
+ usageP5: "\u7A00\u758F\u6D41\u6781\u4F4E\u5E27\u7387\u5BB9\u91CF\uFF08\u7F29\u7565\u56FE\u5F0F\u62C9\u6D41\uFF09"
414
+ },
415
+ {
416
+ id: "hevc-sparse-1fps",
417
+ codec: "hevc",
418
+ width: 1280,
419
+ height: 720,
420
+ fps: 1,
421
+ durationSeconds: 8,
422
+ gopFrames: 4,
423
+ gopMode: "short",
424
+ bframes: 0,
425
+ startPtsSeconds: 30,
426
+ sparse: true,
427
+ moovTrailing: true,
428
+ delayedTail: false,
429
+ usageP3: "HEVC 1fps \u7A00\u758F\u5BF9\u7167",
430
+ usageP5: "HEVC \u7A00\u758F\u6D41\u5BB9\u91CF\u6863"
431
+ },
432
+ {
433
+ id: "h264-gap-a",
434
+ codec: "h264",
435
+ width: 1280,
436
+ height: 720,
437
+ fps: 25,
438
+ durationSeconds: 6,
439
+ gopFrames: 25,
440
+ gopMode: "short",
441
+ bframes: 0,
442
+ startPtsSeconds: 100,
443
+ sparse: false,
444
+ moovTrailing: true,
445
+ delayedTail: false,
446
+ pairKind: "gap",
447
+ pairRole: "a",
448
+ usageP3: "gap \u53CC\u6BB5\u9996\u6BB5\uFF1A\u4E0E gap-b \u4E4B\u95F4\u7559 2s \u7A7A\u6D1E\uFF0C\u6D4B\u7A97\u53E3\u7A7A\u6D1E\u5C55\u793A",
449
+ usageP5: "\u65AD\u6D41\u7A7A\u6D1E\u573A\u666F\u7684\u5BB9\u91CF\u5360\u6BD4\u6A21\u578B\u8F93\u5165"
450
+ },
451
+ {
452
+ id: "h264-gap-b",
453
+ codec: "h264",
454
+ width: 1280,
455
+ height: 720,
456
+ fps: 25,
457
+ durationSeconds: 6,
458
+ gopFrames: 25,
459
+ gopMode: "short",
460
+ bframes: 0,
461
+ startPtsSeconds: 108,
462
+ sparse: false,
463
+ moovTrailing: true,
464
+ delayedTail: false,
465
+ pairKind: "gap",
466
+ pairRole: "b",
467
+ usageP3: "gap \u53CC\u6BB5\u6B21\u6BB5\uFF1A\u9996 PTS \u665A\u4E8E\u9996\u6BB5\u5C3E 2s\uFF0C\u7A97\u53E3\u5E94\u5448\u73B0\u7A7A\u6D1E",
468
+ usageP5: "\u540C gap-a \u914D\u5BF9\u4F7F\u7528"
469
+ },
470
+ {
471
+ id: "h264-overlap-a",
472
+ codec: "h264",
473
+ width: 1280,
474
+ height: 720,
475
+ fps: 25,
476
+ durationSeconds: 6,
477
+ gopFrames: 25,
478
+ gopMode: "short",
479
+ bframes: 0,
480
+ startPtsSeconds: 200,
481
+ sparse: false,
482
+ moovTrailing: true,
483
+ delayedTail: false,
484
+ pairKind: "overlap",
485
+ pairRole: "a",
486
+ usageP3: "overlap \u53CC\u6BB5\u9996\u6BB5\uFF1A\u4E0E overlap-b \u91CD\u53E0 2s\uFF0C\u6D4B\u7A97\u53E3\u53BB\u91CD",
487
+ usageP5: "\u91CD\u53E0\u5F55\u50CF\u573A\u666F\u7684\u5BB9\u91CF\u5360\u6BD4\u6A21\u578B\u8F93\u5165"
488
+ },
489
+ {
490
+ id: "h264-overlap-b",
491
+ codec: "h264",
492
+ width: 1280,
493
+ height: 720,
494
+ fps: 25,
495
+ durationSeconds: 6,
496
+ gopFrames: 25,
497
+ gopMode: "short",
498
+ bframes: 0,
499
+ startPtsSeconds: 204,
500
+ sparse: false,
501
+ moovTrailing: true,
502
+ delayedTail: false,
503
+ pairKind: "overlap",
504
+ pairRole: "b",
505
+ usageP3: "overlap \u53CC\u6BB5\u6B21\u6BB5\uFF1A\u9996 PTS \u65E9\u4E8E\u9996\u6BB5\u5C3E 2s\uFF0C\u7A97\u53E3\u5E94\u53BB\u91CD\u5408\u5E76",
506
+ usageP5: "\u540C overlap-a \u914D\u5BF9\u4F7F\u7528"
507
+ },
508
+ {
509
+ id: "h264-size-720p",
510
+ codec: "h264",
511
+ width: 1280,
512
+ height: 720,
513
+ fps: 25,
514
+ durationSeconds: 6,
515
+ gopFrames: 25,
516
+ gopMode: "short",
517
+ bframes: 0,
518
+ startPtsSeconds: 300,
519
+ sparse: false,
520
+ moovTrailing: true,
521
+ delayedTail: false,
522
+ pairKind: "sizeSwitch",
523
+ pairRole: "a",
524
+ usageP3: "\u5C3A\u5BF8\u5207\u6362\u9996\u6BB5\uFF08720p high\uFF09\uFF1A\u4E0E 360p \u6BB5\u540C\u8D77\u70B9\uFF0C\u6D4B\u7A97\u53E3\u5185\u5206\u8FA8\u7387\u5207\u6362",
525
+ usageP5: "\u6E05\u6670\u5EA6\u5207\u6362\u65F6\u7684\u77AC\u65F6\u89E3\u7801\u5CF0\u503C"
526
+ },
527
+ {
528
+ id: "h264-size-360p",
529
+ codec: "h264",
530
+ width: 640,
531
+ height: 360,
532
+ fps: 25,
533
+ durationSeconds: 6,
534
+ gopFrames: 25,
535
+ gopMode: "short",
536
+ bframes: 0,
537
+ startPtsSeconds: 300,
538
+ sparse: false,
539
+ moovTrailing: true,
540
+ delayedTail: false,
541
+ pairKind: "sizeSwitch",
542
+ pairRole: "b",
543
+ usageP3: "\u5C3A\u5BF8\u5207\u6362\u6B21\u6BB5\uFF08360p baseline\uFF09\uFF1A\u7F16\u7801\u914D\u7F6E\u4E0E\u5206\u8FA8\u7387\u540C\u65F6\u53D8\u5316",
544
+ usageP5: "\u540C size-720p \u914D\u5BF9\u4F7F\u7528"
545
+ }
546
+ ];
547
+ function drawtextFilter(fontfile) {
548
+ return `drawtext=fontfile=${fontfile}:text='n=%{frame_num} t=%{pts\\:hms}':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=16:y=16`;
549
+ }
550
+ function buildWindowPlan(spec, opts) {
551
+ const useDrawtext = opts?.useDrawtext ?? true;
552
+ const fontfile = opts?.fontfile ?? DEFAULT_DRAWTEXT_FONT;
553
+ const specCodec = CODEC_SPEC[spec.codec];
554
+ const videoInput = `testsrc2=size=${spec.width}x${spec.height}:rate=${spec.fps}`;
555
+ const audioInput = `sine=frequency=1000:sample_rate=44100`;
556
+ const videoFilter = useDrawtext ? `format=yuv420p,${drawtextFilter(fontfile)}` : "format=yuv420p";
557
+ const outputFps = spec.sparse ? 1 : spec.fps;
558
+ const sizeSwitchBaseline = spec.pairKind === "sizeSwitch" && spec.pairRole === "b" ? ["-profile:v", "baseline"] : specCodec.videoArgs;
559
+ const ffmpegOutputArgs = [
560
+ "-y",
561
+ "-f",
562
+ "lavfi",
563
+ "-i",
564
+ videoInput,
565
+ "-f",
566
+ "lavfi",
567
+ "-i",
568
+ audioInput,
569
+ "-t",
570
+ String(spec.durationSeconds),
571
+ "-vf",
572
+ videoFilter,
573
+ "-r",
574
+ String(outputFps),
575
+ "-c:v",
576
+ specCodec.encoder,
577
+ ...sizeSwitchBaseline,
578
+ "-pix_fmt",
579
+ specCodec.pixelFormat,
580
+ "-bf",
581
+ String(spec.bframes),
582
+ "-g",
583
+ String(spec.gopFrames),
584
+ "-keyint_min",
585
+ String(spec.gopFrames),
586
+ "-sc_threshold",
587
+ "0",
588
+ ...containerExtraArgs("mp4", spec.codec),
589
+ "-c:a",
590
+ "aac",
591
+ "-b:a",
592
+ "128k",
593
+ "-output_ts_offset",
594
+ String(spec.startPtsSeconds),
595
+ "-f",
596
+ "mp4",
597
+ "-movflags",
598
+ "-faststart"
599
+ ];
600
+ const mainFile = join3(WINDOW_CONTAINER_DIR, `${spec.id}.mp4`);
601
+ const encode = {
602
+ codec: spec.codec,
603
+ gopFrames: spec.gopFrames,
604
+ gopMode: spec.gopMode,
605
+ bframes: spec.bframes,
606
+ startPtsSeconds: spec.startPtsSeconds,
607
+ sparse: spec.sparse,
608
+ moov: "trailing",
609
+ delayedTail: spec.delayedTail,
610
+ width: spec.width,
611
+ height: spec.height,
612
+ fps: outputFps
613
+ };
614
+ return { id: spec.id, spec, mainFile, files: [mainFile], ffmpegOutputArgs, encode };
615
+ }
616
+ function expandWindowMatrix(opts) {
617
+ return WINDOW_SPECS.map((spec) => buildWindowPlan(spec, opts));
618
+ }
619
+
250
620
  // src/server/server.ts
251
621
  import { createReadStream, readFileSync as readFileSync3 } from "node:fs";
252
622
  import {
@@ -321,7 +691,7 @@ function setRangeHeaders(req, res, fullPath, mime) {
321
691
 
322
692
  // src/server/routes.ts
323
693
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
324
- import { join as join3 } from "node:path";
694
+ import { join as join4 } from "node:path";
325
695
  var FORMAT_SET = Object.fromEntries(ALL_FORMATS.map((f) => [f, true]));
326
696
  var CODEC_SET = Object.fromEntries(ALL_CODECS.map((c) => [c, true]));
327
697
  var HLS_FMP4 = { hls: true, fmp4: true };
@@ -337,23 +707,23 @@ function resolveRoute(path) {
337
707
  if (HLS_FMP4[formatRaw]) return null;
338
708
  const format = formatRaw;
339
709
  const codec = codecRaw;
340
- return join3(format, `${codec}.${format}`);
710
+ return join4(format, `${codec}.${format}`);
341
711
  }
342
712
  const playlist = PLAYLIST.exec(path);
343
713
  if (playlist) {
344
714
  const [, formatRaw, codecRaw, rest] = playlist;
345
715
  if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
346
- return join3(formatRaw, codecRaw, rest);
716
+ return join4(formatRaw, codecRaw, rest);
347
717
  }
348
718
  const vod = VOD.exec(path);
349
719
  if (vod) {
350
720
  const [, name] = vod;
351
- return join3("vod", name);
721
+ return join4("vod", name);
352
722
  }
353
723
  const vodHevc = VOD_HEVC.exec(path);
354
724
  if (vodHevc) {
355
725
  const [, name] = vodHevc;
356
- return join3("vod-hevc", name);
726
+ return join4("vod-hevc", name);
357
727
  }
358
728
  return null;
359
729
  }
@@ -376,7 +746,7 @@ function handleList(res, manifest) {
376
746
  writeJson(res, 200, manifest);
377
747
  }
378
748
  function handleVodList(res, manifest, fixtureDir, codec = "h264") {
379
- const vodListPath = codec === "hevc" ? join3(fixtureDir, "vod-hevc", "vod-list.json") : join3(fixtureDir, "vod", "vod-list.json");
749
+ const vodListPath = codec === "hevc" ? join4(fixtureDir, "vod-hevc", "vod-list.json") : join4(fixtureDir, "vod", "vod-list.json");
380
750
  if (existsSync2(vodListPath)) {
381
751
  try {
382
752
  const raw = JSON.parse(readFileSync2(vodListPath, "utf8"));
@@ -448,9 +818,15 @@ function parseChannelCount(raw) {
448
818
  if (!Number.isFinite(value) || value < 1) return MULTI_VOD_DEFAULT_CHANNELS;
449
819
  return Math.min(MULTI_VOD_MAX_CHANNELS, Math.floor(value));
450
820
  }
821
+ function parseBoundedQueryInt(raw, min, max) {
822
+ if (raw == null || raw === "") return null;
823
+ const value = Number(raw);
824
+ if (!Number.isInteger(value) || value < min || value > max) return null;
825
+ return value;
826
+ }
451
827
  function handleVodListMulti(res, manifest, fixtureDir, channels) {
452
828
  const entriesSource = (() => {
453
- const vodListPath = join3(fixtureDir, "vod", "vod-list.json");
829
+ const vodListPath = join4(fixtureDir, "vod", "vod-list.json");
454
830
  if (existsSync2(vodListPath)) {
455
831
  try {
456
832
  const raw = JSON.parse(readFileSync2(vodListPath, "utf8"));
@@ -745,7 +1121,13 @@ function createStreamsServer(options) {
745
1121
  }
746
1122
  const liveMatch = matchLivePath(path);
747
1123
  if (liveMatch) {
748
- handleLiveStream(req, res, liveMatch.format, liveMatch.codec);
1124
+ const liveQuery = (req.url ?? "").split("?", 2)[1] ?? "";
1125
+ const liveParams = new URLSearchParams(liveQuery);
1126
+ handleLiveStream(req, res, liveMatch.format, liveMatch.codec, {
1127
+ width: parseBoundedQueryInt(liveParams.get("width"), 160, 3840) ?? void 0,
1128
+ height: parseBoundedQueryInt(liveParams.get("height"), 160, 3840) ?? void 0,
1129
+ fps: parseBoundedQueryInt(liveParams.get("fps"), 1, 60) ?? void 0
1130
+ });
749
1131
  return;
750
1132
  }
751
1133
  writeNotFound(res);
@@ -832,7 +1214,7 @@ import {
832
1214
  writeFileSync
833
1215
  } from "node:fs";
834
1216
  import { spawn as spawn2 } from "node:child_process";
835
- import { join as join4, resolve as resolve3 } from "node:path";
1217
+ import { join as join5, resolve as resolve3 } from "node:path";
836
1218
  var FFMPEG_PROBE_ARGS = ["-version"];
837
1219
  async function generateFixtures(opts = {}) {
838
1220
  const ffmpegPath = opts.ffmpegPath ?? await findFfmpeg();
@@ -873,26 +1255,39 @@ async function generateFixtures(opts = {}) {
873
1255
  }
874
1256
  }
875
1257
  copyVodSamples(fixtureDir);
1258
+ let windowFixtures = [];
1259
+ if (opts.includeWindow !== false) {
1260
+ const windowResult = await generateWindowSet(
1261
+ fixtureDir,
1262
+ ffmpegPath,
1263
+ opts.quiet ?? false,
1264
+ opts.overwrite ?? false
1265
+ );
1266
+ windowFixtures = windowResult.items;
1267
+ generated += windowResult.generated;
1268
+ skipped += windowResult.skipped;
1269
+ }
876
1270
  const manifest = {
877
1271
  version: 1,
878
1272
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
879
1273
  durationSeconds: duration,
880
- fixtures: manifestItems
1274
+ fixtures: manifestItems,
1275
+ windowFixtures
881
1276
  };
882
- writeFileSync(join4(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
1277
+ writeFileSync(join5(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
883
1278
  return { fixtureDir, manifest, generated, skipped, ffmpegPath, ffmpegMissing: false };
884
1279
  }
885
1280
  async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
886
- const outDir = join4(fixtureDir, plan.containerDir);
1281
+ const outDir = join5(fixtureDir, plan.containerDir);
887
1282
  mkdirSync(outDir, { recursive: true });
888
- const outFile = join4(fixtureDir, plan.mainFile);
1283
+ const outFile = join5(fixtureDir, plan.mainFile);
889
1284
  if (!overwrite && existsSync3(outFile)) {
890
1285
  return collectExisting(plan, fixtureDir);
891
1286
  }
892
1287
  if (plan.layout.isContainerDirectory) {
893
1288
  for (const f of readdirSync(outDir)) {
894
1289
  if (f === plan.layout.mainFileName) continue;
895
- rmIfExists(join4(outDir, f));
1290
+ rmIfExists(join5(outDir, f));
896
1291
  }
897
1292
  }
898
1293
  const args = plan.ffmpegOutputArgs.concat([outFile]);
@@ -906,21 +1301,89 @@ async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
906
1301
  }
907
1302
  return collectExisting(plan, fixtureDir);
908
1303
  }
1304
+ async function generateWindowSet(fixtureDir, ffmpegPath, quiet, overwrite) {
1305
+ const outDir = join5(fixtureDir, WINDOW_CONTAINER_DIR);
1306
+ mkdirSync(outDir, { recursive: true });
1307
+ const useDrawtext = await supportsDrawtext(ffmpegPath);
1308
+ const fontfile = useDrawtext ? pickFontfile() : void 0;
1309
+ if (!useDrawtext && !quiet) {
1310
+ process.stdout.write(
1311
+ "[generate] drawtext \u6EE4\u955C\u7F3A\u5931\uFF0C\u70E7\u5F55\u7A97\u964D\u7EA7\u4E3A\u88F8 testsrc2\uFF08\u65E0\u70E7\u5F55\u6587\u5B57\uFF0C\u7F16\u7801\u53C2\u6570\u4E0D\u53D8\uFF1B\u5B8C\u6574\u547D\u4EE4\u89C1 README P3-H \u4E00\u8282\uFF09\n"
1312
+ );
1313
+ }
1314
+ const items = [];
1315
+ let generated = 0;
1316
+ let skipped = 0;
1317
+ for (const spec of WINDOW_SPECS) {
1318
+ const plan = buildWindowPlan(spec, { useDrawtext, fontfile });
1319
+ const outFile = join5(fixtureDir, plan.mainFile);
1320
+ if (!overwrite && existsSync3(outFile)) {
1321
+ items.push(collectWindowExisting(plan, fixtureDir));
1322
+ skipped += 1;
1323
+ continue;
1324
+ }
1325
+ if (!quiet) {
1326
+ process.stdout.write(`[generate] window/${plan.id} -> ${plan.mainFile}
1327
+ `);
1328
+ }
1329
+ const code = await runFfmpeg(ffmpegPath, plan.ffmpegOutputArgs.concat([outFile]), quiet);
1330
+ if (code !== 0) {
1331
+ throw new Error(`ffmpeg exited with code ${code} for window/${plan.id}`);
1332
+ }
1333
+ items.push(collectWindowExisting(plan, fixtureDir));
1334
+ generated += 1;
1335
+ }
1336
+ return { items, generated, skipped };
1337
+ }
1338
+ function collectWindowExisting(plan, fixtureDir) {
1339
+ const entries = [];
1340
+ for (const rel of plan.files) {
1341
+ const abs = join5(fixtureDir, rel);
1342
+ if (!existsSync3(abs)) continue;
1343
+ entries.push({ path: rel, size: statSync3(abs).size, sha256: hashFile(abs) });
1344
+ }
1345
+ return { id: plan.id, codec: plan.spec.codec, entries, encode: plan.encode };
1346
+ }
1347
+ async function supportsDrawtext(ffmpegPath) {
1348
+ const { promise, resolve: resolve4 } = Promise.withResolvers();
1349
+ const proc = spawn2(ffmpegPath, ["-hide_banner", "-filters"], {
1350
+ stdio: ["ignore", "pipe", "ignore"]
1351
+ });
1352
+ let out = "";
1353
+ proc.stdout?.on("data", (chunk) => {
1354
+ out += chunk.toString("utf8");
1355
+ });
1356
+ proc.once("error", () => resolve4(false));
1357
+ proc.once("exit", (code) => {
1358
+ if (code !== 0) {
1359
+ resolve4(false);
1360
+ return;
1361
+ }
1362
+ resolve4(/^\s*\S{2,3}\s+drawtext\s/m.test(out));
1363
+ });
1364
+ return promise;
1365
+ }
1366
+ function pickFontfile() {
1367
+ for (const candidate of DRAWTEXT_FONT_CANDIDATES) {
1368
+ if (existsSync3(candidate)) return candidate;
1369
+ }
1370
+ return DRAWTEXT_FONT_CANDIDATES[0] ?? "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
1371
+ }
909
1372
  function collectExisting(plan, fixtureDir) {
910
1373
  const entries = [];
911
1374
  for (const rel of plan.files) {
912
- const abs = join4(fixtureDir, rel);
1375
+ const abs = join5(fixtureDir, rel);
913
1376
  if (!existsSync3(abs)) continue;
914
1377
  entries.push({ path: rel, size: statSync3(abs).size, sha256: hashFile(abs) });
915
1378
  }
916
1379
  if (plan.layout.isContainerDirectory) {
917
- const absDir = join4(fixtureDir, plan.containerDir);
1380
+ const absDir = join5(fixtureDir, plan.containerDir);
918
1381
  if (existsSync3(absDir)) {
919
1382
  for (const name of readdirSync(absDir).sort()) {
920
1383
  if (name === plan.layout.mainFileName) continue;
921
- const rel = join4(plan.containerDir, name);
1384
+ const rel = join5(plan.containerDir, name);
922
1385
  if (entries.find((e) => e.path === rel)) continue;
923
- const abs = join4(absDir, name);
1386
+ const abs = join5(absDir, name);
924
1387
  if (!statSync3(abs).isFile()) continue;
925
1388
  entries.push({ path: rel, size: statSync3(abs).size, sha256: hashFile(abs) });
926
1389
  }
@@ -929,12 +1392,12 @@ function collectExisting(plan, fixtureDir) {
929
1392
  return { codec: plan.codec, format: plan.format, entries };
930
1393
  }
931
1394
  function copyVodSamples(fixtureDir) {
932
- const src = join4(fixtureDir, VOD_SAMPLE_RELATIVE);
1395
+ const src = join5(fixtureDir, VOD_SAMPLE_RELATIVE);
933
1396
  if (!existsSync3(src)) return;
934
- const vodDir = join4(fixtureDir, "vod");
1397
+ const vodDir = join5(fixtureDir, "vod");
935
1398
  mkdirSync(vodDir, { recursive: true });
936
1399
  for (const name of ["sample-1.mp4", "sample-2.mp4"]) {
937
- const dst = join4(vodDir, name);
1400
+ const dst = join5(vodDir, name);
938
1401
  writeFileSync(dst, readFileSync4(src));
939
1402
  }
940
1403
  }
@@ -983,7 +1446,8 @@ function emptyManifest(duration) {
983
1446
  version: 1,
984
1447
  generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
985
1448
  durationSeconds: duration,
986
- fixtures: []
1449
+ fixtures: [],
1450
+ windowFixtures: []
987
1451
  };
988
1452
  }
989
1453
 
@@ -1004,17 +1468,24 @@ export {
1004
1468
  ALL_CODECS,
1005
1469
  ALL_FORMATS,
1006
1470
  CODEC_SPEC,
1471
+ DEFAULT_DRAWTEXT_FONT,
1007
1472
  DEFAULT_DURATION_SECONDS,
1008
1473
  DEFAULT_FIXTURE_DIR,
1009
1474
  DEFAULT_HLS_SEGMENT_SECONDS,
1010
1475
  DEFAULT_HOST,
1011
1476
  DEFAULT_PORT,
1477
+ DRAWTEXT_FONT_CANDIDATES,
1012
1478
  VOD_SAMPLE_RELATIVE,
1479
+ WINDOW_CONTAINER_DIR,
1480
+ WINDOW_SPECS,
1013
1481
  buildPlan,
1482
+ buildWindowPlan,
1014
1483
  containerRelativeDir,
1015
1484
  containerRelativePath,
1016
1485
  createStreamsServer,
1486
+ drawtextFilter,
1017
1487
  expandMatrix,
1488
+ expandWindowMatrix,
1018
1489
  generateFixtures,
1019
1490
  handleLiveStream,
1020
1491
  layoutFor,
@@ -22,6 +22,11 @@ export type MultiVodListResponse = VodListResponse & {
22
22
  export declare const MULTI_VOD_MAX_CHANNELS = 9;
23
23
  export declare const MULTI_VOD_DEFAULT_CHANNELS = 4;
24
24
  export declare function parseChannelCount(raw: string | null): number;
25
+ /**
26
+ * live 流分辨率/帧率 query 解析:整数且落在 [min, max] 才采纳,否则返回 null
27
+ * (调用方回退默认值)。供 /streams/live/* 的 ?width/?height/?fps 使用。
28
+ */
29
+ export declare function parseBoundedQueryInt(raw: string | null, min: number, max: number): number | null;
25
30
  /**
26
31
  * /streams/vod-list-multi?channels=N:返回 N 个通道的录像段列表。
27
32
  * 时间基准对齐当天上海时区 00:00(与 demo 时间轴的 24h 视野对齐);
@@ -38,6 +38,7 @@ function summarizeCatalog(fixtureDir) {
38
38
  }
39
39
  let total = 0;
40
40
  for (const item of manifest.fixtures) total += item.entries.length;
41
+ for (const item of manifest.windowFixtures ?? []) total += item.entries.length;
41
42
  return {
42
43
  codecs: ALL_CODECS,
43
44
  formats: ALL_FORMATS,
@@ -239,6 +240,12 @@ function parseChannelCount(raw) {
239
240
  if (!Number.isFinite(value) || value < 1) return MULTI_VOD_DEFAULT_CHANNELS;
240
241
  return Math.min(MULTI_VOD_MAX_CHANNELS, Math.floor(value));
241
242
  }
243
+ function parseBoundedQueryInt(raw, min, max) {
244
+ if (raw == null || raw === "") return null;
245
+ const value = Number(raw);
246
+ if (!Number.isInteger(value) || value < min || value > max) return null;
247
+ return value;
248
+ }
242
249
  function handleVodListMulti(res, manifest, fixtureDir, channels) {
243
250
  const entriesSource = (() => {
244
251
  const vodListPath = join(fixtureDir, "vod", "vod-list.json");
@@ -558,7 +565,13 @@ function createStreamsServer(options) {
558
565
  }
559
566
  const liveMatch = matchLivePath(path);
560
567
  if (liveMatch) {
561
- handleLiveStream(req, res, liveMatch.format, liveMatch.codec);
568
+ const liveQuery = (req.url ?? "").split("?", 2)[1] ?? "";
569
+ const liveParams = new URLSearchParams(liveQuery);
570
+ handleLiveStream(req, res, liveMatch.format, liveMatch.codec, {
571
+ width: parseBoundedQueryInt(liveParams.get("width"), 160, 3840) ?? void 0,
572
+ height: parseBoundedQueryInt(liveParams.get("height"), 160, 3840) ?? void 0,
573
+ fps: parseBoundedQueryInt(liveParams.get("fps"), 1, 60) ?? void 0
574
+ });
562
575
  return;
563
576
  }
564
577
  writeNotFound(res);