@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/README.md +63 -0
- package/dist/cli/generate.js +469 -15
- package/dist/cli/serve.js +14 -1
- package/dist/config.d.ts +2 -0
- package/dist/fixtures/catalog.d.ts +18 -0
- package/dist/fixtures/catalog.js +1 -0
- package/dist/fixtures/generate.d.ts +6 -3
- package/dist/fixtures/generate.js +462 -14
- package/dist/fixtures/window.d.ts +110 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +493 -22
- package/dist/server/routes.d.ts +5 -0
- package/dist/server/server.js +14 -1
- package/package.json +1 -1
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` 切换基址。
|
package/dist/cli/generate.js
CHANGED
|
@@ -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
|
|
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(
|
|
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 =
|
|
641
|
+
const outDir = join4(fixtureDir, plan.containerDir);
|
|
263
642
|
mkdirSync(outDir, { recursive: true });
|
|
264
|
-
const outFile =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
744
|
+
const rel = join4(plan.containerDir, name);
|
|
298
745
|
if (entries.find((e) => e.path === rel)) continue;
|
|
299
|
-
const abs =
|
|
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 =
|
|
755
|
+
const src = join4(fixtureDir, VOD_SAMPLE_RELATIVE);
|
|
309
756
|
if (!existsSync(src)) return;
|
|
310
|
-
const vodDir =
|
|
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 =
|
|
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
|
-
|
|
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 = {
|