@flow-player/test-streams 0.6.0 → 0.6.1

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.
Files changed (40) hide show
  1. package/{src/cli/extend-vod.ts → dist/cli/extend-vod.js} +39 -61
  2. package/dist/cli/generate.js +473 -0
  3. package/dist/cli/serve.js +644 -0
  4. package/dist/config.d.ts +29 -0
  5. package/dist/fixtures/catalog.d.ts +37 -0
  6. package/dist/fixtures/catalog.js +63 -0
  7. package/dist/fixtures/codec.d.ts +10 -0
  8. package/dist/fixtures/format.d.ts +13 -0
  9. package/dist/fixtures/generate.d.ts +11 -0
  10. package/dist/fixtures/generate.js +361 -0
  11. package/dist/fixtures/matrix.d.ts +18 -0
  12. package/dist/index.d.ts +8 -0
  13. package/dist/index.js +894 -0
  14. package/dist/server/live.d.ts +14 -0
  15. package/dist/server/routes.d.ts +18 -0
  16. package/dist/server/server.d.ts +18 -0
  17. package/dist/server/server.js +534 -0
  18. package/dist/server/static.d.ts +13 -0
  19. package/dist/vite-plugin.d.ts +12 -0
  20. package/dist/vite-plugin.js +58 -0
  21. package/package.json +24 -11
  22. package/src/cli/generate.ts +0 -138
  23. package/src/cli/serve.ts +0 -112
  24. package/src/config.ts +0 -34
  25. package/src/fixtures/catalog.ts +0 -99
  26. package/src/fixtures/codec.ts +0 -43
  27. package/src/fixtures/format.ts +0 -118
  28. package/src/fixtures/generate.ts +0 -209
  29. package/src/fixtures/matrix.ts +0 -94
  30. package/src/index.ts +0 -41
  31. package/src/server/live.ts +0 -220
  32. package/src/server/routes.ts +0 -141
  33. package/src/server/server.ts +0 -193
  34. package/src/server/static.ts +0 -83
  35. package/src/visual/analyze.ts +0 -234
  36. package/src/visual/dhash.ts +0 -76
  37. package/src/visual/index.ts +0 -18
  38. package/src/visual/report.ts +0 -80
  39. package/src/vite-plugin.d.ts +0 -10
  40. package/src/vite-plugin.ts +0 -76
@@ -1,38 +1,30 @@
1
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 窗口下足以验证任意点倒放回跳。
2
+
3
+ // packages/test-streams/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
- type Options = {
18
- fixtureDir: string;
19
- segmentSeconds: number;
20
- segmentCount: number;
21
- gopSeconds: number;
22
- width: number;
23
- height: number;
24
- fps: number;
25
- };
9
+ // packages/test-streams/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
+ // packages/test-streams/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
- function parseArgs(argv: ReadonlyArray<string>): Options & { help: boolean } {
28
- const opts: Options = {
18
+ // packages/test-streams/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
- async function runFfmpeg(ffmpegPath: string, args: ReadonlyArray<string>): Promise<number> {
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
- `${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`,
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: Array<{ index: number; path: string; size: number; sha256: string }> = [];
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}\n`);
193
- // 更新 manifest.json 的 durationSeconds 为 "per-segment duration",
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")) as Manifest;
175
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
198
176
  manifest.durationSeconds = args.segmentSeconds;
199
- manifest.generatedAt = new Date().toISOString();
200
- (manifest as Manifest & { segmentCount?: number }).segmentCount = args.segmentCount;
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
- `segmentCount=${args.segmentCount}\n`,
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
- `(run pnpm run generate first)\n`,
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: unknown) => {
218
- process.stderr.write(`[extend-vod] unexpected error: ${String(err)}\n`);
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
+ // packages/test-streams/src/cli/generate.ts
4
+ import { dirname, resolve as resolve2 } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // packages/test-streams/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
+ // packages/test-streams/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
+ // packages/test-streams/src/fixtures/matrix.ts
28
+ import { join as join2 } from "node:path";
29
+
30
+ // packages/test-streams/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
+ // packages/test-streams/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
+ // packages/test-streams/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
+ // packages/test-streams/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
+ // packages/test-streams/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
+ );