@flow-player/test-streams 0.6.0 → 0.6.2
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/{src/cli/extend-vod.ts → dist/cli/extend-vod.js} +39 -61
- package/dist/cli/generate.js +473 -0
- package/dist/cli/serve.js +644 -0
- package/dist/config.d.ts +29 -0
- package/dist/fixtures/catalog.d.ts +37 -0
- package/dist/fixtures/catalog.js +63 -0
- package/dist/fixtures/codec.d.ts +10 -0
- package/dist/fixtures/format.d.ts +13 -0
- package/dist/fixtures/generate.d.ts +11 -0
- package/dist/fixtures/generate.js +361 -0
- package/dist/fixtures/matrix.d.ts +18 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +894 -0
- package/dist/server/live.d.ts +14 -0
- package/dist/server/routes.d.ts +18 -0
- package/dist/server/server.d.ts +18 -0
- package/dist/server/server.js +534 -0
- package/dist/server/static.d.ts +13 -0
- package/dist/vite-plugin.d.ts +12 -0
- package/dist/vite-plugin.js +58 -0
- package/package.json +24 -11
- package/src/cli/generate.ts +0 -138
- package/src/cli/serve.ts +0 -112
- package/src/config.ts +0 -34
- package/src/fixtures/catalog.ts +0 -99
- package/src/fixtures/codec.ts +0 -43
- package/src/fixtures/format.ts +0 -118
- package/src/fixtures/generate.ts +0 -209
- package/src/fixtures/matrix.ts +0 -94
- package/src/index.ts +0 -41
- package/src/server/live.ts +0 -220
- package/src/server/routes.ts +0 -141
- package/src/server/server.ts +0 -193
- package/src/server/static.ts +0 -83
- package/src/visual/analyze.ts +0 -234
- package/src/visual/dhash.ts +0 -76
- package/src/visual/index.ts +0 -18
- package/src/visual/report.ts +0 -80
- package/src/vite-plugin.d.ts +0 -10
- package/src/vite-plugin.ts +0 -76
|
@@ -1,38 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
//
|
|
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 窗口下足以验证任意点倒放回跳。
|
|
2
|
+
|
|
3
|
+
// src/cli/extend-vod.ts
|
|
11
4
|
import { createHash } from "node:crypto";
|
|
12
5
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
6
|
import { spawn } from "node:child_process";
|
|
14
7
|
import { join, resolve } from "node:path";
|
|
15
|
-
import { DEFAULT_FIXTURE_DIR, type Manifest } from "../index.ts";
|
|
16
8
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
9
|
+
// src/config.ts
|
|
10
|
+
var DEFAULT_FIXTURE_DIR = "fixtures";
|
|
11
|
+
var ALL_CODECS = ["h264", "hevc"];
|
|
12
|
+
var ALL_FORMATS = ["flv", "hls", "mp4", "fmp4", "ts"];
|
|
13
|
+
|
|
14
|
+
// src/server/routes.ts
|
|
15
|
+
var FORMAT_SET = Object.fromEntries(ALL_FORMATS.map((f) => [f, true]));
|
|
16
|
+
var CODEC_SET = Object.fromEntries(ALL_CODECS.map((c) => [c, true]));
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
|
|
18
|
+
// src/cli/extend-vod.ts
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const opts = {
|
|
29
21
|
fixtureDir: DEFAULT_FIXTURE_DIR,
|
|
30
22
|
segmentSeconds: 30,
|
|
31
23
|
segmentCount: 20,
|
|
32
24
|
gopSeconds: 1,
|
|
33
25
|
width: 1280,
|
|
34
26
|
height: 720,
|
|
35
|
-
fps: 25
|
|
27
|
+
fps: 25
|
|
36
28
|
};
|
|
37
29
|
let help = false;
|
|
38
30
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -56,8 +48,7 @@ function parseArgs(argv: ReadonlyArray<string>): Options & { help: boolean } {
|
|
|
56
48
|
}
|
|
57
49
|
return { ...opts, help };
|
|
58
50
|
}
|
|
59
|
-
|
|
60
|
-
const USAGE = `Usage:
|
|
51
|
+
var USAGE = `Usage:
|
|
61
52
|
pnpm --filter @flow-player/test-streams run extend-vod [-- <args>]
|
|
62
53
|
|
|
63
54
|
Options:
|
|
@@ -68,13 +59,11 @@ Options:
|
|
|
68
59
|
--width <px> Video width (default: 1280)
|
|
69
60
|
--height <px> Video height (default: 720)
|
|
70
61
|
`;
|
|
71
|
-
|
|
72
|
-
function hashFile(abs: string): string {
|
|
62
|
+
function hashFile(abs) {
|
|
73
63
|
return createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
74
64
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return await new Promise<number>((resolveRun, rejectRun) => {
|
|
65
|
+
async function runFfmpeg(ffmpegPath, args) {
|
|
66
|
+
return await new Promise((resolveRun, rejectRun) => {
|
|
78
67
|
const child = spawn(ffmpegPath, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
79
68
|
let stderr = "";
|
|
80
69
|
child.stderr.on("data", (b) => {
|
|
@@ -89,8 +78,7 @@ async function runFfmpeg(ffmpegPath: string, args: ReadonlyArray<string>): Promi
|
|
|
89
78
|
});
|
|
90
79
|
});
|
|
91
80
|
}
|
|
92
|
-
|
|
93
|
-
async function findFfmpeg(): Promise<string | null> {
|
|
81
|
+
async function findFfmpeg() {
|
|
94
82
|
const candidates = ["ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"];
|
|
95
83
|
for (const path of candidates) {
|
|
96
84
|
const code = await runFfmpeg(path, ["-version"]);
|
|
@@ -98,8 +86,7 @@ async function findFfmpeg(): Promise<string | null> {
|
|
|
98
86
|
}
|
|
99
87
|
return null;
|
|
100
88
|
}
|
|
101
|
-
|
|
102
|
-
async function main(): Promise<number> {
|
|
89
|
+
async function main() {
|
|
103
90
|
const args = parseArgs(process.argv.slice(2));
|
|
104
91
|
if (args.help) {
|
|
105
92
|
process.stdout.write(USAGE);
|
|
@@ -115,22 +102,15 @@ async function main(): Promise<number> {
|
|
|
115
102
|
mkdirSync(vodDir, { recursive: true });
|
|
116
103
|
const gop = String(args.gopSeconds * args.fps);
|
|
117
104
|
process.stdout.write(
|
|
118
|
-
`[extend-vod] generating ${args.segmentCount} segments x ${args.segmentSeconds}s
|
|
119
|
-
|
|
120
|
-
`(${args.gopSeconds}s); total VOD=${args.segmentCount * args.segmentSeconds}s\n`,
|
|
105
|
+
`[extend-vod] generating ${args.segmentCount} segments x ${args.segmentSeconds}s ${args.width}x${args.height}@${args.fps} mp4 h264+aac, gop=${gop} frames (${args.gopSeconds}s); total VOD=${args.segmentCount * args.segmentSeconds}s
|
|
106
|
+
`
|
|
121
107
|
);
|
|
122
|
-
const segments
|
|
108
|
+
const segments = [];
|
|
123
109
|
for (let i = 0; i < args.segmentCount; i += 1) {
|
|
124
110
|
const index = i + 1;
|
|
125
111
|
const file = join(vodDir, `seg-${String(index).padStart(3, "0")}.mp4`);
|
|
126
|
-
// 每段用不同音高 + 起始时间偏移,让相邻段可视/可听差异化,便于人工验证
|
|
127
|
-
// 段间的 monotonic start time 让 GOP / 帧序号跨越段连续
|
|
128
112
|
const startSec = i * args.segmentSeconds;
|
|
129
113
|
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
114
|
const ffmpegArgs = [
|
|
135
115
|
"-y",
|
|
136
116
|
"-f",
|
|
@@ -171,51 +151,49 @@ async function main(): Promise<number> {
|
|
|
171
151
|
"+faststart",
|
|
172
152
|
"-metadata",
|
|
173
153
|
`start_time=${startSec}`,
|
|
174
|
-
file
|
|
154
|
+
file
|
|
175
155
|
];
|
|
176
156
|
const code = await runFfmpeg(ffmpegPath, ffmpegArgs);
|
|
177
157
|
const size = statSync(file).size;
|
|
178
158
|
const sha256 = hashFile(file);
|
|
179
159
|
segments.push({ index, path: `vod/seg-${String(index).padStart(3, "0")}.mp4`, size, sha256 });
|
|
180
160
|
}
|
|
181
|
-
// 写 vod-list.json 让 handleVodList 端点能返回 N 段
|
|
182
161
|
const vodList = {
|
|
183
162
|
version: 1,
|
|
184
|
-
generatedAt: new Date().toISOString(),
|
|
163
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
185
164
|
segmentSeconds: args.segmentSeconds,
|
|
186
165
|
segmentCount: args.segmentCount,
|
|
187
166
|
gopSeconds: args.gopSeconds,
|
|
188
|
-
entries: segments
|
|
167
|
+
entries: segments
|
|
189
168
|
};
|
|
190
169
|
const vodListPath = join(vodDir, "vod-list.json");
|
|
191
170
|
writeFileSync(vodListPath, JSON.stringify(vodList, null, 2));
|
|
192
|
-
process.stdout.write(`[extend-vod] wrote ${vodListPath}
|
|
193
|
-
|
|
194
|
-
// 并附带 segmentCount 字段,让 handleVodList 知道总段数。
|
|
171
|
+
process.stdout.write(`[extend-vod] wrote ${vodListPath}
|
|
172
|
+
`);
|
|
195
173
|
const manifestPath = join(fixtureDir, "manifest.json");
|
|
196
174
|
if (existsSync(manifestPath)) {
|
|
197
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"))
|
|
175
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
198
176
|
manifest.durationSeconds = args.segmentSeconds;
|
|
199
|
-
manifest.generatedAt = new Date().toISOString();
|
|
200
|
-
|
|
177
|
+
manifest.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
178
|
+
manifest.segmentCount = args.segmentCount;
|
|
201
179
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
202
180
|
process.stdout.write(
|
|
203
|
-
`[extend-vod] updated ${manifestPath} durationSeconds=${args.segmentSeconds}
|
|
204
|
-
|
|
181
|
+
`[extend-vod] updated ${manifestPath} durationSeconds=${args.segmentSeconds} segmentCount=${args.segmentCount}
|
|
182
|
+
`
|
|
205
183
|
);
|
|
206
184
|
} else {
|
|
207
185
|
process.stdout.write(
|
|
208
|
-
`[extend-vod] ${manifestPath} not found; skipping manifest update
|
|
209
|
-
|
|
186
|
+
`[extend-vod] ${manifestPath} not found; skipping manifest update (run pnpm run generate first)
|
|
187
|
+
`
|
|
210
188
|
);
|
|
211
189
|
}
|
|
212
190
|
return 0;
|
|
213
191
|
}
|
|
214
|
-
|
|
215
192
|
void main().then(
|
|
216
193
|
(code) => process.exit(code),
|
|
217
|
-
(err
|
|
218
|
-
process.stderr.write(`[extend-vod] unexpected error: ${String(err)}
|
|
194
|
+
(err) => {
|
|
195
|
+
process.stderr.write(`[extend-vod] unexpected error: ${String(err)}
|
|
196
|
+
`);
|
|
219
197
|
process.exit(2);
|
|
220
|
-
}
|
|
198
|
+
}
|
|
221
199
|
);
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/generate.ts
|
|
4
|
+
import { dirname, resolve as resolve2 } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
var DEFAULT_FIXTURE_DIR = "fixtures";
|
|
9
|
+
var DEFAULT_DURATION_SECONDS = 5;
|
|
10
|
+
var ALL_CODECS = ["h264", "hevc"];
|
|
11
|
+
var ALL_FORMATS = ["flv", "hls", "mp4", "fmp4", "ts"];
|
|
12
|
+
|
|
13
|
+
// src/fixtures/generate.ts
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import {
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
statSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
writeFileSync
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { join as join3, resolve } from "node:path";
|
|
26
|
+
|
|
27
|
+
// src/fixtures/matrix.ts
|
|
28
|
+
import { join as join2 } from "node:path";
|
|
29
|
+
|
|
30
|
+
// src/fixtures/codec.ts
|
|
31
|
+
var CODEC_SPEC = {
|
|
32
|
+
h264: {
|
|
33
|
+
codec: "h264",
|
|
34
|
+
encoder: "libx264",
|
|
35
|
+
videoArgs: ["-profile:v", "high", "-preset", "veryfast"],
|
|
36
|
+
audioArgs: ["-c:a", "aac", "-b:a", "128k"],
|
|
37
|
+
pixelFormat: "yuv420p"
|
|
38
|
+
},
|
|
39
|
+
hevc: {
|
|
40
|
+
codec: "hevc",
|
|
41
|
+
encoder: "libx265",
|
|
42
|
+
// 注意:FLV / TS 容器用 enhanced-rtmp 1cvh tag,不能强行写 hvc1;
|
|
43
|
+
// MP4 / fMP4 / HLS 走 hvc1。containerArgs 在 buildPlan 时按 format 决定。
|
|
44
|
+
videoArgs: ["-preset", "veryfast"],
|
|
45
|
+
audioArgs: ["-c:a", "aac", "-b:a", "128k"],
|
|
46
|
+
pixelFormat: "yuv420p"
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
function containerExtraArgs(format, codec) {
|
|
50
|
+
if (codec !== "hevc") return [];
|
|
51
|
+
if (format === "mp4") return ["-tag:v", "hvc1"];
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/fixtures/format.ts
|
|
56
|
+
import { join } from "node:path";
|
|
57
|
+
function formatSpecific(format) {
|
|
58
|
+
if (format === "flv") return ["-f", "flv"];
|
|
59
|
+
if (format === "hls") {
|
|
60
|
+
return [
|
|
61
|
+
"-f",
|
|
62
|
+
"hls",
|
|
63
|
+
"-hls_time",
|
|
64
|
+
"2",
|
|
65
|
+
"-hls_playlist_type",
|
|
66
|
+
"vod",
|
|
67
|
+
"-hls_segment_type",
|
|
68
|
+
"mpegts",
|
|
69
|
+
"-hls_flags",
|
|
70
|
+
"independent_segments"
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
if (format === "mp4") return ["-f", "mp4", "-movflags", "+faststart"];
|
|
74
|
+
if (format === "fmp4") {
|
|
75
|
+
return [
|
|
76
|
+
"-f",
|
|
77
|
+
"mp4",
|
|
78
|
+
"-movflags",
|
|
79
|
+
"frag_keyframe+empty_moov+default_base_moof",
|
|
80
|
+
"-frag_duration",
|
|
81
|
+
"2000000"
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
if (format === "ts") return ["-f", "mpegts"];
|
|
85
|
+
throw new Error(`Unknown format: ${String(format)}`);
|
|
86
|
+
}
|
|
87
|
+
function layoutFor(codec, format) {
|
|
88
|
+
if (format === "hls") {
|
|
89
|
+
return {
|
|
90
|
+
format,
|
|
91
|
+
mime: "application/vnd.apple.mpegurl",
|
|
92
|
+
mainFileName: "master.m3u8",
|
|
93
|
+
containerArgs: formatSpecific(format),
|
|
94
|
+
isContainerDirectory: true,
|
|
95
|
+
sideFiles: ["seg-00000.ts", "seg-00001.ts", "seg-00002.ts"]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (format === "fmp4") {
|
|
99
|
+
return {
|
|
100
|
+
format,
|
|
101
|
+
mime: "video/iso.segment",
|
|
102
|
+
mainFileName: "init.mp4",
|
|
103
|
+
containerArgs: formatSpecific(format),
|
|
104
|
+
isContainerDirectory: true,
|
|
105
|
+
sideFiles: ["seg-00000.m4s", "seg-00001.m4s", "seg-00002.m4s"]
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (format === "flv") {
|
|
109
|
+
return {
|
|
110
|
+
format,
|
|
111
|
+
mime: "video/x-flv",
|
|
112
|
+
mainFileName: `${codec}.flv`,
|
|
113
|
+
containerArgs: formatSpecific(format),
|
|
114
|
+
isContainerDirectory: false,
|
|
115
|
+
sideFiles: []
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (format === "mp4") {
|
|
119
|
+
return {
|
|
120
|
+
format,
|
|
121
|
+
mime: "video/mp4",
|
|
122
|
+
mainFileName: `${codec}.mp4`,
|
|
123
|
+
containerArgs: formatSpecific(format),
|
|
124
|
+
isContainerDirectory: false,
|
|
125
|
+
sideFiles: []
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (format === "ts") {
|
|
129
|
+
return {
|
|
130
|
+
format,
|
|
131
|
+
mime: "video/mp2t",
|
|
132
|
+
mainFileName: `${codec}.ts`,
|
|
133
|
+
containerArgs: formatSpecific(format),
|
|
134
|
+
isContainerDirectory: false,
|
|
135
|
+
sideFiles: []
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Unknown format: ${String(format)}`);
|
|
139
|
+
}
|
|
140
|
+
function singleFileRelative(codec, format) {
|
|
141
|
+
return join(format, `${codec}.${format}`);
|
|
142
|
+
}
|
|
143
|
+
function containerRelativeDir(codec, format) {
|
|
144
|
+
return join(format, codec);
|
|
145
|
+
}
|
|
146
|
+
function containerRelativePath(codec, format, name) {
|
|
147
|
+
return join(containerRelativeDir(codec, format), name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/fixtures/matrix.ts
|
|
151
|
+
function buildPlan(codec, format, durationSeconds) {
|
|
152
|
+
const layout = layoutFor(codec, format);
|
|
153
|
+
const spec = CODEC_SPEC[codec];
|
|
154
|
+
const gop = String(durationSeconds * 25);
|
|
155
|
+
const videoInput = `testsrc2=size=1280x720:rate=25,format=yuv420p`;
|
|
156
|
+
const audioInput = `sine=frequency=1000:sample_rate=44100`;
|
|
157
|
+
const ffmpegOutputArgs = [
|
|
158
|
+
"-y",
|
|
159
|
+
"-f",
|
|
160
|
+
"lavfi",
|
|
161
|
+
"-i",
|
|
162
|
+
videoInput,
|
|
163
|
+
"-f",
|
|
164
|
+
"lavfi",
|
|
165
|
+
"-i",
|
|
166
|
+
audioInput,
|
|
167
|
+
"-t",
|
|
168
|
+
String(durationSeconds),
|
|
169
|
+
"-c:v",
|
|
170
|
+
spec.encoder,
|
|
171
|
+
...spec.videoArgs,
|
|
172
|
+
"-pix_fmt",
|
|
173
|
+
spec.pixelFormat,
|
|
174
|
+
"-g",
|
|
175
|
+
gop,
|
|
176
|
+
"-keyint_min",
|
|
177
|
+
gop,
|
|
178
|
+
"-sc_threshold",
|
|
179
|
+
"0",
|
|
180
|
+
...containerExtraArgs(format, codec),
|
|
181
|
+
...spec.audioArgs,
|
|
182
|
+
...layout.containerArgs
|
|
183
|
+
];
|
|
184
|
+
if (layout.isContainerDirectory) {
|
|
185
|
+
const containerDir2 = containerRelativeDir(codec, format);
|
|
186
|
+
const mainFile2 = containerRelativePath(codec, format, layout.mainFileName);
|
|
187
|
+
const files = [
|
|
188
|
+
mainFile2,
|
|
189
|
+
...layout.sideFiles.map((s) => containerRelativePath(codec, format, s))
|
|
190
|
+
];
|
|
191
|
+
return { codec, format, layout, containerDir: containerDir2, mainFile: mainFile2, files, ffmpegOutputArgs };
|
|
192
|
+
}
|
|
193
|
+
const mainFile = singleFileRelative(codec, format);
|
|
194
|
+
const containerDir = join2(mainFile.split("/").slice(0, -1).join("/"));
|
|
195
|
+
return { codec, format, layout, containerDir, mainFile, files: [mainFile], ffmpegOutputArgs };
|
|
196
|
+
}
|
|
197
|
+
function expandMatrix(opts) {
|
|
198
|
+
const codecs = opts?.codecs ?? ALL_CODECS;
|
|
199
|
+
const formats = opts?.formats ?? ALL_FORMATS;
|
|
200
|
+
const duration = opts?.durationSeconds ?? 5;
|
|
201
|
+
const plans = [];
|
|
202
|
+
for (const codec of codecs) {
|
|
203
|
+
for (const format of formats) {
|
|
204
|
+
plans.push(buildPlan(codec, format, duration));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return plans;
|
|
208
|
+
}
|
|
209
|
+
var VOD_SAMPLE_RELATIVE = "mp4/h264.mp4";
|
|
210
|
+
|
|
211
|
+
// src/fixtures/generate.ts
|
|
212
|
+
var FFMPEG_PROBE_ARGS = ["-version"];
|
|
213
|
+
async function generateFixtures(opts = {}) {
|
|
214
|
+
const ffmpegPath = opts.ffmpegPath ?? await findFfmpeg();
|
|
215
|
+
if (!ffmpegPath) {
|
|
216
|
+
return {
|
|
217
|
+
fixtureDir: resolve(opts.fixtureDir ?? DEFAULT_FIXTURE_DIR),
|
|
218
|
+
manifest: emptyManifest(opts.durationSeconds ?? DEFAULT_DURATION_SECONDS),
|
|
219
|
+
generated: 0,
|
|
220
|
+
skipped: 0,
|
|
221
|
+
ffmpegPath: "ffmpeg",
|
|
222
|
+
ffmpegMissing: true
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const fixtureDir = resolve(opts.fixtureDir ?? DEFAULT_FIXTURE_DIR);
|
|
226
|
+
mkdirSync(fixtureDir, { recursive: true });
|
|
227
|
+
const duration = opts.durationSeconds ?? DEFAULT_DURATION_SECONDS;
|
|
228
|
+
const plans = expandMatrix({
|
|
229
|
+
codecs: opts.codecs ?? ALL_CODECS,
|
|
230
|
+
formats: opts.formats ?? ALL_FORMATS,
|
|
231
|
+
durationSeconds: duration
|
|
232
|
+
});
|
|
233
|
+
const manifestItems = [];
|
|
234
|
+
let generated = 0;
|
|
235
|
+
let skipped = 0;
|
|
236
|
+
for (const plan of plans) {
|
|
237
|
+
const item = await generateOne(
|
|
238
|
+
plan,
|
|
239
|
+
fixtureDir,
|
|
240
|
+
ffmpegPath,
|
|
241
|
+
opts.quiet ?? false,
|
|
242
|
+
opts.overwrite ?? false
|
|
243
|
+
);
|
|
244
|
+
if (item) {
|
|
245
|
+
manifestItems.push(item);
|
|
246
|
+
generated += 1;
|
|
247
|
+
} else {
|
|
248
|
+
skipped += 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
copyVodSamples(fixtureDir);
|
|
252
|
+
const manifest = {
|
|
253
|
+
version: 1,
|
|
254
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
255
|
+
durationSeconds: duration,
|
|
256
|
+
fixtures: manifestItems
|
|
257
|
+
};
|
|
258
|
+
writeFileSync(join3(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
259
|
+
return { fixtureDir, manifest, generated, skipped, ffmpegPath, ffmpegMissing: false };
|
|
260
|
+
}
|
|
261
|
+
async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
|
|
262
|
+
const outDir = join3(fixtureDir, plan.containerDir);
|
|
263
|
+
mkdirSync(outDir, { recursive: true });
|
|
264
|
+
const outFile = join3(fixtureDir, plan.mainFile);
|
|
265
|
+
if (!overwrite && existsSync(outFile)) {
|
|
266
|
+
return collectExisting(plan, fixtureDir);
|
|
267
|
+
}
|
|
268
|
+
if (plan.layout.isContainerDirectory) {
|
|
269
|
+
for (const f of readdirSync(outDir)) {
|
|
270
|
+
if (f === plan.layout.mainFileName) continue;
|
|
271
|
+
rmIfExists(join3(outDir, f));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const args = plan.ffmpegOutputArgs.concat([outFile]);
|
|
275
|
+
if (!quiet) {
|
|
276
|
+
process.stdout.write(`[generate] ${plan.codec}/${plan.format} -> ${plan.mainFile}
|
|
277
|
+
`);
|
|
278
|
+
}
|
|
279
|
+
const code = await runFfmpeg(ffmpegPath, args, quiet);
|
|
280
|
+
if (code !== 0) {
|
|
281
|
+
throw new Error(`ffmpeg exited with code ${code} for ${plan.codec}/${plan.format}`);
|
|
282
|
+
}
|
|
283
|
+
return collectExisting(plan, fixtureDir);
|
|
284
|
+
}
|
|
285
|
+
function collectExisting(plan, fixtureDir) {
|
|
286
|
+
const entries = [];
|
|
287
|
+
for (const rel of plan.files) {
|
|
288
|
+
const abs = join3(fixtureDir, rel);
|
|
289
|
+
if (!existsSync(abs)) continue;
|
|
290
|
+
entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
|
|
291
|
+
}
|
|
292
|
+
if (plan.layout.isContainerDirectory) {
|
|
293
|
+
const absDir = join3(fixtureDir, plan.containerDir);
|
|
294
|
+
if (existsSync(absDir)) {
|
|
295
|
+
for (const name of readdirSync(absDir).sort()) {
|
|
296
|
+
if (name === plan.layout.mainFileName) continue;
|
|
297
|
+
const rel = join3(plan.containerDir, name);
|
|
298
|
+
if (entries.find((e) => e.path === rel)) continue;
|
|
299
|
+
const abs = join3(absDir, name);
|
|
300
|
+
if (!statSync(abs).isFile()) continue;
|
|
301
|
+
entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return { codec: plan.codec, format: plan.format, entries };
|
|
306
|
+
}
|
|
307
|
+
function copyVodSamples(fixtureDir) {
|
|
308
|
+
const src = join3(fixtureDir, VOD_SAMPLE_RELATIVE);
|
|
309
|
+
if (!existsSync(src)) return;
|
|
310
|
+
const vodDir = join3(fixtureDir, "vod");
|
|
311
|
+
mkdirSync(vodDir, { recursive: true });
|
|
312
|
+
for (const name of ["sample-1.mp4", "sample-2.mp4"]) {
|
|
313
|
+
const dst = join3(vodDir, name);
|
|
314
|
+
writeFileSync(dst, readFileSync(src));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function rmIfExists(abs) {
|
|
318
|
+
if (!existsSync(abs)) return;
|
|
319
|
+
try {
|
|
320
|
+
unlinkSync(abs);
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function runFfmpeg(ffmpegPath, args, quiet) {
|
|
325
|
+
const { promise, resolve: resolve3, reject } = Promise.withResolvers();
|
|
326
|
+
const proc = spawn(ffmpegPath, args, {
|
|
327
|
+
stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit"
|
|
328
|
+
});
|
|
329
|
+
proc.once("error", reject);
|
|
330
|
+
proc.once("exit", (code) => resolve3(code ?? 1));
|
|
331
|
+
return promise;
|
|
332
|
+
}
|
|
333
|
+
async function findFfmpeg() {
|
|
334
|
+
const candidates = [
|
|
335
|
+
"ffmpeg",
|
|
336
|
+
"/opt/homebrew/bin/ffmpeg",
|
|
337
|
+
"/usr/local/bin/ffmpeg",
|
|
338
|
+
"/usr/bin/ffmpeg"
|
|
339
|
+
];
|
|
340
|
+
for (const candidate of candidates) {
|
|
341
|
+
if (await canRun(candidate)) return candidate;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
async function canRun(path) {
|
|
346
|
+
const { promise, resolve: resolve3 } = Promise.withResolvers();
|
|
347
|
+
const proc = spawn(path, FFMPEG_PROBE_ARGS, { stdio: "ignore" });
|
|
348
|
+
proc.once("error", () => resolve3(false));
|
|
349
|
+
proc.once("exit", (code) => resolve3(code === 0));
|
|
350
|
+
return promise;
|
|
351
|
+
}
|
|
352
|
+
function hashFile(abs) {
|
|
353
|
+
const hash = createHash("sha256");
|
|
354
|
+
hash.update(readFileSync(abs));
|
|
355
|
+
return hash.digest("hex");
|
|
356
|
+
}
|
|
357
|
+
function emptyManifest(duration) {
|
|
358
|
+
return {
|
|
359
|
+
version: 1,
|
|
360
|
+
generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
361
|
+
durationSeconds: duration,
|
|
362
|
+
fixtures: []
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/cli/generate.ts
|
|
367
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
368
|
+
var repoRoot = resolve2(here, "..", "..", "..");
|
|
369
|
+
var CODEC_SET = { h264: true, hevc: true };
|
|
370
|
+
var FORMAT_SET = { flv: true, hls: true, mp4: true, fmp4: true, ts: true };
|
|
371
|
+
function parseArgs(argv) {
|
|
372
|
+
const parsed = {
|
|
373
|
+
codecs: ALL_CODECS,
|
|
374
|
+
formats: ALL_FORMATS,
|
|
375
|
+
durationSeconds: DEFAULT_DURATION_SECONDS,
|
|
376
|
+
fixtureDir: resolve2(here, "..", "..", DEFAULT_FIXTURE_DIR),
|
|
377
|
+
overwrite: false,
|
|
378
|
+
help: false
|
|
379
|
+
};
|
|
380
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
381
|
+
const arg = argv[i];
|
|
382
|
+
switch (arg) {
|
|
383
|
+
case "--codecs": {
|
|
384
|
+
const value = argv[++i] ?? "";
|
|
385
|
+
const list = value.split(",").map((s) => s.trim()).filter((s) => CODEC_SET[s] === true);
|
|
386
|
+
if (list.length === 0) throw new Error(`--codecs invalid: ${value}`);
|
|
387
|
+
parsed.codecs = list;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
case "--formats": {
|
|
391
|
+
const value = argv[++i] ?? "";
|
|
392
|
+
const list = value.split(",").map((s) => s.trim()).filter((s) => FORMAT_SET[s] === true);
|
|
393
|
+
if (list.length === 0) throw new Error(`--formats invalid: ${value}`);
|
|
394
|
+
parsed.formats = list;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "--duration": {
|
|
398
|
+
const value = Number(argv[++i]);
|
|
399
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
400
|
+
throw new Error(`--duration must be positive, got ${argv[i]}`);
|
|
401
|
+
parsed.durationSeconds = value;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case "--fixture-dir": {
|
|
405
|
+
parsed.fixtureDir = resolve2(argv[++i] ?? "");
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case "--overwrite":
|
|
409
|
+
parsed.overwrite = true;
|
|
410
|
+
break;
|
|
411
|
+
case "--help":
|
|
412
|
+
case "-h":
|
|
413
|
+
parsed.help = true;
|
|
414
|
+
break;
|
|
415
|
+
default:
|
|
416
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return parsed;
|
|
420
|
+
}
|
|
421
|
+
var USAGE = `Usage:
|
|
422
|
+
pnpm --filter @flow-player/test-streams run generate [-- <args>]
|
|
423
|
+
|
|
424
|
+
Options:
|
|
425
|
+
--codecs <h264,hevc> Codecs to generate (default: h264,hevc)
|
|
426
|
+
--formats <flv,hls,mp4,fmp4,ts>
|
|
427
|
+
Container formats to generate (default: all 5)
|
|
428
|
+
--duration <seconds> Per-fixture duration (default: 5)
|
|
429
|
+
--fixture-dir <path> Output directory (default: packages/test-streams/fixtures)
|
|
430
|
+
--overwrite Re-run ffmpeg even if output exists
|
|
431
|
+
--help, -h Show this help
|
|
432
|
+
`;
|
|
433
|
+
async function main() {
|
|
434
|
+
let args;
|
|
435
|
+
try {
|
|
436
|
+
args = parseArgs(process.argv.slice(2));
|
|
437
|
+
} catch (err) {
|
|
438
|
+
process.stderr.write(`${err.message}
|
|
439
|
+
${USAGE}`);
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
if (args.help) {
|
|
443
|
+
process.stdout.write(USAGE);
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
446
|
+
const result = await generateFixtures({
|
|
447
|
+
codecs: args.codecs,
|
|
448
|
+
formats: args.formats,
|
|
449
|
+
durationSeconds: args.durationSeconds,
|
|
450
|
+
fixtureDir: args.fixtureDir,
|
|
451
|
+
overwrite: args.overwrite
|
|
452
|
+
});
|
|
453
|
+
if (result.ffmpegMissing) {
|
|
454
|
+
process.stderr.write(
|
|
455
|
+
"ffmpeg not found in PATH. Install ffmpeg (brew install ffmpeg) and re-run.\n"
|
|
456
|
+
);
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
process.stdout.write(
|
|
460
|
+
`Generated ${result.generated} fixture(s) under ${result.fixtureDir}
|
|
461
|
+
Manifest: ${resolve2(result.fixtureDir, "manifest.json")}
|
|
462
|
+
`
|
|
463
|
+
);
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
466
|
+
main().then(
|
|
467
|
+
(code) => process.exit(code),
|
|
468
|
+
(err) => {
|
|
469
|
+
process.stderr.write(`${err.message}
|
|
470
|
+
`);
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
);
|