@flow-player/test-streams 0.14.2 → 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/README.md CHANGED
@@ -50,6 +50,69 @@ npm 发布产物由 `scripts/build-package.ts` 生成:esbuild 编译出 `dist/
50
50
  1. 先跑 `test-streams-generate` 生成完整矩阵 + `manifest.json`(系统需有 ffmpeg;建议用 `--fixture-dir` 指到项目内目录,避免写入 `node_modules`);或
51
51
  2. 用 `--fixture-dir <path>` 显式指向一个已有 fixture 目录。
52
52
 
53
+ ## P3-H 烧录窗序列(window/)
54
+
55
+ P3 窗口测试与 P5 容量测试用的合成 MP4 序列:`window/<id>.mp4`(16 段,
56
+ 规格见 `src/fixtures/window.ts` 的 `WINDOW_SPECS`)。每段画面经 drawtext
57
+ 烧录帧号与媒体时间(`n=<frame_num> t=<hms>`,左上白字黑底),供视觉核对;
58
+ manifest 的 `windowFixtures` 数组为每段记录 `entries`(单文件,
59
+ size/sha256,与存量条目同契约)+ `encode`(codec/GOP/B 帧/首 PTS/稀疏标记
60
+ 等编码参数)。
61
+
62
+ 参数矩阵(`moov` 全段为 trailing,即关闭 faststart;`delayedTail` 为 true
63
+ 的段用 B 帧回吐延迟考察尾部 delayed 帧):
64
+
65
+ | id | codec | GOP | B 帧 | 首 PTS(s) | 稀疏 | 用途 P3 | 用途 P5 |
66
+ |----|-------|-----|------|-----------|------|---------|---------|
67
+ | h264-shortgop-bframes | h264 | 短 25f/1s | 3 | 5 | 否 | 短 GOP 有 B 帧基线:窗口逐 GOP 前进对齐 | 常规解码负载基线 |
68
+ | h264-shortgop-nobframes | h264 | 短 25f/1s | 0 | 5 | 否 | 无 B 帧对照:PTS 单调性 | 低延迟容量对照 |
69
+ | hevc-shortgop-bframes | hevc | 短 25f/1s | 3 | 5 | 否 | HEVC 短 GOP 基线(hvc1) | HEVC 解码基线 |
70
+ | hevc-shortgop-nobframes | hevc | 短 25f/1s | 0 | 5 | 否 | HEVC 无 B 帧对照 | HEVC 低延迟对照 |
71
+ | h264-longgop-bframes | h264 | 长 125f/5s | 3 | 10 | 否 | 长 GOP 跨窗 seek 回跳距离 | 长 GOP 容量档 |
72
+ | hevc-longgop-bframes | hevc | 长 125f/5s | 3 | 10 | 否 | HEVC 长 GOP 对照 | HEVC 长 GOP 容量档 |
73
+ | h264-singlegop | h264 | 单 200f/8s | 3 | 20 | 否 | 单 GOP 极限:任意 seek 回段首 IDR | 极低码率容量档 |
74
+ | hevc-singlegop | hevc | 单 200f/8s | 3 | 20 | 否 | HEVC 单 GOP 对照 | HEVC 单 GOP 容量档 |
75
+ | h264-sparse-1fps | h264 | 短 4f | 0 | 30 | 是 | 1fps 稀疏:秒步进帧号/时间对应 | 稀疏流容量档 |
76
+ | hevc-sparse-1fps | hevc | 短 4f | 0 | 30 | 是 | HEVC 稀疏对照 | HEVC 稀疏容量档 |
77
+ | h264-gap-a / h264-gap-b | h264 | 短 25f | 0 | 100 / 108 | 否 | gap 双段:2s 空洞展示 | 空洞占比模型输入 |
78
+ | h264-overlap-a / h264-overlap-b | h264 | 短 25f | 0 | 200 / 204 | 否 | overlap 双段:2s 重叠去重 | 重叠占比模型输入 |
79
+ | h264-size-720p / h264-size-360p | h264 | 短 25f | 0 | 300 / 300 | 否 | 同起点尺寸+配置切换(high→baseline) | 切换瞬时峰值 |
80
+
81
+ 生成(默认连带烧录窗;`--skip-window` 跳过以加速反馈):
82
+
83
+ ```bash
84
+ # 全量(含烧录窗)
85
+ node src/cli/generate.ts --fixture-dir ./fixtures
86
+ # 快速反馈(跳过烧录窗)
87
+ node src/cli/generate.ts --fixture-dir ./fixtures --skip-window
88
+ # 仅看烧录窗计划(不跑 ffmpeg,离线可执行)
89
+ node --test test/window.test.ts
90
+ ```
91
+
92
+ 单段 canonical 命令(以 h264-shortgop-bframes 为例,`<font>` 取本机存在字体,
93
+ `DRAWTEXT_FONT_CANDIDATES` 顺序探测;HEVC 段追加 `-tag:v hvc1`):
94
+
95
+ ```bash
96
+ ffmpeg -y -f lavfi -i "testsrc2=size=1280x720:rate=25" \
97
+ -f lavfi -i "sine=frequency=1000:sample_rate=44100" -t 6 \
98
+ -vf "format=yuv420p,drawtext=fontfile=<font>:text='n=%{frame_num} t=%{pts\:hms}':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=16:y=16" \
99
+ -r 25 -c:v libx264 -profile:v high -preset veryfast -pix_fmt yuv420p \
100
+ -bf 3 -g 25 -keyint_min 25 -sc_threshold 0 \
101
+ -c:a aac -b:a 128k -output_ts_offset 5 -f mp4 -movflags -faststart \
102
+ window/h264-shortgop-bframes.mp4
103
+ ```
104
+
105
+ 注意:
106
+
107
+ - brew 版 ffmpeg 默认缺 libfreetype(无 drawtext 滤镜)时,生成器自动降级为
108
+ 裸 testsrc2(画面无烧录文字,但 GOP/B 帧/PTS/稀疏/moov 参数不变),并在
109
+ stdout 提示;Linux CI(带 libfreetype)走完整 drawtext 链路。
110
+ - 烧录窗 fixture 为文件级产物(`window/*.mp4` + manifest `windowFixtures`),
111
+ 供 P3/P5 测试 harness 直接读取或 ffprobe 校验;`/streams/<format>/<codec>`
112
+ 等 VOD 路由语义不变,不新增路由。
113
+ - `/streams/list` 原样返回 manifest(含 `windowFixtures`),`summarizeCatalog`
114
+ 的 fixtures 计数包含烧录窗条目。
115
+
53
116
  ## 注意
54
117
 
55
118
  - 内置矩阵、CLI 参数与路径语义与 [仓库文档](../../docs/reference/test-stream.md) 保持一致;**不要在业务代码里硬编码流地址**——demo 侧经 `VITE_FLOW_PLAYER_DEMO_STREAMS_BASE` 切换基址。
@@ -22,7 +22,7 @@ import {
22
22
  writeFileSync
23
23
  } from "node:fs";
24
24
  import { spawn } from "node:child_process";
25
- import { join as join3, resolve } from "node:path";
25
+ import { join as join4, resolve } from "node:path";
26
26
 
27
27
  // src/fixtures/matrix.ts
28
28
  import { join as join2 } from "node:path";
@@ -208,6 +208,372 @@ function expandMatrix(opts) {
208
208
  }
209
209
  var VOD_SAMPLE_RELATIVE = "mp4/h264.mp4";
210
210
 
211
+ // src/fixtures/window.ts
212
+ import { join as join3 } from "node:path";
213
+ var DEFAULT_DRAWTEXT_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
214
+ var DRAWTEXT_FONT_CANDIDATES = [
215
+ "/System/Library/Fonts/Supplemental/Verdana.ttf",
216
+ "/System/Library/Fonts/Hiragino Sans GB.ttc",
217
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
218
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
219
+ ];
220
+ var WINDOW_CONTAINER_DIR = "window";
221
+ var WINDOW_SPECS = [
222
+ {
223
+ id: "h264-shortgop-bframes",
224
+ codec: "h264",
225
+ width: 1280,
226
+ height: 720,
227
+ fps: 25,
228
+ durationSeconds: 6,
229
+ gopFrames: 25,
230
+ gopMode: "short",
231
+ bframes: 3,
232
+ startPtsSeconds: 5,
233
+ sparse: false,
234
+ moovTrailing: true,
235
+ delayedTail: true,
236
+ usageP3: "\u77ED GOP \u6709 B \u5E27\u57FA\u7EBF\uFF1A\u7A97\u53E3\u9010 GOP \u524D\u8FDB\u65F6\u7684\u89E3\u7801\u8D77\u70B9\u5BF9\u9F50",
237
+ usageP5: "\u591A\u8DEF\u5E76\u53D1\u4E0B\u7684\u5E38\u89C4\u89E3\u7801\u8D1F\u8F7D\u57FA\u7EBF"
238
+ },
239
+ {
240
+ id: "h264-shortgop-nobframes",
241
+ codec: "h264",
242
+ width: 1280,
243
+ height: 720,
244
+ fps: 25,
245
+ durationSeconds: 6,
246
+ gopFrames: 25,
247
+ gopMode: "short",
248
+ bframes: 0,
249
+ startPtsSeconds: 5,
250
+ sparse: false,
251
+ moovTrailing: true,
252
+ delayedTail: false,
253
+ usageP3: "\u65E0 B \u5E27\u5BF9\u7167\uFF1A\u5E27\u8F93\u51FA\u65E0\u91CD\u6392\u5EF6\u8FDF\uFF0C\u6821\u9A8C PTS \u5355\u8C03\u6027",
254
+ usageP5: "\u4F4E\u5EF6\u8FDF\u94FE\u8DEF\u7684\u5BB9\u91CF\u5BF9\u7167\u7EC4"
255
+ },
256
+ {
257
+ id: "hevc-shortgop-bframes",
258
+ codec: "hevc",
259
+ width: 1280,
260
+ height: 720,
261
+ fps: 25,
262
+ durationSeconds: 6,
263
+ gopFrames: 25,
264
+ gopMode: "short",
265
+ bframes: 3,
266
+ startPtsSeconds: 5,
267
+ sparse: false,
268
+ moovTrailing: true,
269
+ delayedTail: true,
270
+ usageP3: "HEVC \u77ED GOP \u6709 B \u5E27\u57FA\u7EBF\uFF1Ahvc1 \u5C01\u88C5\u4E0B\u7A97\u53E3\u63A8\u8FDB",
271
+ usageP5: "HEVC \u8F6F\u89E3 / \u786C\u89E3\u5BB9\u91CF\u57FA\u7EBF"
272
+ },
273
+ {
274
+ id: "hevc-shortgop-nobframes",
275
+ codec: "hevc",
276
+ width: 1280,
277
+ height: 720,
278
+ fps: 25,
279
+ durationSeconds: 6,
280
+ gopFrames: 25,
281
+ gopMode: "short",
282
+ bframes: 0,
283
+ startPtsSeconds: 5,
284
+ sparse: false,
285
+ moovTrailing: true,
286
+ delayedTail: false,
287
+ usageP3: "HEVC \u65E0 B \u5E27\u5BF9\u7167\uFF1A\u91CD\u6392\u5173\u95ED\u65F6\u7684\u7A97\u53E3\u884C\u4E3A",
288
+ usageP5: "HEVC \u4F4E\u5EF6\u8FDF\u5BB9\u91CF\u5BF9\u7167\u7EC4"
289
+ },
290
+ {
291
+ id: "h264-longgop-bframes",
292
+ codec: "h264",
293
+ width: 1280,
294
+ height: 720,
295
+ fps: 25,
296
+ durationSeconds: 10,
297
+ gopFrames: 125,
298
+ gopMode: "long",
299
+ bframes: 3,
300
+ startPtsSeconds: 10,
301
+ sparse: false,
302
+ moovTrailing: true,
303
+ delayedTail: true,
304
+ usageP3: "\u957F GOP\uFF085s\uFF09\u8DE8\u7A97 seek\uFF1A\u7A97\u53E3\u843D\u5728 GOP \u4E2D\u90E8\u65F6\u7684\u56DE\u8DF3\u8DDD\u79BB",
305
+ usageP5: "\u957F GOP \u4F4E\u7801\u7387\u6863\u7684\u89E3\u7801\u5BB9\u91CF"
306
+ },
307
+ {
308
+ id: "hevc-longgop-bframes",
309
+ codec: "hevc",
310
+ width: 1280,
311
+ height: 720,
312
+ fps: 25,
313
+ durationSeconds: 10,
314
+ gopFrames: 125,
315
+ gopMode: "long",
316
+ bframes: 3,
317
+ startPtsSeconds: 10,
318
+ sparse: false,
319
+ moovTrailing: true,
320
+ delayedTail: true,
321
+ usageP3: "HEVC \u957F GOP \u8DE8\u7A97 seek \u5BF9\u7167",
322
+ usageP5: "HEVC \u957F GOP \u5BB9\u91CF\u6863"
323
+ },
324
+ {
325
+ id: "h264-singlegop",
326
+ codec: "h264",
327
+ width: 1280,
328
+ height: 720,
329
+ fps: 25,
330
+ durationSeconds: 8,
331
+ gopFrames: 200,
332
+ gopMode: "single",
333
+ bframes: 3,
334
+ startPtsSeconds: 20,
335
+ sparse: false,
336
+ moovTrailing: true,
337
+ delayedTail: true,
338
+ usageP3: "\u5355 GOP \u6781\u9650\uFF1A\u7A97\u53E3\u4EFB\u610F\u70B9 seek \u5FC5\u987B\u56DE\u8DF3\u5230\u6BB5\u9996 IDR",
339
+ usageP5: "\u5355 GOP \u6781\u4F4E\u7801\u7387\u6863\u5BB9\u91CF"
340
+ },
341
+ {
342
+ id: "hevc-singlegop",
343
+ codec: "hevc",
344
+ width: 1280,
345
+ height: 720,
346
+ fps: 25,
347
+ durationSeconds: 8,
348
+ gopFrames: 200,
349
+ gopMode: "single",
350
+ bframes: 3,
351
+ startPtsSeconds: 20,
352
+ sparse: false,
353
+ moovTrailing: true,
354
+ delayedTail: true,
355
+ usageP3: "HEVC \u5355 GOP \u6781\u9650\u5BF9\u7167",
356
+ usageP5: "HEVC \u5355 GOP \u5BB9\u91CF\u6863"
357
+ },
358
+ {
359
+ id: "h264-sparse-1fps",
360
+ codec: "h264",
361
+ width: 1280,
362
+ height: 720,
363
+ fps: 1,
364
+ durationSeconds: 8,
365
+ gopFrames: 4,
366
+ gopMode: "short",
367
+ bframes: 0,
368
+ startPtsSeconds: 30,
369
+ sparse: true,
370
+ moovTrailing: true,
371
+ delayedTail: false,
372
+ usageP3: "1fps \u7A00\u758F\u6D41\uFF1A\u7A97\u53E3\u6309\u79D2\u6B65\u8FDB\u65F6\u7684\u5E27\u53F7 / \u5A92\u4F53\u65F6\u95F4\u5BF9\u5E94",
373
+ usageP5: "\u7A00\u758F\u6D41\u6781\u4F4E\u5E27\u7387\u5BB9\u91CF\uFF08\u7F29\u7565\u56FE\u5F0F\u62C9\u6D41\uFF09"
374
+ },
375
+ {
376
+ id: "hevc-sparse-1fps",
377
+ codec: "hevc",
378
+ width: 1280,
379
+ height: 720,
380
+ fps: 1,
381
+ durationSeconds: 8,
382
+ gopFrames: 4,
383
+ gopMode: "short",
384
+ bframes: 0,
385
+ startPtsSeconds: 30,
386
+ sparse: true,
387
+ moovTrailing: true,
388
+ delayedTail: false,
389
+ usageP3: "HEVC 1fps \u7A00\u758F\u5BF9\u7167",
390
+ usageP5: "HEVC \u7A00\u758F\u6D41\u5BB9\u91CF\u6863"
391
+ },
392
+ {
393
+ id: "h264-gap-a",
394
+ codec: "h264",
395
+ width: 1280,
396
+ height: 720,
397
+ fps: 25,
398
+ durationSeconds: 6,
399
+ gopFrames: 25,
400
+ gopMode: "short",
401
+ bframes: 0,
402
+ startPtsSeconds: 100,
403
+ sparse: false,
404
+ moovTrailing: true,
405
+ delayedTail: false,
406
+ pairKind: "gap",
407
+ pairRole: "a",
408
+ usageP3: "gap \u53CC\u6BB5\u9996\u6BB5\uFF1A\u4E0E gap-b \u4E4B\u95F4\u7559 2s \u7A7A\u6D1E\uFF0C\u6D4B\u7A97\u53E3\u7A7A\u6D1E\u5C55\u793A",
409
+ usageP5: "\u65AD\u6D41\u7A7A\u6D1E\u573A\u666F\u7684\u5BB9\u91CF\u5360\u6BD4\u6A21\u578B\u8F93\u5165"
410
+ },
411
+ {
412
+ id: "h264-gap-b",
413
+ codec: "h264",
414
+ width: 1280,
415
+ height: 720,
416
+ fps: 25,
417
+ durationSeconds: 6,
418
+ gopFrames: 25,
419
+ gopMode: "short",
420
+ bframes: 0,
421
+ startPtsSeconds: 108,
422
+ sparse: false,
423
+ moovTrailing: true,
424
+ delayedTail: false,
425
+ pairKind: "gap",
426
+ pairRole: "b",
427
+ usageP3: "gap \u53CC\u6BB5\u6B21\u6BB5\uFF1A\u9996 PTS \u665A\u4E8E\u9996\u6BB5\u5C3E 2s\uFF0C\u7A97\u53E3\u5E94\u5448\u73B0\u7A7A\u6D1E",
428
+ usageP5: "\u540C gap-a \u914D\u5BF9\u4F7F\u7528"
429
+ },
430
+ {
431
+ id: "h264-overlap-a",
432
+ codec: "h264",
433
+ width: 1280,
434
+ height: 720,
435
+ fps: 25,
436
+ durationSeconds: 6,
437
+ gopFrames: 25,
438
+ gopMode: "short",
439
+ bframes: 0,
440
+ startPtsSeconds: 200,
441
+ sparse: false,
442
+ moovTrailing: true,
443
+ delayedTail: false,
444
+ pairKind: "overlap",
445
+ pairRole: "a",
446
+ usageP3: "overlap \u53CC\u6BB5\u9996\u6BB5\uFF1A\u4E0E overlap-b \u91CD\u53E0 2s\uFF0C\u6D4B\u7A97\u53E3\u53BB\u91CD",
447
+ usageP5: "\u91CD\u53E0\u5F55\u50CF\u573A\u666F\u7684\u5BB9\u91CF\u5360\u6BD4\u6A21\u578B\u8F93\u5165"
448
+ },
449
+ {
450
+ id: "h264-overlap-b",
451
+ codec: "h264",
452
+ width: 1280,
453
+ height: 720,
454
+ fps: 25,
455
+ durationSeconds: 6,
456
+ gopFrames: 25,
457
+ gopMode: "short",
458
+ bframes: 0,
459
+ startPtsSeconds: 204,
460
+ sparse: false,
461
+ moovTrailing: true,
462
+ delayedTail: false,
463
+ pairKind: "overlap",
464
+ pairRole: "b",
465
+ usageP3: "overlap \u53CC\u6BB5\u6B21\u6BB5\uFF1A\u9996 PTS \u65E9\u4E8E\u9996\u6BB5\u5C3E 2s\uFF0C\u7A97\u53E3\u5E94\u53BB\u91CD\u5408\u5E76",
466
+ usageP5: "\u540C overlap-a \u914D\u5BF9\u4F7F\u7528"
467
+ },
468
+ {
469
+ id: "h264-size-720p",
470
+ codec: "h264",
471
+ width: 1280,
472
+ height: 720,
473
+ fps: 25,
474
+ durationSeconds: 6,
475
+ gopFrames: 25,
476
+ gopMode: "short",
477
+ bframes: 0,
478
+ startPtsSeconds: 300,
479
+ sparse: false,
480
+ moovTrailing: true,
481
+ delayedTail: false,
482
+ pairKind: "sizeSwitch",
483
+ pairRole: "a",
484
+ 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",
485
+ usageP5: "\u6E05\u6670\u5EA6\u5207\u6362\u65F6\u7684\u77AC\u65F6\u89E3\u7801\u5CF0\u503C"
486
+ },
487
+ {
488
+ id: "h264-size-360p",
489
+ codec: "h264",
490
+ width: 640,
491
+ height: 360,
492
+ fps: 25,
493
+ durationSeconds: 6,
494
+ gopFrames: 25,
495
+ gopMode: "short",
496
+ bframes: 0,
497
+ startPtsSeconds: 300,
498
+ sparse: false,
499
+ moovTrailing: true,
500
+ delayedTail: false,
501
+ pairKind: "sizeSwitch",
502
+ pairRole: "b",
503
+ usageP3: "\u5C3A\u5BF8\u5207\u6362\u6B21\u6BB5\uFF08360p baseline\uFF09\uFF1A\u7F16\u7801\u914D\u7F6E\u4E0E\u5206\u8FA8\u7387\u540C\u65F6\u53D8\u5316",
504
+ usageP5: "\u540C size-720p \u914D\u5BF9\u4F7F\u7528"
505
+ }
506
+ ];
507
+ function drawtextFilter(fontfile) {
508
+ 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`;
509
+ }
510
+ function buildWindowPlan(spec, opts) {
511
+ const useDrawtext = opts?.useDrawtext ?? true;
512
+ const fontfile = opts?.fontfile ?? DEFAULT_DRAWTEXT_FONT;
513
+ const specCodec = CODEC_SPEC[spec.codec];
514
+ const videoInput = `testsrc2=size=${spec.width}x${spec.height}:rate=${spec.fps}`;
515
+ const audioInput = `sine=frequency=1000:sample_rate=44100`;
516
+ const videoFilter = useDrawtext ? `format=yuv420p,${drawtextFilter(fontfile)}` : "format=yuv420p";
517
+ const outputFps = spec.sparse ? 1 : spec.fps;
518
+ const sizeSwitchBaseline = spec.pairKind === "sizeSwitch" && spec.pairRole === "b" ? ["-profile:v", "baseline"] : specCodec.videoArgs;
519
+ const ffmpegOutputArgs = [
520
+ "-y",
521
+ "-f",
522
+ "lavfi",
523
+ "-i",
524
+ videoInput,
525
+ "-f",
526
+ "lavfi",
527
+ "-i",
528
+ audioInput,
529
+ "-t",
530
+ String(spec.durationSeconds),
531
+ "-vf",
532
+ videoFilter,
533
+ "-r",
534
+ String(outputFps),
535
+ "-c:v",
536
+ specCodec.encoder,
537
+ ...sizeSwitchBaseline,
538
+ "-pix_fmt",
539
+ specCodec.pixelFormat,
540
+ "-bf",
541
+ String(spec.bframes),
542
+ "-g",
543
+ String(spec.gopFrames),
544
+ "-keyint_min",
545
+ String(spec.gopFrames),
546
+ "-sc_threshold",
547
+ "0",
548
+ ...containerExtraArgs("mp4", spec.codec),
549
+ "-c:a",
550
+ "aac",
551
+ "-b:a",
552
+ "128k",
553
+ "-output_ts_offset",
554
+ String(spec.startPtsSeconds),
555
+ "-f",
556
+ "mp4",
557
+ "-movflags",
558
+ "-faststart"
559
+ ];
560
+ const mainFile = join3(WINDOW_CONTAINER_DIR, `${spec.id}.mp4`);
561
+ const encode = {
562
+ codec: spec.codec,
563
+ gopFrames: spec.gopFrames,
564
+ gopMode: spec.gopMode,
565
+ bframes: spec.bframes,
566
+ startPtsSeconds: spec.startPtsSeconds,
567
+ sparse: spec.sparse,
568
+ moov: "trailing",
569
+ delayedTail: spec.delayedTail,
570
+ width: spec.width,
571
+ height: spec.height,
572
+ fps: outputFps
573
+ };
574
+ return { id: spec.id, spec, mainFile, files: [mainFile], ffmpegOutputArgs, encode };
575
+ }
576
+
211
577
  // src/fixtures/generate.ts
212
578
  var FFMPEG_PROBE_ARGS = ["-version"];
213
579
  async function generateFixtures(opts = {}) {
@@ -249,26 +615,39 @@ async function generateFixtures(opts = {}) {
249
615
  }
250
616
  }
251
617
  copyVodSamples(fixtureDir);
618
+ let windowFixtures = [];
619
+ if (opts.includeWindow !== false) {
620
+ const windowResult = await generateWindowSet(
621
+ fixtureDir,
622
+ ffmpegPath,
623
+ opts.quiet ?? false,
624
+ opts.overwrite ?? false
625
+ );
626
+ windowFixtures = windowResult.items;
627
+ generated += windowResult.generated;
628
+ skipped += windowResult.skipped;
629
+ }
252
630
  const manifest = {
253
631
  version: 1,
254
632
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
255
633
  durationSeconds: duration,
256
- fixtures: manifestItems
634
+ fixtures: manifestItems,
635
+ windowFixtures
257
636
  };
258
- writeFileSync(join3(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
637
+ writeFileSync(join4(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
259
638
  return { fixtureDir, manifest, generated, skipped, ffmpegPath, ffmpegMissing: false };
260
639
  }
261
640
  async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
262
- const outDir = join3(fixtureDir, plan.containerDir);
641
+ const outDir = join4(fixtureDir, plan.containerDir);
263
642
  mkdirSync(outDir, { recursive: true });
264
- const outFile = join3(fixtureDir, plan.mainFile);
643
+ const outFile = join4(fixtureDir, plan.mainFile);
265
644
  if (!overwrite && existsSync(outFile)) {
266
645
  return collectExisting(plan, fixtureDir);
267
646
  }
268
647
  if (plan.layout.isContainerDirectory) {
269
648
  for (const f of readdirSync(outDir)) {
270
649
  if (f === plan.layout.mainFileName) continue;
271
- rmIfExists(join3(outDir, f));
650
+ rmIfExists(join4(outDir, f));
272
651
  }
273
652
  }
274
653
  const args = plan.ffmpegOutputArgs.concat([outFile]);
@@ -282,21 +661,89 @@ async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
282
661
  }
283
662
  return collectExisting(plan, fixtureDir);
284
663
  }
664
+ async function generateWindowSet(fixtureDir, ffmpegPath, quiet, overwrite) {
665
+ const outDir = join4(fixtureDir, WINDOW_CONTAINER_DIR);
666
+ mkdirSync(outDir, { recursive: true });
667
+ const useDrawtext = await supportsDrawtext(ffmpegPath);
668
+ const fontfile = useDrawtext ? pickFontfile() : void 0;
669
+ if (!useDrawtext && !quiet) {
670
+ process.stdout.write(
671
+ "[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"
672
+ );
673
+ }
674
+ const items = [];
675
+ let generated = 0;
676
+ let skipped = 0;
677
+ for (const spec of WINDOW_SPECS) {
678
+ const plan = buildWindowPlan(spec, { useDrawtext, fontfile });
679
+ const outFile = join4(fixtureDir, plan.mainFile);
680
+ if (!overwrite && existsSync(outFile)) {
681
+ items.push(collectWindowExisting(plan, fixtureDir));
682
+ skipped += 1;
683
+ continue;
684
+ }
685
+ if (!quiet) {
686
+ process.stdout.write(`[generate] window/${plan.id} -> ${plan.mainFile}
687
+ `);
688
+ }
689
+ const code = await runFfmpeg(ffmpegPath, plan.ffmpegOutputArgs.concat([outFile]), quiet);
690
+ if (code !== 0) {
691
+ throw new Error(`ffmpeg exited with code ${code} for window/${plan.id}`);
692
+ }
693
+ items.push(collectWindowExisting(plan, fixtureDir));
694
+ generated += 1;
695
+ }
696
+ return { items, generated, skipped };
697
+ }
698
+ function collectWindowExisting(plan, fixtureDir) {
699
+ const entries = [];
700
+ for (const rel of plan.files) {
701
+ const abs = join4(fixtureDir, rel);
702
+ if (!existsSync(abs)) continue;
703
+ entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
704
+ }
705
+ return { id: plan.id, codec: plan.spec.codec, entries, encode: plan.encode };
706
+ }
707
+ async function supportsDrawtext(ffmpegPath) {
708
+ const { promise, resolve: resolve3 } = Promise.withResolvers();
709
+ const proc = spawn(ffmpegPath, ["-hide_banner", "-filters"], {
710
+ stdio: ["ignore", "pipe", "ignore"]
711
+ });
712
+ let out = "";
713
+ proc.stdout?.on("data", (chunk) => {
714
+ out += chunk.toString("utf8");
715
+ });
716
+ proc.once("error", () => resolve3(false));
717
+ proc.once("exit", (code) => {
718
+ if (code !== 0) {
719
+ resolve3(false);
720
+ return;
721
+ }
722
+ resolve3(/^\s*\S{2,3}\s+drawtext\s/m.test(out));
723
+ });
724
+ return promise;
725
+ }
726
+ function pickFontfile() {
727
+ for (const candidate of DRAWTEXT_FONT_CANDIDATES) {
728
+ if (existsSync(candidate)) return candidate;
729
+ }
730
+ return DRAWTEXT_FONT_CANDIDATES[0] ?? "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
731
+ }
285
732
  function collectExisting(plan, fixtureDir) {
286
733
  const entries = [];
287
734
  for (const rel of plan.files) {
288
- const abs = join3(fixtureDir, rel);
735
+ const abs = join4(fixtureDir, rel);
289
736
  if (!existsSync(abs)) continue;
290
737
  entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
291
738
  }
292
739
  if (plan.layout.isContainerDirectory) {
293
- const absDir = join3(fixtureDir, plan.containerDir);
740
+ const absDir = join4(fixtureDir, plan.containerDir);
294
741
  if (existsSync(absDir)) {
295
742
  for (const name of readdirSync(absDir).sort()) {
296
743
  if (name === plan.layout.mainFileName) continue;
297
- const rel = join3(plan.containerDir, name);
744
+ const rel = join4(plan.containerDir, name);
298
745
  if (entries.find((e) => e.path === rel)) continue;
299
- const abs = join3(absDir, name);
746
+ const abs = join4(absDir, name);
300
747
  if (!statSync(abs).isFile()) continue;
301
748
  entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
302
749
  }
@@ -305,12 +752,12 @@ function collectExisting(plan, fixtureDir) {
305
752
  return { codec: plan.codec, format: plan.format, entries };
306
753
  }
307
754
  function copyVodSamples(fixtureDir) {
308
- const src = join3(fixtureDir, VOD_SAMPLE_RELATIVE);
755
+ const src = join4(fixtureDir, VOD_SAMPLE_RELATIVE);
309
756
  if (!existsSync(src)) return;
310
- const vodDir = join3(fixtureDir, "vod");
757
+ const vodDir = join4(fixtureDir, "vod");
311
758
  mkdirSync(vodDir, { recursive: true });
312
759
  for (const name of ["sample-1.mp4", "sample-2.mp4"]) {
313
- const dst = join3(vodDir, name);
760
+ const dst = join4(vodDir, name);
314
761
  writeFileSync(dst, readFileSync(src));
315
762
  }
316
763
  }
@@ -359,7 +806,8 @@ function emptyManifest(duration) {
359
806
  version: 1,
360
807
  generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
361
808
  durationSeconds: duration,
362
- fixtures: []
809
+ fixtures: [],
810
+ windowFixtures: []
363
811
  };
364
812
  }
365
813
 
@@ -374,6 +822,7 @@ function parseArgs(argv) {
374
822
  durationSeconds: DEFAULT_DURATION_SECONDS,
375
823
  fixtureDir: resolve2(here, "..", "..", DEFAULT_FIXTURE_DIR),
376
824
  overwrite: false,
825
+ includeWindow: true,
377
826
  help: false
378
827
  };
379
828
  for (let i = 0; i < argv.length; i += 1) {
@@ -410,6 +859,9 @@ function parseArgs(argv) {
410
859
  case "--overwrite":
411
860
  parsed.overwrite = true;
412
861
  break;
862
+ case "--skip-window":
863
+ parsed.includeWindow = false;
864
+ break;
413
865
  case "--help":
414
866
  case "-h":
415
867
  parsed.help = true;
@@ -431,6 +883,7 @@ Options:
431
883
  --fixture-dir <path> Output directory (default: packages/test-streams/fixtures)
432
884
  --ffmpeg-path <path> Explicit ffmpeg executable (default: auto-detect on PATH)
433
885
  --overwrite Re-run ffmpeg even if output exists
886
+ --skip-window Skip P3-H burned window sequence (window/<id>.mp4)
434
887
  --help, -h Show this help
435
888
  `;
436
889
  async function main() {
@@ -452,7 +905,8 @@ ${USAGE}`);
452
905
  durationSeconds: args.durationSeconds,
453
906
  fixtureDir: args.fixtureDir,
454
907
  overwrite: args.overwrite,
455
- ffmpegPath: args.ffmpegPath
908
+ ffmpegPath: args.ffmpegPath,
909
+ includeWindow: args.includeWindow
456
910
  });
457
911
  if (result.ffmpegMissing) {
458
912
  process.stderr.write(
package/dist/cli/serve.js CHANGED
@@ -45,6 +45,7 @@ function summarizeCatalog(fixtureDir) {
45
45
  }
46
46
  let total = 0;
47
47
  for (const item of manifest.fixtures) total += item.entries.length;
48
+ for (const item of manifest.windowFixtures ?? []) total += item.entries.length;
48
49
  return {
49
50
  codecs: ALL_CODECS,
50
51
  formats: ALL_FORMATS,
@@ -246,6 +247,12 @@ function parseChannelCount(raw) {
246
247
  if (!Number.isFinite(value) || value < 1) return MULTI_VOD_DEFAULT_CHANNELS;
247
248
  return Math.min(MULTI_VOD_MAX_CHANNELS, Math.floor(value));
248
249
  }
250
+ function parseBoundedQueryInt(raw, min, max) {
251
+ if (raw == null || raw === "") return null;
252
+ const value = Number(raw);
253
+ if (!Number.isInteger(value) || value < min || value > max) return null;
254
+ return value;
255
+ }
249
256
  function handleVodListMulti(res, manifest, fixtureDir, channels) {
250
257
  const entriesSource = (() => {
251
258
  const vodListPath = join(fixtureDir, "vod", "vod-list.json");
@@ -565,7 +572,13 @@ function createStreamsServer(options) {
565
572
  }
566
573
  const liveMatch = matchLivePath(path);
567
574
  if (liveMatch) {
568
- handleLiveStream(req, res, liveMatch.format, liveMatch.codec);
575
+ const liveQuery = (req.url ?? "").split("?", 2)[1] ?? "";
576
+ const liveParams = new URLSearchParams(liveQuery);
577
+ handleLiveStream(req, res, liveMatch.format, liveMatch.codec, {
578
+ width: parseBoundedQueryInt(liveParams.get("width"), 160, 3840) ?? void 0,
579
+ height: parseBoundedQueryInt(liveParams.get("height"), 160, 3840) ?? void 0,
580
+ fps: parseBoundedQueryInt(liveParams.get("fps"), 1, 60) ?? void 0
581
+ });
569
582
  return;
570
583
  }
571
584
  writeNotFound(res);
package/dist/config.d.ts CHANGED
@@ -21,6 +21,8 @@ export type GenerateOptions = {
21
21
  /** ffmpeg 可执行文件路径;缺省自动探测 PATH 与常见安装位置。 */
22
22
  ffmpegPath?: string;
23
23
  quiet?: boolean;
24
+ /** 是否生成 P3-H 烧录窗序列(window/,默认 true;--skip-window 可关闭以加速反馈)。 */
25
+ includeWindow?: boolean;
24
26
  };
25
27
  /** createStreamsServer / serve CLI 的选项;fixtureDir 必填(server 侧)。 */
26
28
  export type ServeOptions = {