@flow-player/test-streams 0.6.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/LICENSE +21 -0
- package/README.md +53 -0
- package/package.json +42 -0
- package/src/cli/extend-vod.ts +221 -0
- package/src/cli/generate.ts +138 -0
- package/src/cli/serve.ts +112 -0
- package/src/config.ts +34 -0
- package/src/fixtures/catalog.ts +99 -0
- package/src/fixtures/codec.ts +43 -0
- package/src/fixtures/format.ts +118 -0
- package/src/fixtures/generate.ts +209 -0
- package/src/fixtures/matrix.ts +94 -0
- package/src/index.ts +41 -0
- package/src/server/live.ts +220 -0
- package/src/server/routes.ts +141 -0
- package/src/server/server.ts +193 -0
- package/src/server/static.ts +83 -0
- package/src/visual/analyze.ts +234 -0
- package/src/visual/dhash.ts +76 -0
- package/src/visual/index.ts +18 -0
- package/src/visual/report.ts +80 -0
- package/src/vite-plugin.d.ts +10 -0
- package/src/vite-plugin.ts +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Flow Player contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @flow-player/test-streams
|
|
2
|
+
|
|
3
|
+
Flow Player 集成本地验收用的测试流服务:spawn 系统 ffmpeg 生成 H.264/HEVC × HTTP-FLV / HLS / fMP4 / MP4 / MPEG-TS fixture,经本地 HTTPS server(默认 `127.0.0.1:4177`)提供两类流:
|
|
4
|
+
|
|
5
|
+
1. **`/streams/live/<format>/<codec>`** — 无限循环 live 流(`-stream_loop -1 -re -fflags +genpts`,HTTP 永不 EOF),模拟 NVR 长期监控场景;格式 `flv|hls|mp4|fmp4|ts`,编码 `h264|hevc`。
|
|
6
|
+
2. **`/streams/<format>/<codec>`** — 5s 预生成 VOD 风格 fixture,适合短链路验收与范围请求验证。
|
|
7
|
+
|
|
8
|
+
## 要求
|
|
9
|
+
|
|
10
|
+
- Node ≥ 24(本包直接发布 TypeScript 源码,依赖原生 type stripping 运行)
|
|
11
|
+
- 系统 `ffmpeg` 在 PATH 上(fixture 生成时按需 spawn;缺失时脚本给出提示)
|
|
12
|
+
|
|
13
|
+
## 接入
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -D @flow-player/test-streams
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
启动 streams server(HTTPS 需自备证书,`--cert/--key` 可省略走 HTTP):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node node_modules/@flow-player/test-streams/src/cli/serve.ts --cert ./local.pem --key ./local-key.pem
|
|
23
|
+
# 或经包 exports 编程式接入:
|
|
24
|
+
# import { ... } from "@flow-player/test-streams"
|
|
25
|
+
# import { createStreamsServer } from "@flow-player/test-streams/server"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
健康检查:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
curl -ksSf https://127.0.0.1:4177/streams/health
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Vite 项目可加包提供的 dev 插件(仅 serve 生效:健康检查失败时自动后台拉起 streams server,日志 `/tmp/test-streams.log`):
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { testStreamsVitePlugin } from "@flow-player/test-streams/vite-plugin";
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
plugins: [
|
|
41
|
+
testStreamsVitePlugin({ cert: "./local.pem", key: "./local-key.pem" }),
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 注意
|
|
47
|
+
|
|
48
|
+
- 内置矩阵、CLI 参数与路径语义与 [仓库文档](../../docs/reference/test-stream.md) 保持一致;**不要在业务代码里硬编码流地址**——demo 侧经 `VITE_FLOW_PLAYER_DEMO_STREAMS_BASE` 切换基址。
|
|
49
|
+
- 本包是测试/验收工具,不是生产代理服务;生成码流质量只服务于接入验收(低码率、短 GOP)。
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT。
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flow-player/test-streams",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "ffmpeg-driven local test stream service for Flow Player integration testing: generates and serves H.264/HEVC × HTTP-FLV / HLS / fMP4 / MP4 / MPEG-TS fixtures on 127.0.0.1:4177 (live infinite-loop + 5s VOD fixtures).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"esm",
|
|
7
|
+
"flow-player",
|
|
8
|
+
"test-streams",
|
|
9
|
+
"flv",
|
|
10
|
+
"hls",
|
|
11
|
+
"h264",
|
|
12
|
+
"h265",
|
|
13
|
+
"hevc",
|
|
14
|
+
"fixture"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts",
|
|
20
|
+
"./server": "./src/server/server.ts",
|
|
21
|
+
"./fixtures/generate": "./src/fixtures/generate.ts",
|
|
22
|
+
"./fixtures/catalog": "./src/fixtures/catalog.ts",
|
|
23
|
+
"./vite-plugin": {
|
|
24
|
+
"types": "./src/vite-plugin.d.ts",
|
|
25
|
+
"default": "./src/vite-plugin.ts"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"src/"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=24.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org/",
|
|
40
|
+
"tag": "latest"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 单独生成 30s/段、1s GOP 的 H.264 + AAC LC MP4 fixture,
|
|
3
|
+
// 复制到 vod/sample-1.mp4 与 vod/sample-2.mp4,
|
|
4
|
+
// 并把 vod 段总时长写进 manifest.json(供 /streams/vod-list 端点使用)。
|
|
5
|
+
//
|
|
6
|
+
// 跟 generate.ts 解耦:不重跑 codec × format 矩阵,只补充 VOD 段。
|
|
7
|
+
// 不改 fixture 矩阵(gop=5*25 锁单 GOP,保持 fast feedback),只让 VOD 段足够长
|
|
8
|
+
// 供 e2e / 人工验证多 GOP 倒放语义。每段 30s、GOP=1s,30 段共 15 分钟素材。
|
|
9
|
+
// 倒放 1s GOP 间隔保留:每段 30 GOP,跨段 GOP 序号连续,worker gopBuffer
|
|
10
|
+
// 30 GOP 窗口下足以验证任意点倒放回跳。
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { DEFAULT_FIXTURE_DIR, type Manifest } from "../index.ts";
|
|
16
|
+
|
|
17
|
+
type Options = {
|
|
18
|
+
fixtureDir: string;
|
|
19
|
+
segmentSeconds: number;
|
|
20
|
+
segmentCount: number;
|
|
21
|
+
gopSeconds: number;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
fps: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv: ReadonlyArray<string>): Options & { help: boolean } {
|
|
28
|
+
const opts: Options = {
|
|
29
|
+
fixtureDir: DEFAULT_FIXTURE_DIR,
|
|
30
|
+
segmentSeconds: 30,
|
|
31
|
+
segmentCount: 20,
|
|
32
|
+
gopSeconds: 1,
|
|
33
|
+
width: 1280,
|
|
34
|
+
height: 720,
|
|
35
|
+
fps: 25,
|
|
36
|
+
};
|
|
37
|
+
let help = false;
|
|
38
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
39
|
+
const a = argv[i];
|
|
40
|
+
if (a === "--help" || a === "-h") help = true;
|
|
41
|
+
else if (a === "--fixture-dir") {
|
|
42
|
+
opts.fixtureDir = argv[++i];
|
|
43
|
+
} else if (a === "--segment-seconds") {
|
|
44
|
+
opts.segmentSeconds = Number(argv[++i]);
|
|
45
|
+
} else if (a === "--segment-count") {
|
|
46
|
+
opts.segmentCount = Number(argv[++i]);
|
|
47
|
+
} else if (a === "--gop") {
|
|
48
|
+
opts.gopSeconds = Number(argv[++i]);
|
|
49
|
+
} else if (a === "--width") {
|
|
50
|
+
opts.width = Number(argv[++i]);
|
|
51
|
+
} else if (a === "--height") {
|
|
52
|
+
opts.height = Number(argv[++i]);
|
|
53
|
+
} else if (a === "--fps") {
|
|
54
|
+
opts.fps = Number(argv[++i]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { ...opts, help };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const USAGE = `Usage:
|
|
61
|
+
pnpm --filter @flow-player/test-streams run extend-vod [-- <args>]
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
--fixture-dir <path> Fixture root (default: packages/test-streams/fixtures)
|
|
65
|
+
--segment-seconds <sec> Per-segment duration (default: 30)
|
|
66
|
+
--segment-count <n> Number of segments per VOD (default: 30; total VOD = seconds * count)
|
|
67
|
+
--gop <seconds> GOP interval (default: 1)
|
|
68
|
+
--width <px> Video width (default: 1280)
|
|
69
|
+
--height <px> Video height (default: 720)
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
function hashFile(abs: string): string {
|
|
73
|
+
return createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function runFfmpeg(ffmpegPath: string, args: ReadonlyArray<string>): Promise<number> {
|
|
77
|
+
return await new Promise<number>((resolveRun, rejectRun) => {
|
|
78
|
+
const child = spawn(ffmpegPath, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
79
|
+
let stderr = "";
|
|
80
|
+
child.stderr.on("data", (b) => {
|
|
81
|
+
stderr += b.toString("utf8");
|
|
82
|
+
});
|
|
83
|
+
child.on("error", rejectRun);
|
|
84
|
+
child.on("close", (code) => {
|
|
85
|
+
if (code !== 0) {
|
|
86
|
+
process.stderr.write(stderr);
|
|
87
|
+
}
|
|
88
|
+
resolveRun(code ?? 1);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function findFfmpeg(): Promise<string | null> {
|
|
94
|
+
const candidates = ["ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"];
|
|
95
|
+
for (const path of candidates) {
|
|
96
|
+
const code = await runFfmpeg(path, ["-version"]);
|
|
97
|
+
if (code === 0) return path;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function main(): Promise<number> {
|
|
103
|
+
const args = parseArgs(process.argv.slice(2));
|
|
104
|
+
if (args.help) {
|
|
105
|
+
process.stdout.write(USAGE);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
const ffmpegPath = await findFfmpeg();
|
|
109
|
+
if (!ffmpegPath) {
|
|
110
|
+
process.stderr.write("[extend-vod] ffmpeg not found in PATH\n");
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
const fixtureDir = resolve(args.fixtureDir);
|
|
114
|
+
const vodDir = join(fixtureDir, "vod");
|
|
115
|
+
mkdirSync(vodDir, { recursive: true });
|
|
116
|
+
const gop = String(args.gopSeconds * args.fps);
|
|
117
|
+
process.stdout.write(
|
|
118
|
+
`[extend-vod] generating ${args.segmentCount} segments x ${args.segmentSeconds}s ` +
|
|
119
|
+
`${args.width}x${args.height}@${args.fps} mp4 h264+aac, gop=${gop} frames ` +
|
|
120
|
+
`(${args.gopSeconds}s); total VOD=${args.segmentCount * args.segmentSeconds}s\n`,
|
|
121
|
+
);
|
|
122
|
+
const segments: Array<{ index: number; path: string; size: number; sha256: string }> = [];
|
|
123
|
+
for (let i = 0; i < args.segmentCount; i += 1) {
|
|
124
|
+
const index = i + 1;
|
|
125
|
+
const file = join(vodDir, `seg-${String(index).padStart(3, "0")}.mp4`);
|
|
126
|
+
// 每段用不同音高 + 起始时间偏移,让相邻段可视/可听差异化,便于人工验证
|
|
127
|
+
// 段间的 monotonic start time 让 GOP / 帧序号跨越段连续
|
|
128
|
+
const startSec = i * args.segmentSeconds;
|
|
129
|
+
const frequency = 220 + i * 40;
|
|
130
|
+
// D 轨: 输出 fragmented MP4 (fMP4) 让 MSE 模式可直接 appendBuffer。
|
|
131
|
+
// 非 fragmented MP4 的 mdat 偏移依赖 stco, MSE 的 SourceBuffer demuxer 解析时会失败
|
|
132
|
+
// (典型症状: video.error = "PIPELINE_ERROR_DECODE: Failed to send audio packet for decoding")。
|
|
133
|
+
// +empty_moov 把 moov 写到开头; +frag_keyframe 在每个关键帧边界开 fragment。
|
|
134
|
+
const ffmpegArgs = [
|
|
135
|
+
"-y",
|
|
136
|
+
"-f",
|
|
137
|
+
"lavfi",
|
|
138
|
+
"-i",
|
|
139
|
+
`testsrc2=size=${args.width}x${args.height}:rate=${args.fps},format=yuv420p`,
|
|
140
|
+
"-f",
|
|
141
|
+
"lavfi",
|
|
142
|
+
"-i",
|
|
143
|
+
`sine=frequency=${frequency}:sample_rate=44100`,
|
|
144
|
+
"-t",
|
|
145
|
+
String(args.segmentSeconds),
|
|
146
|
+
"-c:v",
|
|
147
|
+
"libx264",
|
|
148
|
+
"-profile:v",
|
|
149
|
+
"high",
|
|
150
|
+
"-bf",
|
|
151
|
+
"0",
|
|
152
|
+
"-preset",
|
|
153
|
+
"veryfast",
|
|
154
|
+
"-pix_fmt",
|
|
155
|
+
"yuv420p",
|
|
156
|
+
"-g",
|
|
157
|
+
gop,
|
|
158
|
+
"-keyint_min",
|
|
159
|
+
gop,
|
|
160
|
+
"-sc_threshold",
|
|
161
|
+
"0",
|
|
162
|
+
"-c:a",
|
|
163
|
+
"aac",
|
|
164
|
+
"-b:a",
|
|
165
|
+
"128k",
|
|
166
|
+
"-ar",
|
|
167
|
+
"44100",
|
|
168
|
+
"-ac",
|
|
169
|
+
"2",
|
|
170
|
+
"-movflags",
|
|
171
|
+
"+faststart",
|
|
172
|
+
"-metadata",
|
|
173
|
+
`start_time=${startSec}`,
|
|
174
|
+
file,
|
|
175
|
+
];
|
|
176
|
+
const code = await runFfmpeg(ffmpegPath, ffmpegArgs);
|
|
177
|
+
const size = statSync(file).size;
|
|
178
|
+
const sha256 = hashFile(file);
|
|
179
|
+
segments.push({ index, path: `vod/seg-${String(index).padStart(3, "0")}.mp4`, size, sha256 });
|
|
180
|
+
}
|
|
181
|
+
// 写 vod-list.json 让 handleVodList 端点能返回 N 段
|
|
182
|
+
const vodList = {
|
|
183
|
+
version: 1,
|
|
184
|
+
generatedAt: new Date().toISOString(),
|
|
185
|
+
segmentSeconds: args.segmentSeconds,
|
|
186
|
+
segmentCount: args.segmentCount,
|
|
187
|
+
gopSeconds: args.gopSeconds,
|
|
188
|
+
entries: segments,
|
|
189
|
+
};
|
|
190
|
+
const vodListPath = join(vodDir, "vod-list.json");
|
|
191
|
+
writeFileSync(vodListPath, JSON.stringify(vodList, null, 2));
|
|
192
|
+
process.stdout.write(`[extend-vod] wrote ${vodListPath}\n`);
|
|
193
|
+
// 更新 manifest.json 的 durationSeconds 为 "per-segment duration",
|
|
194
|
+
// 并附带 segmentCount 字段,让 handleVodList 知道总段数。
|
|
195
|
+
const manifestPath = join(fixtureDir, "manifest.json");
|
|
196
|
+
if (existsSync(manifestPath)) {
|
|
197
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Manifest;
|
|
198
|
+
manifest.durationSeconds = args.segmentSeconds;
|
|
199
|
+
manifest.generatedAt = new Date().toISOString();
|
|
200
|
+
(manifest as Manifest & { segmentCount?: number }).segmentCount = args.segmentCount;
|
|
201
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
202
|
+
process.stdout.write(
|
|
203
|
+
`[extend-vod] updated ${manifestPath} durationSeconds=${args.segmentSeconds} ` +
|
|
204
|
+
`segmentCount=${args.segmentCount}\n`,
|
|
205
|
+
);
|
|
206
|
+
} else {
|
|
207
|
+
process.stdout.write(
|
|
208
|
+
`[extend-vod] ${manifestPath} not found; skipping manifest update ` +
|
|
209
|
+
`(run pnpm run generate first)\n`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
void main().then(
|
|
216
|
+
(code) => process.exit(code),
|
|
217
|
+
(err: unknown) => {
|
|
218
|
+
process.stderr.write(`[extend-vod] unexpected error: ${String(err)}\n`);
|
|
219
|
+
process.exit(2);
|
|
220
|
+
},
|
|
221
|
+
);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
ALL_CODECS,
|
|
6
|
+
ALL_FORMATS,
|
|
7
|
+
DEFAULT_DURATION_SECONDS,
|
|
8
|
+
DEFAULT_FIXTURE_DIR,
|
|
9
|
+
type CodecId,
|
|
10
|
+
type FormatId,
|
|
11
|
+
} from "../config.ts";
|
|
12
|
+
import { generateFixtures } from "../fixtures/generate.ts";
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const repoRoot = resolve(here, "..", "..", "..");
|
|
16
|
+
|
|
17
|
+
const CODEC_SET: Record<string, true> = { h264: true, hevc: true };
|
|
18
|
+
const FORMAT_SET: Record<string, true> = { flv: true, hls: true, mp4: true, fmp4: true, ts: true };
|
|
19
|
+
|
|
20
|
+
type ParsedArgs = {
|
|
21
|
+
codecs: ReadonlyArray<CodecId>;
|
|
22
|
+
formats: ReadonlyArray<FormatId>;
|
|
23
|
+
durationSeconds: number;
|
|
24
|
+
fixtureDir: string;
|
|
25
|
+
overwrite: boolean;
|
|
26
|
+
help: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv: ReadonlyArray<string>): ParsedArgs {
|
|
30
|
+
const parsed: ParsedArgs = {
|
|
31
|
+
codecs: ALL_CODECS,
|
|
32
|
+
formats: ALL_FORMATS,
|
|
33
|
+
durationSeconds: DEFAULT_DURATION_SECONDS,
|
|
34
|
+
fixtureDir: resolve(here, "..", "..", DEFAULT_FIXTURE_DIR),
|
|
35
|
+
overwrite: false,
|
|
36
|
+
help: false,
|
|
37
|
+
};
|
|
38
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
39
|
+
const arg = argv[i];
|
|
40
|
+
switch (arg) {
|
|
41
|
+
case "--codecs": {
|
|
42
|
+
const value = argv[++i] ?? "";
|
|
43
|
+
const list = value
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((s) => s.trim())
|
|
46
|
+
.filter((s): s is CodecId => CODEC_SET[s] === true);
|
|
47
|
+
if (list.length === 0) throw new Error(`--codecs invalid: ${value}`);
|
|
48
|
+
parsed.codecs = list;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case "--formats": {
|
|
52
|
+
const value = argv[++i] ?? "";
|
|
53
|
+
const list = value
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter((s): s is FormatId => FORMAT_SET[s] === true);
|
|
57
|
+
if (list.length === 0) throw new Error(`--formats invalid: ${value}`);
|
|
58
|
+
parsed.formats = list;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case "--duration": {
|
|
62
|
+
const value = Number(argv[++i]);
|
|
63
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
64
|
+
throw new Error(`--duration must be positive, got ${argv[i]}`);
|
|
65
|
+
parsed.durationSeconds = value;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "--fixture-dir": {
|
|
69
|
+
parsed.fixtureDir = resolve(argv[++i] ?? "");
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "--overwrite":
|
|
73
|
+
parsed.overwrite = true;
|
|
74
|
+
break;
|
|
75
|
+
case "--help":
|
|
76
|
+
case "-h":
|
|
77
|
+
parsed.help = true;
|
|
78
|
+
break;
|
|
79
|
+
default:
|
|
80
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const USAGE = `Usage:
|
|
87
|
+
pnpm --filter @flow-player/test-streams run generate [-- <args>]
|
|
88
|
+
|
|
89
|
+
Options:
|
|
90
|
+
--codecs <h264,hevc> Codecs to generate (default: h264,hevc)
|
|
91
|
+
--formats <flv,hls,mp4,fmp4,ts>
|
|
92
|
+
Container formats to generate (default: all 5)
|
|
93
|
+
--duration <seconds> Per-fixture duration (default: 5)
|
|
94
|
+
--fixture-dir <path> Output directory (default: packages/test-streams/fixtures)
|
|
95
|
+
--overwrite Re-run ffmpeg even if output exists
|
|
96
|
+
--help, -h Show this help
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
async function main(): Promise<number> {
|
|
100
|
+
let args: ParsedArgs;
|
|
101
|
+
try {
|
|
102
|
+
args = parseArgs(process.argv.slice(2));
|
|
103
|
+
} catch (err) {
|
|
104
|
+
process.stderr.write(`${(err as Error).message}\n${USAGE}`);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
if (args.help) {
|
|
108
|
+
process.stdout.write(USAGE);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
const result = await generateFixtures({
|
|
112
|
+
codecs: args.codecs,
|
|
113
|
+
formats: args.formats,
|
|
114
|
+
durationSeconds: args.durationSeconds,
|
|
115
|
+
fixtureDir: args.fixtureDir,
|
|
116
|
+
overwrite: args.overwrite,
|
|
117
|
+
});
|
|
118
|
+
if (result.ffmpegMissing) {
|
|
119
|
+
process.stderr.write(
|
|
120
|
+
"ffmpeg not found in PATH. Install ffmpeg (brew install ffmpeg) and re-run.\n",
|
|
121
|
+
);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
process.stdout.write(
|
|
125
|
+
`Generated ${result.generated} fixture(s) under ${result.fixtureDir}\n` +
|
|
126
|
+
`Manifest: ${resolve(result.fixtureDir, "manifest.json")}\n`,
|
|
127
|
+
);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
void repoRoot;
|
|
132
|
+
main().then(
|
|
133
|
+
(code) => process.exit(code),
|
|
134
|
+
(err) => {
|
|
135
|
+
process.stderr.write(`${(err as Error).message}\n`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
},
|
|
138
|
+
);
|
package/src/cli/serve.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { DEFAULT_FIXTURE_DIR, DEFAULT_PORT, type ServeOptions } from "../config.ts";
|
|
5
|
+
import { createStreamsServer, listen } from "../server/server.ts";
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
type ParsedArgs = ServeOptions & { help: boolean };
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv: ReadonlyArray<string>): ParsedArgs {
|
|
12
|
+
const parsed: ParsedArgs = {
|
|
13
|
+
port: DEFAULT_PORT,
|
|
14
|
+
fixtureDir: resolve(here, "..", "..", DEFAULT_FIXTURE_DIR),
|
|
15
|
+
help: false,
|
|
16
|
+
};
|
|
17
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
18
|
+
const arg = argv[i];
|
|
19
|
+
switch (arg) {
|
|
20
|
+
case "--port": {
|
|
21
|
+
const v = Number(argv[++i]);
|
|
22
|
+
if (!Number.isInteger(v) || v < 1 || v > 65_535)
|
|
23
|
+
throw new Error(`--port invalid: ${argv[i]}`);
|
|
24
|
+
parsed.port = v;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case "--host":
|
|
28
|
+
parsed.host = argv[++i];
|
|
29
|
+
break;
|
|
30
|
+
case "--fixture-dir":
|
|
31
|
+
parsed.fixtureDir = resolve(argv[++i] ?? "");
|
|
32
|
+
break;
|
|
33
|
+
case "--cert":
|
|
34
|
+
parsed.cert = argv[++i];
|
|
35
|
+
break;
|
|
36
|
+
case "--key":
|
|
37
|
+
parsed.key = argv[++i];
|
|
38
|
+
break;
|
|
39
|
+
case "--help":
|
|
40
|
+
case "-h":
|
|
41
|
+
parsed.help = true;
|
|
42
|
+
break;
|
|
43
|
+
default:
|
|
44
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const USAGE = `Usage:
|
|
51
|
+
pnpm --filter @flow-player/test-streams run serve [-- <args>]
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
--port <port> Listen port (default: 4177)
|
|
55
|
+
--host <host> Bind host (default: 127.0.0.1)
|
|
56
|
+
--fixture-dir <path> Fixture root (default: packages/test-streams/fixtures)
|
|
57
|
+
--cert <path> TLS cert (with --key, enables HTTPS)
|
|
58
|
+
--key <path> TLS private key (with --cert, enables HTTPS)
|
|
59
|
+
--help, -h Show this help
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
async function main(): Promise<number> {
|
|
63
|
+
let args: ParsedArgs;
|
|
64
|
+
try {
|
|
65
|
+
args = parseArgs(process.argv.slice(2));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
process.stderr.write(`${(err as Error).message}\n${USAGE}`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
if (args.help) {
|
|
71
|
+
process.stdout.write(USAGE);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const server = createStreamsServer({
|
|
75
|
+
port: args.port,
|
|
76
|
+
host: args.host,
|
|
77
|
+
// parseArgs guarantees a default; treat as string.
|
|
78
|
+
fixtureDir: args.fixtureDir as string,
|
|
79
|
+
cert: args.cert,
|
|
80
|
+
key: args.key,
|
|
81
|
+
});
|
|
82
|
+
const listening = await listen(server);
|
|
83
|
+
const protocol = args.cert && args.key ? "https" : "http";
|
|
84
|
+
process.stdout.write(
|
|
85
|
+
`streams server listening on ${protocol}://${listening.host}:${listening.port}\n`,
|
|
86
|
+
);
|
|
87
|
+
process.stdout.write(
|
|
88
|
+
` health: ${protocol}://${listening.host}:${listening.port}/streams/health\n`,
|
|
89
|
+
);
|
|
90
|
+
process.stdout.write(` fixture: ${resolve(args.fixtureDir ?? "")}\n`);
|
|
91
|
+
// 等待 SIGINT / SIGTERM 或未捕获异常,再决定是否 exit。
|
|
92
|
+
const { promise, resolve: resolveWait } = Promise.withResolvers<number>();
|
|
93
|
+
const shutdown = (code: number): void => {
|
|
94
|
+
process.stdout.write("\nshutting down streams server...\n");
|
|
95
|
+
listening.close().finally(() => resolveWait(code));
|
|
96
|
+
};
|
|
97
|
+
process.once("SIGINT", () => shutdown(0));
|
|
98
|
+
process.once("SIGTERM", () => shutdown(0));
|
|
99
|
+
process.once("uncaughtException", (err) => {
|
|
100
|
+
process.stderr.write(`uncaught: ${err.stack ?? err.message}\n`);
|
|
101
|
+
shutdown(1);
|
|
102
|
+
});
|
|
103
|
+
return promise;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
void main().then(
|
|
107
|
+
(code) => process.exit(code),
|
|
108
|
+
(err) => {
|
|
109
|
+
process.stderr.write(`${(err as Error).message}\n`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
},
|
|
112
|
+
);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Streams server / fixture defaults. Override per call when needed.
|
|
2
|
+
export const DEFAULT_PORT = 4177;
|
|
3
|
+
export const DEFAULT_HOST = "127.0.0.1";
|
|
4
|
+
export const DEFAULT_FIXTURE_DIR = "fixtures";
|
|
5
|
+
export const DEFAULT_DURATION_SECONDS = 5;
|
|
6
|
+
export const DEFAULT_VIDEO_WIDTH = 1280;
|
|
7
|
+
export const DEFAULT_VIDEO_HEIGHT = 720;
|
|
8
|
+
export const DEFAULT_VIDEO_FPS = 25;
|
|
9
|
+
export const DEFAULT_GOP_SECONDS = 1;
|
|
10
|
+
export const DEFAULT_HLS_SEGMENT_SECONDS = 2;
|
|
11
|
+
|
|
12
|
+
export type GenerateOptions = {
|
|
13
|
+
codecs?: ReadonlyArray<CodecId>;
|
|
14
|
+
formats?: ReadonlyArray<FormatId>;
|
|
15
|
+
durationSeconds?: number;
|
|
16
|
+
fixtureDir?: string;
|
|
17
|
+
overwrite?: boolean;
|
|
18
|
+
ffmpegPath?: string;
|
|
19
|
+
quiet?: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ServeOptions = {
|
|
23
|
+
port?: number;
|
|
24
|
+
host?: string;
|
|
25
|
+
fixtureDir?: string;
|
|
26
|
+
cert?: string;
|
|
27
|
+
key?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CodecId = "h264" | "hevc";
|
|
31
|
+
export type FormatId = "flv" | "hls" | "mp4" | "fmp4" | "ts";
|
|
32
|
+
|
|
33
|
+
export const ALL_CODECS: ReadonlyArray<CodecId> = ["h264", "hevc"];
|
|
34
|
+
export const ALL_FORMATS: ReadonlyArray<FormatId> = ["flv", "hls", "mp4", "fmp4", "ts"];
|