@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.
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
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/serve.ts
4
+ import { dirname, resolve as resolve3 } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // src/config.ts
8
+ var DEFAULT_PORT = 4177;
9
+ var DEFAULT_HOST = "127.0.0.1";
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/server.ts
15
+ import { createReadStream as createReadStream2, readFileSync as readFileSync3 } from "node:fs";
16
+ import {
17
+ createServer as createHttpServer
18
+ } from "node:http";
19
+ import { createServer as createHttpsServer } from "node:https";
20
+ import { extname, resolve as resolve2, sep } from "node:path";
21
+
22
+ // src/fixtures/catalog.ts
23
+ import { existsSync, readFileSync, statSync } from "node:fs";
24
+ import { resolve } from "node:path";
25
+ function loadManifest(fixtureDir) {
26
+ const path = resolve(fixtureDir, "manifest.json");
27
+ if (!existsSync(path)) return null;
28
+ const raw = JSON.parse(readFileSync(path, "utf8"));
29
+ return raw;
30
+ }
31
+ function summarizeCatalog(fixtureDir) {
32
+ const manifest = loadManifest(fixtureDir);
33
+ if (!manifest) {
34
+ return {
35
+ codecs: ALL_CODECS,
36
+ formats: ALL_FORMATS,
37
+ fixtureCount: 0,
38
+ exists: false,
39
+ reason: `manifest.json not found at ${resolve(fixtureDir, "manifest.json")}`
40
+ };
41
+ }
42
+ let total = 0;
43
+ for (const item of manifest.fixtures) total += item.entries.length;
44
+ return {
45
+ codecs: ALL_CODECS,
46
+ formats: ALL_FORMATS,
47
+ fixtureCount: total,
48
+ exists: true
49
+ };
50
+ }
51
+
52
+ // src/server/static.ts
53
+ import { createReadStream, statSync as statSync2 } from "node:fs";
54
+ function applyCors(res) {
55
+ res.setHeader("Access-Control-Allow-Origin", "*");
56
+ res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
57
+ res.setHeader("Access-Control-Allow-Headers", "Range, Content-Type, If-None-Match");
58
+ }
59
+ function writeJson(res, status, body) {
60
+ const text = JSON.stringify(body);
61
+ res.writeHead(status, {
62
+ "Content-Type": "application/json; charset=utf-8",
63
+ "Content-Length": Buffer.byteLength(text),
64
+ "Cache-Control": "no-store"
65
+ });
66
+ res.end(text);
67
+ }
68
+ function writeNotFound(res) {
69
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
70
+ res.end("Not Found\n");
71
+ }
72
+ function writeBadRequest(res, message) {
73
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
74
+ res.end(`${message}
75
+ `);
76
+ }
77
+ function setRangeHeaders(req, res, fullPath, mime) {
78
+ const stat = statSync2(fullPath);
79
+ const total = stat.size;
80
+ res.setHeader("Accept-Ranges", "bytes");
81
+ const range = req.headers.range;
82
+ if (!range) {
83
+ res.setHeader("Content-Length", total);
84
+ res.setHeader("Content-Type", mime);
85
+ return null;
86
+ }
87
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range);
88
+ if (!match) {
89
+ res.setHeader("Content-Length", total);
90
+ res.setHeader("Content-Type", mime);
91
+ return null;
92
+ }
93
+ const startRaw = match[1];
94
+ const endRaw = match[2];
95
+ let start = startRaw === "" ? Math.max(0, total - Number(endRaw || 0)) : Number(startRaw);
96
+ let end = endRaw === "" ? total - 1 : Number(endRaw);
97
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) {
98
+ res.setHeader("Content-Range", `bytes */${total}`);
99
+ res.writeHead(416);
100
+ res.end();
101
+ return { alreadyServed: true };
102
+ }
103
+ start = Math.max(0, start);
104
+ end = Math.min(total - 1, end);
105
+ res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
106
+ res.setHeader("Content-Length", end - start + 1);
107
+ res.setHeader("Content-Type", mime);
108
+ res.statusCode = 206;
109
+ return { start, end };
110
+ }
111
+
112
+ // src/server/routes.ts
113
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
114
+ import { join } from "node:path";
115
+ var FORMAT_SET = Object.fromEntries(ALL_FORMATS.map((f) => [f, true]));
116
+ var CODEC_SET = Object.fromEntries(ALL_CODECS.map((c) => [c, true]));
117
+ var HLS_FMP4 = { hls: true, fmp4: true };
118
+ var SINGLE = /^\/streams\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
119
+ var PLAYLIST = /^\/streams\/(hls|fmp4)\/([a-z0-9]+)\/(.+)$/;
120
+ var VOD = /^\/streams\/vod\/([a-zA-Z0-9._-]+)\/?$/;
121
+ function resolveRoute(path) {
122
+ const single = SINGLE.exec(path);
123
+ if (single) {
124
+ const [, formatRaw, codecRaw] = single;
125
+ if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
126
+ if (HLS_FMP4[formatRaw]) return null;
127
+ const format = formatRaw;
128
+ const codec = codecRaw;
129
+ return join(format, `${codec}.${format}`);
130
+ }
131
+ const playlist = PLAYLIST.exec(path);
132
+ if (playlist) {
133
+ const [, formatRaw, codecRaw, rest] = playlist;
134
+ if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
135
+ return join(formatRaw, codecRaw, rest);
136
+ }
137
+ const vod = VOD.exec(path);
138
+ if (vod) {
139
+ const [, name] = vod;
140
+ return join("vod", name);
141
+ }
142
+ return null;
143
+ }
144
+ function handleHealth(res, manifest, fixtureDir) {
145
+ const summary = summarizeCatalog(fixtureDir);
146
+ const status = manifest ? 200 : 503;
147
+ writeJson(res, status, {
148
+ status: summary.exists ? "ok" : "down",
149
+ codecs: summary.codecs,
150
+ formats: summary.formats,
151
+ fixtures: summary.fixtureCount,
152
+ reason: manifest ? void 0 : summary.reason
153
+ });
154
+ }
155
+ function handleList(res, manifest) {
156
+ if (!manifest) {
157
+ writeJson(res, 503, { error: "manifest not found" });
158
+ return;
159
+ }
160
+ writeJson(res, 200, manifest);
161
+ }
162
+ function handleVodList(res, manifest, fixtureDir) {
163
+ const vodListPath = join(fixtureDir, "vod", "vod-list.json");
164
+ if (existsSync2(vodListPath)) {
165
+ try {
166
+ const raw = JSON.parse(readFileSync2(vodListPath, "utf8"));
167
+ const segMs = manifest?.durationSeconds ? manifest.durationSeconds * 1e3 : 0;
168
+ const segments = raw.entries.map((e, i) => ({
169
+ url: `/streams/${e.path}`,
170
+ startTimestamp: i * segMs,
171
+ endTimestamp: (i + 1) * segMs,
172
+ timeLen: segMs,
173
+ fileSize: e.size,
174
+ channelID: 1,
175
+ RecordType: 1
176
+ }));
177
+ writeJson(res, 200, { segments });
178
+ return;
179
+ } catch {
180
+ }
181
+ }
182
+ if (!manifest) {
183
+ writeJson(res, 503, { error: "manifest not found" });
184
+ return;
185
+ }
186
+ const h264mp4 = manifest.fixtures.find((f) => f.codec === "h264" && f.format === "mp4");
187
+ const fileSize = h264mp4?.entries[0]?.size ?? 0;
188
+ const durMs = manifest.durationSeconds * 1e3;
189
+ writeJson(res, 200, {
190
+ segments: [
191
+ {
192
+ url: "/streams/vod/sample-1.mp4",
193
+ startTimestamp: 0,
194
+ endTimestamp: durMs,
195
+ timeLen: durMs,
196
+ fileSize,
197
+ channelID: 1,
198
+ RecordType: 1
199
+ },
200
+ {
201
+ url: "/streams/vod/sample-2.mp4",
202
+ startTimestamp: durMs,
203
+ endTimestamp: durMs * 2,
204
+ timeLen: durMs,
205
+ fileSize,
206
+ channelID: 1,
207
+ RecordType: 1
208
+ }
209
+ ]
210
+ });
211
+ }
212
+
213
+ // src/server/live.ts
214
+ import { spawn } from "node:child_process";
215
+
216
+ // src/fixtures/codec.ts
217
+ var CODEC_SPEC = {
218
+ h264: {
219
+ codec: "h264",
220
+ encoder: "libx264",
221
+ videoArgs: ["-profile:v", "high", "-preset", "veryfast"],
222
+ audioArgs: ["-c:a", "aac", "-b:a", "128k"],
223
+ pixelFormat: "yuv420p"
224
+ },
225
+ hevc: {
226
+ codec: "hevc",
227
+ encoder: "libx265",
228
+ // 注意:FLV / TS 容器用 enhanced-rtmp 1cvh tag,不能强行写 hvc1;
229
+ // MP4 / fMP4 / HLS 走 hvc1。containerArgs 在 buildPlan 时按 format 决定。
230
+ videoArgs: ["-preset", "veryfast"],
231
+ audioArgs: ["-c:a", "aac", "-b:a", "128k"],
232
+ pixelFormat: "yuv420p"
233
+ }
234
+ };
235
+
236
+ // src/server/live.ts
237
+ var LIVE_MIME = {
238
+ flv: "video/x-flv",
239
+ hls: "application/vnd.apple.mpegurl",
240
+ mp4: "video/mp4",
241
+ fmp4: "video/iso.segment",
242
+ ts: "video/mp2t"
243
+ };
244
+ var CODEC_SET2 = { h264: true, hevc: true };
245
+ var FORMAT_SET2 = { flv: true, hls: true, mp4: true, fmp4: true, ts: true };
246
+ var LIVE_PATTERN = /^\/streams\/live\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
247
+ function matchLivePath(path) {
248
+ const match = LIVE_PATTERN.exec(path);
249
+ if (!match) return null;
250
+ const [, formatRaw, codecRaw] = match;
251
+ if (!FORMAT_SET2[formatRaw] || !CODEC_SET2[codecRaw]) return null;
252
+ return { format: formatRaw, codec: codecRaw };
253
+ }
254
+ function handleLiveStream(req, res, format, codec, options = {}) {
255
+ if (req.method !== "GET" && req.method !== "HEAD") {
256
+ res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
257
+ res.end("Method Not Allowed\n");
258
+ return;
259
+ }
260
+ if (format === "hls") {
261
+ writeJson(res, 501, {
262
+ error: "HLS live streaming not yet supported in this build",
263
+ reason: "use /streams/live/flv/hevc etc. for non-HLS live, or /streams/hls/hevc/master.m3u8 for VOD HLS"
264
+ });
265
+ return;
266
+ }
267
+ const ffmpegPath = options.ffmpegPath ?? "ffmpeg";
268
+ const width = options.width ?? 1280;
269
+ const height = options.height ?? 720;
270
+ const fps = options.fps ?? 25;
271
+ const bitrate = options.bitrate ?? "1500k";
272
+ const spec = CODEC_SPEC[codec];
273
+ const mime = LIVE_MIME[format];
274
+ const ffmpegArgs = buildLiveFfmpegArgs(format, codec, spec, {
275
+ width,
276
+ height,
277
+ fps,
278
+ bitrate
279
+ });
280
+ if (req.method === "HEAD") {
281
+ res.writeHead(200, {
282
+ "Content-Type": mime,
283
+ "Access-Control-Allow-Origin": "*",
284
+ "X-Live-Source": "ffmpeg-stream-loop"
285
+ });
286
+ res.end();
287
+ return;
288
+ }
289
+ let proc;
290
+ try {
291
+ proc = spawn(ffmpegPath, ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
292
+ } catch (err) {
293
+ const message = err instanceof Error ? err.message : String(err);
294
+ writeJson(res, 503, { error: "ffmpeg spawn failed", reason: message });
295
+ return;
296
+ }
297
+ proc.once("error", (err) => {
298
+ if (!res.headersSent) {
299
+ writeJson(res, 503, { error: "ffmpeg failed to start", reason: err.message });
300
+ return;
301
+ }
302
+ res.destroy(err);
303
+ });
304
+ proc.stdout.once("data", () => {
305
+ if (!res.headersSent) {
306
+ res.writeHead(200, {
307
+ "Content-Type": mime,
308
+ "Access-Control-Allow-Origin": "*",
309
+ "Transfer-Encoding": "chunked",
310
+ "X-Live-Source": "ffmpeg-stream-loop"
311
+ });
312
+ }
313
+ });
314
+ proc.stdout.pipe(res);
315
+ const cleanup = () => {
316
+ if (!proc.killed) proc.kill("SIGKILL");
317
+ };
318
+ req.once("close", cleanup);
319
+ res.once("close", cleanup);
320
+ }
321
+ function buildLiveFfmpegArgs(format, codec, spec, opts) {
322
+ const gop = String(opts.fps);
323
+ const videoInput = `testsrc2=size=${opts.width}x${opts.height}:rate=${opts.fps},format=yuv420p`;
324
+ const audioInput = `sine=frequency=1000:sample_rate=44100`;
325
+ const hevcTag = codec === "hevc" && format === "mp4" ? ["-tag:v", "hvc1"] : [];
326
+ const baseArgs = [
327
+ "-hide_banner",
328
+ "-loglevel",
329
+ "error",
330
+ "-fflags",
331
+ "+genpts",
332
+ "-re",
333
+ "-stream_loop",
334
+ "-1",
335
+ "-f",
336
+ "lavfi",
337
+ "-i",
338
+ videoInput,
339
+ "-f",
340
+ "lavfi",
341
+ "-i",
342
+ audioInput,
343
+ "-c:v",
344
+ spec.encoder,
345
+ ...spec.videoArgs,
346
+ ...liveLowLatencyVideoArgs(codec),
347
+ "-pix_fmt",
348
+ spec.pixelFormat,
349
+ "-g",
350
+ gop,
351
+ "-keyint_min",
352
+ gop,
353
+ "-sc_threshold",
354
+ "0",
355
+ "-b:v",
356
+ opts.bitrate,
357
+ ...hevcTag,
358
+ ...spec.audioArgs
359
+ ];
360
+ if (format === "flv") {
361
+ return [...baseArgs, "-f", "flv", "-flvflags", "no_sequence_end", "-"];
362
+ }
363
+ if (format === "mp4") {
364
+ return [
365
+ ...baseArgs,
366
+ "-f",
367
+ "mp4",
368
+ "-movflags",
369
+ "+faststart+frag_keyframe+empty_moov+default_base_moof",
370
+ "-"
371
+ ];
372
+ }
373
+ if (format === "fmp4") {
374
+ return [
375
+ ...baseArgs,
376
+ "-f",
377
+ "mp4",
378
+ "-movflags",
379
+ "frag_keyframe+empty_moov+default_base_moof",
380
+ "-frag_duration",
381
+ "2000000",
382
+ "-"
383
+ ];
384
+ }
385
+ if (format === "ts") {
386
+ return [...baseArgs, "-f", "mpegts", "-"];
387
+ }
388
+ throw new Error(`Unknown format: ${String(format)}`);
389
+ }
390
+ function liveLowLatencyVideoArgs(codec) {
391
+ if (codec === "h264" || codec === "hevc") {
392
+ return ["-tune", "zerolatency"];
393
+ }
394
+ return [];
395
+ }
396
+
397
+ // src/server/server.ts
398
+ function createStreamsServer(options) {
399
+ const host = options.host ?? DEFAULT_HOST;
400
+ const port = options.port ?? DEFAULT_PORT;
401
+ const fixtureDir = resolve2(options.fixtureDir);
402
+ const ctx = {
403
+ manifest: loadManifest(fixtureDir),
404
+ fixtureDir
405
+ };
406
+ const refreshManifest = () => {
407
+ ctx.manifest = loadManifest(fixtureDir);
408
+ return ctx.manifest;
409
+ };
410
+ const handler = (req, res) => {
411
+ applyCors(res);
412
+ if (req.method === "OPTIONS") {
413
+ res.writeHead(204, {
414
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
415
+ "Access-Control-Allow-Headers": "Range, Content-Type, If-None-Match"
416
+ });
417
+ res.end();
418
+ return;
419
+ }
420
+ if (req.method !== "GET" && req.method !== "HEAD") {
421
+ res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
422
+ res.end("Method Not Allowed\n");
423
+ return;
424
+ }
425
+ try {
426
+ const path = decodePath(req.url ?? "/");
427
+ if (path.includes("..")) {
428
+ writeBadRequest(res, "Path traversal not allowed");
429
+ return;
430
+ }
431
+ if (path === "/streams/health" || path === "/streams/health/") {
432
+ handleHealth(res, ctx.manifest, fixtureDir);
433
+ return;
434
+ }
435
+ if (path === "/streams/list" || path === "/streams/list/") {
436
+ handleList(res, ctx.manifest);
437
+ return;
438
+ }
439
+ if (path === "/streams/vod-list" || path === "/streams/vod-list/") {
440
+ handleVodList(res, ctx.manifest, ctx.fixtureDir);
441
+ return;
442
+ }
443
+ const resolved = resolveRoute(path);
444
+ if (resolved) {
445
+ serveEntry(req, res, fixtureDir, resolved);
446
+ return;
447
+ }
448
+ const liveMatch = matchLivePath(path);
449
+ if (liveMatch) {
450
+ handleLiveStream(req, res, liveMatch.format, liveMatch.codec);
451
+ return;
452
+ }
453
+ writeNotFound(res);
454
+ } catch (err) {
455
+ const message = err instanceof Error ? err.message : String(err);
456
+ if (!res.headersSent) {
457
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
458
+ res.end(`Internal Error: ${message}
459
+ `);
460
+ } else {
461
+ res.destroy(err instanceof Error ? err : new Error(message));
462
+ }
463
+ }
464
+ };
465
+ const server = options.cert && options.key ? createHttpsServer(
466
+ {
467
+ cert: readFileSync3(resolve2(options.cert)),
468
+ key: readFileSync3(resolve2(options.key))
469
+ },
470
+ handler
471
+ ) : createHttpServer(handler);
472
+ return {
473
+ server,
474
+ port,
475
+ host,
476
+ close: () => new Promise((resolveClose, rejectClose) => {
477
+ server.close((err) => err ? rejectClose(err) : resolveClose());
478
+ }),
479
+ refreshManifest
480
+ };
481
+ }
482
+ async function listen(server) {
483
+ const { promise, resolve: resolve4, reject } = Promise.withResolvers();
484
+ server.server.once("error", reject);
485
+ server.server.listen(server.port, server.host, () => {
486
+ const addr = server.server.address();
487
+ if (!addr) {
488
+ reject(new Error("server.address() returned null after listen"));
489
+ return;
490
+ }
491
+ server.port = addr.port;
492
+ resolve4(server);
493
+ });
494
+ return promise;
495
+ }
496
+ var FIXTURE_MIME = {
497
+ ".flv": "video/x-flv",
498
+ ".mp4": "video/mp4",
499
+ ".m4s": "video/iso.segment",
500
+ ".ts": "video/mp2t",
501
+ ".m3u8": "application/vnd.apple.mpegurl",
502
+ ".json": "application/json; charset=utf-8",
503
+ ".aac": "audio/aac"
504
+ };
505
+ function serveEntry(req, res, fixtureDir, relPath) {
506
+ const abs = resolve2(fixtureDir, relPath);
507
+ const rel = relativePosix(fixtureDir, abs);
508
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
509
+ writeNotFound(res);
510
+ return;
511
+ }
512
+ const mime = FIXTURE_MIME[extname(abs).toLowerCase()] ?? "application/octet-stream";
513
+ const rangeInfo = setRangeHeaders(req, res, abs, mime);
514
+ if (rangeInfo && "alreadyServed" in rangeInfo) return;
515
+ if (req.method === "HEAD") {
516
+ res.end();
517
+ return;
518
+ }
519
+ const startEnd = rangeInfo && "start" in rangeInfo ? rangeInfo : null;
520
+ const stream = startEnd ? createReadStream2(abs, { start: startEnd.start, end: startEnd.end }) : createReadStream2(abs);
521
+ stream.on("error", (err) => res.destroy(err));
522
+ stream.pipe(res);
523
+ }
524
+ function relativePosix(root, abs) {
525
+ if (abs === root) return "";
526
+ if (abs.startsWith(root + sep)) return abs.slice(root.length + 1);
527
+ if (abs.startsWith(root)) return abs.slice(root.length);
528
+ return abs;
529
+ }
530
+ function decodePath(rawTarget) {
531
+ const stripped = rawTarget.split(/[?#]/, 1)[0] || "/";
532
+ try {
533
+ return decodeURIComponent(stripped);
534
+ } catch {
535
+ return stripped;
536
+ }
537
+ }
538
+
539
+ // src/cli/serve.ts
540
+ var here = dirname(fileURLToPath(import.meta.url));
541
+ function parseArgs(argv) {
542
+ const parsed = {
543
+ port: DEFAULT_PORT,
544
+ fixtureDir: resolve3(here, "..", "..", DEFAULT_FIXTURE_DIR),
545
+ help: false
546
+ };
547
+ for (let i = 0; i < argv.length; i += 1) {
548
+ const arg = argv[i];
549
+ switch (arg) {
550
+ case "--port": {
551
+ const v = Number(argv[++i]);
552
+ if (!Number.isInteger(v) || v < 1 || v > 65535)
553
+ throw new Error(`--port invalid: ${argv[i]}`);
554
+ parsed.port = v;
555
+ break;
556
+ }
557
+ case "--host":
558
+ parsed.host = argv[++i];
559
+ break;
560
+ case "--fixture-dir":
561
+ parsed.fixtureDir = resolve3(argv[++i] ?? "");
562
+ break;
563
+ case "--cert":
564
+ parsed.cert = argv[++i];
565
+ break;
566
+ case "--key":
567
+ parsed.key = argv[++i];
568
+ break;
569
+ case "--help":
570
+ case "-h":
571
+ parsed.help = true;
572
+ break;
573
+ default:
574
+ throw new Error(`Unknown option: ${arg}`);
575
+ }
576
+ }
577
+ return parsed;
578
+ }
579
+ var USAGE = `Usage:
580
+ pnpm --filter @flow-player/test-streams run serve [-- <args>]
581
+
582
+ Options:
583
+ --port <port> Listen port (default: 4177)
584
+ --host <host> Bind host (default: 127.0.0.1)
585
+ --fixture-dir <path> Fixture root (default: packages/test-streams/fixtures)
586
+ --cert <path> TLS cert (with --key, enables HTTPS)
587
+ --key <path> TLS private key (with --cert, enables HTTPS)
588
+ --help, -h Show this help
589
+ `;
590
+ async function main() {
591
+ let args;
592
+ try {
593
+ args = parseArgs(process.argv.slice(2));
594
+ } catch (err) {
595
+ process.stderr.write(`${err.message}
596
+ ${USAGE}`);
597
+ return 1;
598
+ }
599
+ if (args.help) {
600
+ process.stdout.write(USAGE);
601
+ return 0;
602
+ }
603
+ const server = createStreamsServer({
604
+ port: args.port,
605
+ host: args.host,
606
+ // parseArgs guarantees a default; treat as string.
607
+ fixtureDir: args.fixtureDir,
608
+ cert: args.cert,
609
+ key: args.key
610
+ });
611
+ const listening = await listen(server);
612
+ const protocol = args.cert && args.key ? "https" : "http";
613
+ process.stdout.write(
614
+ `streams server listening on ${protocol}://${listening.host}:${listening.port}
615
+ `
616
+ );
617
+ process.stdout.write(
618
+ ` health: ${protocol}://${listening.host}:${listening.port}/streams/health
619
+ `
620
+ );
621
+ process.stdout.write(` fixture: ${resolve3(args.fixtureDir ?? "")}
622
+ `);
623
+ const { promise, resolve: resolveWait } = Promise.withResolvers();
624
+ const shutdown = (code) => {
625
+ process.stdout.write("\nshutting down streams server...\n");
626
+ listening.close().finally(() => resolveWait(code));
627
+ };
628
+ process.once("SIGINT", () => shutdown(0));
629
+ process.once("SIGTERM", () => shutdown(0));
630
+ process.once("uncaughtException", (err) => {
631
+ process.stderr.write(`uncaught: ${err.stack ?? err.message}
632
+ `);
633
+ shutdown(1);
634
+ });
635
+ return promise;
636
+ }
637
+ void main().then(
638
+ (code) => process.exit(code),
639
+ (err) => {
640
+ process.stderr.write(`${err.message}
641
+ `);
642
+ process.exit(1);
643
+ }
644
+ );
@@ -0,0 +1,29 @@
1
+ export declare const DEFAULT_PORT = 4177;
2
+ export declare const DEFAULT_HOST = "127.0.0.1";
3
+ export declare const DEFAULT_FIXTURE_DIR = "fixtures";
4
+ export declare const DEFAULT_DURATION_SECONDS = 5;
5
+ export declare const DEFAULT_VIDEO_WIDTH = 1280;
6
+ export declare const DEFAULT_VIDEO_HEIGHT = 720;
7
+ export declare const DEFAULT_VIDEO_FPS = 25;
8
+ export declare const DEFAULT_GOP_SECONDS = 1;
9
+ export declare const DEFAULT_HLS_SEGMENT_SECONDS = 2;
10
+ export type GenerateOptions = {
11
+ codecs?: ReadonlyArray<CodecId>;
12
+ formats?: ReadonlyArray<FormatId>;
13
+ durationSeconds?: number;
14
+ fixtureDir?: string;
15
+ overwrite?: boolean;
16
+ ffmpegPath?: string;
17
+ quiet?: boolean;
18
+ };
19
+ export type ServeOptions = {
20
+ port?: number;
21
+ host?: string;
22
+ fixtureDir?: string;
23
+ cert?: string;
24
+ key?: string;
25
+ };
26
+ export type CodecId = "h264" | "hevc";
27
+ export type FormatId = "flv" | "hls" | "mp4" | "fmp4" | "ts";
28
+ export declare const ALL_CODECS: ReadonlyArray<CodecId>;
29
+ export declare const ALL_FORMATS: ReadonlyArray<FormatId>;
@@ -0,0 +1,37 @@
1
+ import { type CodecId, type FormatId } from "../config.js";
2
+ export type ManifestEntry = {
3
+ path: string;
4
+ size: number;
5
+ sha256: string;
6
+ };
7
+ export type ManifestItem = {
8
+ codec: CodecId;
9
+ format: FormatId;
10
+ entries: ReadonlyArray<ManifestEntry>;
11
+ };
12
+ export type Manifest = {
13
+ version: 1;
14
+ generatedAt: string;
15
+ durationSeconds: number;
16
+ fixtures: ReadonlyArray<ManifestItem>;
17
+ };
18
+ export type FixtureLookup = {
19
+ files: ReadonlyArray<ManifestEntry>;
20
+ mainFile: ManifestEntry | null;
21
+ exists: boolean;
22
+ };
23
+ export declare function loadManifest(fixtureDir: string): Manifest | null;
24
+ export declare function lookupFixture(manifest: Manifest | null, codec: CodecId, format: FormatId): FixtureLookup;
25
+ export type CatalogSummary = {
26
+ codecs: ReadonlyArray<CodecId>;
27
+ formats: ReadonlyArray<FormatId>;
28
+ fixtureCount: number;
29
+ exists: boolean;
30
+ reason?: string;
31
+ };
32
+ export declare function summarizeCatalog(fixtureDir: string): CatalogSummary;
33
+ export declare function verifyEntryOnDisk(fixtureDir: string, entry: ManifestEntry): {
34
+ ok: boolean;
35
+ size: number;
36
+ reason?: string;
37
+ };