@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
package/dist/index.js ADDED
@@ -0,0 +1,894 @@
1
+ // src/fixtures/matrix.ts
2
+ import { join as join2 } from "node:path";
3
+
4
+ // src/config.ts
5
+ var DEFAULT_PORT = 4177;
6
+ var DEFAULT_HOST = "127.0.0.1";
7
+ var DEFAULT_FIXTURE_DIR = "fixtures";
8
+ var DEFAULT_DURATION_SECONDS = 5;
9
+ var DEFAULT_HLS_SEGMENT_SECONDS = 2;
10
+ var ALL_CODECS = ["h264", "hevc"];
11
+ var ALL_FORMATS = ["flv", "hls", "mp4", "fmp4", "ts"];
12
+
13
+ // src/fixtures/codec.ts
14
+ var CODEC_SPEC = {
15
+ h264: {
16
+ codec: "h264",
17
+ encoder: "libx264",
18
+ videoArgs: ["-profile:v", "high", "-preset", "veryfast"],
19
+ audioArgs: ["-c:a", "aac", "-b:a", "128k"],
20
+ pixelFormat: "yuv420p"
21
+ },
22
+ hevc: {
23
+ codec: "hevc",
24
+ encoder: "libx265",
25
+ // 注意:FLV / TS 容器用 enhanced-rtmp 1cvh tag,不能强行写 hvc1;
26
+ // MP4 / fMP4 / HLS 走 hvc1。containerArgs 在 buildPlan 时按 format 决定。
27
+ videoArgs: ["-preset", "veryfast"],
28
+ audioArgs: ["-c:a", "aac", "-b:a", "128k"],
29
+ pixelFormat: "yuv420p"
30
+ }
31
+ };
32
+ function containerExtraArgs(format, codec) {
33
+ if (codec !== "hevc") return [];
34
+ if (format === "mp4") return ["-tag:v", "hvc1"];
35
+ return [];
36
+ }
37
+
38
+ // src/fixtures/format.ts
39
+ import { join } from "node:path";
40
+ function formatSpecific(format) {
41
+ if (format === "flv") return ["-f", "flv"];
42
+ if (format === "hls") {
43
+ return [
44
+ "-f",
45
+ "hls",
46
+ "-hls_time",
47
+ "2",
48
+ "-hls_playlist_type",
49
+ "vod",
50
+ "-hls_segment_type",
51
+ "mpegts",
52
+ "-hls_flags",
53
+ "independent_segments"
54
+ ];
55
+ }
56
+ if (format === "mp4") return ["-f", "mp4", "-movflags", "+faststart"];
57
+ if (format === "fmp4") {
58
+ return [
59
+ "-f",
60
+ "mp4",
61
+ "-movflags",
62
+ "frag_keyframe+empty_moov+default_base_moof",
63
+ "-frag_duration",
64
+ "2000000"
65
+ ];
66
+ }
67
+ if (format === "ts") return ["-f", "mpegts"];
68
+ throw new Error(`Unknown format: ${String(format)}`);
69
+ }
70
+ function layoutFor(codec, format) {
71
+ if (format === "hls") {
72
+ return {
73
+ format,
74
+ mime: "application/vnd.apple.mpegurl",
75
+ mainFileName: "master.m3u8",
76
+ containerArgs: formatSpecific(format),
77
+ isContainerDirectory: true,
78
+ sideFiles: ["seg-00000.ts", "seg-00001.ts", "seg-00002.ts"]
79
+ };
80
+ }
81
+ if (format === "fmp4") {
82
+ return {
83
+ format,
84
+ mime: "video/iso.segment",
85
+ mainFileName: "init.mp4",
86
+ containerArgs: formatSpecific(format),
87
+ isContainerDirectory: true,
88
+ sideFiles: ["seg-00000.m4s", "seg-00001.m4s", "seg-00002.m4s"]
89
+ };
90
+ }
91
+ if (format === "flv") {
92
+ return {
93
+ format,
94
+ mime: "video/x-flv",
95
+ mainFileName: `${codec}.flv`,
96
+ containerArgs: formatSpecific(format),
97
+ isContainerDirectory: false,
98
+ sideFiles: []
99
+ };
100
+ }
101
+ if (format === "mp4") {
102
+ return {
103
+ format,
104
+ mime: "video/mp4",
105
+ mainFileName: `${codec}.mp4`,
106
+ containerArgs: formatSpecific(format),
107
+ isContainerDirectory: false,
108
+ sideFiles: []
109
+ };
110
+ }
111
+ if (format === "ts") {
112
+ return {
113
+ format,
114
+ mime: "video/mp2t",
115
+ mainFileName: `${codec}.ts`,
116
+ containerArgs: formatSpecific(format),
117
+ isContainerDirectory: false,
118
+ sideFiles: []
119
+ };
120
+ }
121
+ throw new Error(`Unknown format: ${String(format)}`);
122
+ }
123
+ function singleFileRelative(codec, format) {
124
+ return join(format, `${codec}.${format}`);
125
+ }
126
+ function containerRelativeDir(codec, format) {
127
+ return join(format, codec);
128
+ }
129
+ function containerRelativePath(codec, format, name) {
130
+ return join(containerRelativeDir(codec, format), name);
131
+ }
132
+
133
+ // src/fixtures/matrix.ts
134
+ function buildPlan(codec, format, durationSeconds) {
135
+ const layout = layoutFor(codec, format);
136
+ const spec = CODEC_SPEC[codec];
137
+ const gop = String(durationSeconds * 25);
138
+ const videoInput = `testsrc2=size=1280x720:rate=25,format=yuv420p`;
139
+ const audioInput = `sine=frequency=1000:sample_rate=44100`;
140
+ const ffmpegOutputArgs = [
141
+ "-y",
142
+ "-f",
143
+ "lavfi",
144
+ "-i",
145
+ videoInput,
146
+ "-f",
147
+ "lavfi",
148
+ "-i",
149
+ audioInput,
150
+ "-t",
151
+ String(durationSeconds),
152
+ "-c:v",
153
+ spec.encoder,
154
+ ...spec.videoArgs,
155
+ "-pix_fmt",
156
+ spec.pixelFormat,
157
+ "-g",
158
+ gop,
159
+ "-keyint_min",
160
+ gop,
161
+ "-sc_threshold",
162
+ "0",
163
+ ...containerExtraArgs(format, codec),
164
+ ...spec.audioArgs,
165
+ ...layout.containerArgs
166
+ ];
167
+ if (layout.isContainerDirectory) {
168
+ const containerDir2 = containerRelativeDir(codec, format);
169
+ const mainFile2 = containerRelativePath(codec, format, layout.mainFileName);
170
+ const files = [
171
+ mainFile2,
172
+ ...layout.sideFiles.map((s) => containerRelativePath(codec, format, s))
173
+ ];
174
+ return { codec, format, layout, containerDir: containerDir2, mainFile: mainFile2, files, ffmpegOutputArgs };
175
+ }
176
+ const mainFile = singleFileRelative(codec, format);
177
+ const containerDir = join2(mainFile.split("/").slice(0, -1).join("/"));
178
+ return { codec, format, layout, containerDir, mainFile, files: [mainFile], ffmpegOutputArgs };
179
+ }
180
+ function expandMatrix(opts) {
181
+ const codecs = opts?.codecs ?? ALL_CODECS;
182
+ const formats = opts?.formats ?? ALL_FORMATS;
183
+ const duration = opts?.durationSeconds ?? 5;
184
+ const plans = [];
185
+ for (const codec of codecs) {
186
+ for (const format of formats) {
187
+ plans.push(buildPlan(codec, format, duration));
188
+ }
189
+ }
190
+ return plans;
191
+ }
192
+ var VOD_SAMPLE_RELATIVE = "mp4/h264.mp4";
193
+
194
+ // src/fixtures/catalog.ts
195
+ import { existsSync, readFileSync, statSync } from "node:fs";
196
+ import { resolve } from "node:path";
197
+ function loadManifest(fixtureDir) {
198
+ const path = resolve(fixtureDir, "manifest.json");
199
+ if (!existsSync(path)) return null;
200
+ const raw = JSON.parse(readFileSync(path, "utf8"));
201
+ return raw;
202
+ }
203
+ function lookupFixture(manifest, codec, format) {
204
+ if (!manifest) return { files: [], mainFile: null, exists: false };
205
+ const item = manifest.fixtures.find((f) => f.codec === codec && f.format === format);
206
+ if (!item) return { files: [], mainFile: null, exists: false };
207
+ const mainFile = item.entries[0] ?? null;
208
+ return { files: item.entries, mainFile, exists: true };
209
+ }
210
+ function summarizeCatalog(fixtureDir) {
211
+ const manifest = loadManifest(fixtureDir);
212
+ if (!manifest) {
213
+ return {
214
+ codecs: ALL_CODECS,
215
+ formats: ALL_FORMATS,
216
+ fixtureCount: 0,
217
+ exists: false,
218
+ reason: `manifest.json not found at ${resolve(fixtureDir, "manifest.json")}`
219
+ };
220
+ }
221
+ let total = 0;
222
+ for (const item of manifest.fixtures) total += item.entries.length;
223
+ return {
224
+ codecs: ALL_CODECS,
225
+ formats: ALL_FORMATS,
226
+ fixtureCount: total,
227
+ exists: true
228
+ };
229
+ }
230
+ function verifyEntryOnDisk(fixtureDir, entry) {
231
+ const abs = resolve(fixtureDir, entry.path);
232
+ if (!existsSync(abs)) {
233
+ return { ok: false, size: 0, reason: `missing file: ${entry.path}` };
234
+ }
235
+ const size = statSync(abs).size;
236
+ if (size !== entry.size) {
237
+ return {
238
+ ok: false,
239
+ size,
240
+ reason: `size mismatch for ${entry.path}: manifest=${entry.size} disk=${size}`
241
+ };
242
+ }
243
+ return { ok: true, size };
244
+ }
245
+
246
+ // src/server/server.ts
247
+ import { createReadStream as createReadStream2, readFileSync as readFileSync3 } from "node:fs";
248
+ import {
249
+ createServer as createHttpServer
250
+ } from "node:http";
251
+ import { createServer as createHttpsServer } from "node:https";
252
+ import { extname, resolve as resolve2, sep } from "node:path";
253
+
254
+ // src/server/static.ts
255
+ import { createReadStream, statSync as statSync2 } from "node:fs";
256
+ function applyCors(res) {
257
+ res.setHeader("Access-Control-Allow-Origin", "*");
258
+ res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
259
+ res.setHeader("Access-Control-Allow-Headers", "Range, Content-Type, If-None-Match");
260
+ }
261
+ function writeJson(res, status, body) {
262
+ const text = JSON.stringify(body);
263
+ res.writeHead(status, {
264
+ "Content-Type": "application/json; charset=utf-8",
265
+ "Content-Length": Buffer.byteLength(text),
266
+ "Cache-Control": "no-store"
267
+ });
268
+ res.end(text);
269
+ }
270
+ function writeNotFound(res) {
271
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
272
+ res.end("Not Found\n");
273
+ }
274
+ function writeBadRequest(res, message) {
275
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
276
+ res.end(`${message}
277
+ `);
278
+ }
279
+ function setRangeHeaders(req, res, fullPath, mime) {
280
+ const stat = statSync2(fullPath);
281
+ const total = stat.size;
282
+ res.setHeader("Accept-Ranges", "bytes");
283
+ const range = req.headers.range;
284
+ if (!range) {
285
+ res.setHeader("Content-Length", total);
286
+ res.setHeader("Content-Type", mime);
287
+ return null;
288
+ }
289
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range);
290
+ if (!match) {
291
+ res.setHeader("Content-Length", total);
292
+ res.setHeader("Content-Type", mime);
293
+ return null;
294
+ }
295
+ const startRaw = match[1];
296
+ const endRaw = match[2];
297
+ let start = startRaw === "" ? Math.max(0, total - Number(endRaw || 0)) : Number(startRaw);
298
+ let end = endRaw === "" ? total - 1 : Number(endRaw);
299
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) {
300
+ res.setHeader("Content-Range", `bytes */${total}`);
301
+ res.writeHead(416);
302
+ res.end();
303
+ return { alreadyServed: true };
304
+ }
305
+ start = Math.max(0, start);
306
+ end = Math.min(total - 1, end);
307
+ res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
308
+ res.setHeader("Content-Length", end - start + 1);
309
+ res.setHeader("Content-Type", mime);
310
+ res.statusCode = 206;
311
+ return { start, end };
312
+ }
313
+
314
+ // src/server/routes.ts
315
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
316
+ import { join as join3 } from "node:path";
317
+ var FORMAT_SET = Object.fromEntries(ALL_FORMATS.map((f) => [f, true]));
318
+ var CODEC_SET = Object.fromEntries(ALL_CODECS.map((c) => [c, true]));
319
+ var HLS_FMP4 = { hls: true, fmp4: true };
320
+ var SINGLE = /^\/streams\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
321
+ var PLAYLIST = /^\/streams\/(hls|fmp4)\/([a-z0-9]+)\/(.+)$/;
322
+ var VOD = /^\/streams\/vod\/([a-zA-Z0-9._-]+)\/?$/;
323
+ function resolveRoute(path) {
324
+ const single = SINGLE.exec(path);
325
+ if (single) {
326
+ const [, formatRaw, codecRaw] = single;
327
+ if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
328
+ if (HLS_FMP4[formatRaw]) return null;
329
+ const format = formatRaw;
330
+ const codec = codecRaw;
331
+ return join3(format, `${codec}.${format}`);
332
+ }
333
+ const playlist = PLAYLIST.exec(path);
334
+ if (playlist) {
335
+ const [, formatRaw, codecRaw, rest] = playlist;
336
+ if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
337
+ return join3(formatRaw, codecRaw, rest);
338
+ }
339
+ const vod = VOD.exec(path);
340
+ if (vod) {
341
+ const [, name] = vod;
342
+ return join3("vod", name);
343
+ }
344
+ return null;
345
+ }
346
+ function handleHealth(res, manifest, fixtureDir) {
347
+ const summary = summarizeCatalog(fixtureDir);
348
+ const status = manifest ? 200 : 503;
349
+ writeJson(res, status, {
350
+ status: summary.exists ? "ok" : "down",
351
+ codecs: summary.codecs,
352
+ formats: summary.formats,
353
+ fixtures: summary.fixtureCount,
354
+ reason: manifest ? void 0 : summary.reason
355
+ });
356
+ }
357
+ function handleList(res, manifest) {
358
+ if (!manifest) {
359
+ writeJson(res, 503, { error: "manifest not found" });
360
+ return;
361
+ }
362
+ writeJson(res, 200, manifest);
363
+ }
364
+ function handleVodList(res, manifest, fixtureDir) {
365
+ const vodListPath = join3(fixtureDir, "vod", "vod-list.json");
366
+ if (existsSync2(vodListPath)) {
367
+ try {
368
+ const raw = JSON.parse(readFileSync2(vodListPath, "utf8"));
369
+ const segMs = manifest?.durationSeconds ? manifest.durationSeconds * 1e3 : 0;
370
+ const segments = raw.entries.map((e, i) => ({
371
+ url: `/streams/${e.path}`,
372
+ startTimestamp: i * segMs,
373
+ endTimestamp: (i + 1) * segMs,
374
+ timeLen: segMs,
375
+ fileSize: e.size,
376
+ channelID: 1,
377
+ RecordType: 1
378
+ }));
379
+ writeJson(res, 200, { segments });
380
+ return;
381
+ } catch {
382
+ }
383
+ }
384
+ if (!manifest) {
385
+ writeJson(res, 503, { error: "manifest not found" });
386
+ return;
387
+ }
388
+ const h264mp4 = manifest.fixtures.find((f) => f.codec === "h264" && f.format === "mp4");
389
+ const fileSize = h264mp4?.entries[0]?.size ?? 0;
390
+ const durMs = manifest.durationSeconds * 1e3;
391
+ writeJson(res, 200, {
392
+ segments: [
393
+ {
394
+ url: "/streams/vod/sample-1.mp4",
395
+ startTimestamp: 0,
396
+ endTimestamp: durMs,
397
+ timeLen: durMs,
398
+ fileSize,
399
+ channelID: 1,
400
+ RecordType: 1
401
+ },
402
+ {
403
+ url: "/streams/vod/sample-2.mp4",
404
+ startTimestamp: durMs,
405
+ endTimestamp: durMs * 2,
406
+ timeLen: durMs,
407
+ fileSize,
408
+ channelID: 1,
409
+ RecordType: 1
410
+ }
411
+ ]
412
+ });
413
+ }
414
+
415
+ // src/server/live.ts
416
+ import { spawn } from "node:child_process";
417
+ var LIVE_MIME = {
418
+ flv: "video/x-flv",
419
+ hls: "application/vnd.apple.mpegurl",
420
+ mp4: "video/mp4",
421
+ fmp4: "video/iso.segment",
422
+ ts: "video/mp2t"
423
+ };
424
+ var CODEC_SET2 = { h264: true, hevc: true };
425
+ var FORMAT_SET2 = { flv: true, hls: true, mp4: true, fmp4: true, ts: true };
426
+ var LIVE_PATTERN = /^\/streams\/live\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
427
+ function matchLivePath(path) {
428
+ const match = LIVE_PATTERN.exec(path);
429
+ if (!match) return null;
430
+ const [, formatRaw, codecRaw] = match;
431
+ if (!FORMAT_SET2[formatRaw] || !CODEC_SET2[codecRaw]) return null;
432
+ return { format: formatRaw, codec: codecRaw };
433
+ }
434
+ function handleLiveStream(req, res, format, codec, options = {}) {
435
+ if (req.method !== "GET" && req.method !== "HEAD") {
436
+ res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
437
+ res.end("Method Not Allowed\n");
438
+ return;
439
+ }
440
+ if (format === "hls") {
441
+ writeJson(res, 501, {
442
+ error: "HLS live streaming not yet supported in this build",
443
+ reason: "use /streams/live/flv/hevc etc. for non-HLS live, or /streams/hls/hevc/master.m3u8 for VOD HLS"
444
+ });
445
+ return;
446
+ }
447
+ const ffmpegPath = options.ffmpegPath ?? "ffmpeg";
448
+ const width = options.width ?? 1280;
449
+ const height = options.height ?? 720;
450
+ const fps = options.fps ?? 25;
451
+ const bitrate = options.bitrate ?? "1500k";
452
+ const spec = CODEC_SPEC[codec];
453
+ const mime = LIVE_MIME[format];
454
+ const ffmpegArgs = buildLiveFfmpegArgs(format, codec, spec, {
455
+ width,
456
+ height,
457
+ fps,
458
+ bitrate
459
+ });
460
+ if (req.method === "HEAD") {
461
+ res.writeHead(200, {
462
+ "Content-Type": mime,
463
+ "Access-Control-Allow-Origin": "*",
464
+ "X-Live-Source": "ffmpeg-stream-loop"
465
+ });
466
+ res.end();
467
+ return;
468
+ }
469
+ let proc;
470
+ try {
471
+ proc = spawn(ffmpegPath, ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
472
+ } catch (err) {
473
+ const message = err instanceof Error ? err.message : String(err);
474
+ writeJson(res, 503, { error: "ffmpeg spawn failed", reason: message });
475
+ return;
476
+ }
477
+ proc.once("error", (err) => {
478
+ if (!res.headersSent) {
479
+ writeJson(res, 503, { error: "ffmpeg failed to start", reason: err.message });
480
+ return;
481
+ }
482
+ res.destroy(err);
483
+ });
484
+ proc.stdout.once("data", () => {
485
+ if (!res.headersSent) {
486
+ res.writeHead(200, {
487
+ "Content-Type": mime,
488
+ "Access-Control-Allow-Origin": "*",
489
+ "Transfer-Encoding": "chunked",
490
+ "X-Live-Source": "ffmpeg-stream-loop"
491
+ });
492
+ }
493
+ });
494
+ proc.stdout.pipe(res);
495
+ const cleanup = () => {
496
+ if (!proc.killed) proc.kill("SIGKILL");
497
+ };
498
+ req.once("close", cleanup);
499
+ res.once("close", cleanup);
500
+ }
501
+ function buildLiveFfmpegArgs(format, codec, spec, opts) {
502
+ const gop = String(opts.fps);
503
+ const videoInput = `testsrc2=size=${opts.width}x${opts.height}:rate=${opts.fps},format=yuv420p`;
504
+ const audioInput = `sine=frequency=1000:sample_rate=44100`;
505
+ const hevcTag = codec === "hevc" && format === "mp4" ? ["-tag:v", "hvc1"] : [];
506
+ const baseArgs = [
507
+ "-hide_banner",
508
+ "-loglevel",
509
+ "error",
510
+ "-fflags",
511
+ "+genpts",
512
+ "-re",
513
+ "-stream_loop",
514
+ "-1",
515
+ "-f",
516
+ "lavfi",
517
+ "-i",
518
+ videoInput,
519
+ "-f",
520
+ "lavfi",
521
+ "-i",
522
+ audioInput,
523
+ "-c:v",
524
+ spec.encoder,
525
+ ...spec.videoArgs,
526
+ ...liveLowLatencyVideoArgs(codec),
527
+ "-pix_fmt",
528
+ spec.pixelFormat,
529
+ "-g",
530
+ gop,
531
+ "-keyint_min",
532
+ gop,
533
+ "-sc_threshold",
534
+ "0",
535
+ "-b:v",
536
+ opts.bitrate,
537
+ ...hevcTag,
538
+ ...spec.audioArgs
539
+ ];
540
+ if (format === "flv") {
541
+ return [...baseArgs, "-f", "flv", "-flvflags", "no_sequence_end", "-"];
542
+ }
543
+ if (format === "mp4") {
544
+ return [
545
+ ...baseArgs,
546
+ "-f",
547
+ "mp4",
548
+ "-movflags",
549
+ "+faststart+frag_keyframe+empty_moov+default_base_moof",
550
+ "-"
551
+ ];
552
+ }
553
+ if (format === "fmp4") {
554
+ return [
555
+ ...baseArgs,
556
+ "-f",
557
+ "mp4",
558
+ "-movflags",
559
+ "frag_keyframe+empty_moov+default_base_moof",
560
+ "-frag_duration",
561
+ "2000000",
562
+ "-"
563
+ ];
564
+ }
565
+ if (format === "ts") {
566
+ return [...baseArgs, "-f", "mpegts", "-"];
567
+ }
568
+ throw new Error(`Unknown format: ${String(format)}`);
569
+ }
570
+ function liveLowLatencyVideoArgs(codec) {
571
+ if (codec === "h264" || codec === "hevc") {
572
+ return ["-tune", "zerolatency"];
573
+ }
574
+ return [];
575
+ }
576
+
577
+ // src/server/server.ts
578
+ function createStreamsServer(options) {
579
+ const host = options.host ?? DEFAULT_HOST;
580
+ const port = options.port ?? DEFAULT_PORT;
581
+ const fixtureDir = resolve2(options.fixtureDir);
582
+ const ctx = {
583
+ manifest: loadManifest(fixtureDir),
584
+ fixtureDir
585
+ };
586
+ const refreshManifest = () => {
587
+ ctx.manifest = loadManifest(fixtureDir);
588
+ return ctx.manifest;
589
+ };
590
+ const handler = (req, res) => {
591
+ applyCors(res);
592
+ if (req.method === "OPTIONS") {
593
+ res.writeHead(204, {
594
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
595
+ "Access-Control-Allow-Headers": "Range, Content-Type, If-None-Match"
596
+ });
597
+ res.end();
598
+ return;
599
+ }
600
+ if (req.method !== "GET" && req.method !== "HEAD") {
601
+ res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
602
+ res.end("Method Not Allowed\n");
603
+ return;
604
+ }
605
+ try {
606
+ const path = decodePath(req.url ?? "/");
607
+ if (path.includes("..")) {
608
+ writeBadRequest(res, "Path traversal not allowed");
609
+ return;
610
+ }
611
+ if (path === "/streams/health" || path === "/streams/health/") {
612
+ handleHealth(res, ctx.manifest, fixtureDir);
613
+ return;
614
+ }
615
+ if (path === "/streams/list" || path === "/streams/list/") {
616
+ handleList(res, ctx.manifest);
617
+ return;
618
+ }
619
+ if (path === "/streams/vod-list" || path === "/streams/vod-list/") {
620
+ handleVodList(res, ctx.manifest, ctx.fixtureDir);
621
+ return;
622
+ }
623
+ const resolved = resolveRoute(path);
624
+ if (resolved) {
625
+ serveEntry(req, res, fixtureDir, resolved);
626
+ return;
627
+ }
628
+ const liveMatch = matchLivePath(path);
629
+ if (liveMatch) {
630
+ handleLiveStream(req, res, liveMatch.format, liveMatch.codec);
631
+ return;
632
+ }
633
+ writeNotFound(res);
634
+ } catch (err) {
635
+ const message = err instanceof Error ? err.message : String(err);
636
+ if (!res.headersSent) {
637
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
638
+ res.end(`Internal Error: ${message}
639
+ `);
640
+ } else {
641
+ res.destroy(err instanceof Error ? err : new Error(message));
642
+ }
643
+ }
644
+ };
645
+ const server = options.cert && options.key ? createHttpsServer(
646
+ {
647
+ cert: readFileSync3(resolve2(options.cert)),
648
+ key: readFileSync3(resolve2(options.key))
649
+ },
650
+ handler
651
+ ) : createHttpServer(handler);
652
+ return {
653
+ server,
654
+ port,
655
+ host,
656
+ close: () => new Promise((resolveClose, rejectClose) => {
657
+ server.close((err) => err ? rejectClose(err) : resolveClose());
658
+ }),
659
+ refreshManifest
660
+ };
661
+ }
662
+ var FIXTURE_MIME = {
663
+ ".flv": "video/x-flv",
664
+ ".mp4": "video/mp4",
665
+ ".m4s": "video/iso.segment",
666
+ ".ts": "video/mp2t",
667
+ ".m3u8": "application/vnd.apple.mpegurl",
668
+ ".json": "application/json; charset=utf-8",
669
+ ".aac": "audio/aac"
670
+ };
671
+ function serveEntry(req, res, fixtureDir, relPath) {
672
+ const abs = resolve2(fixtureDir, relPath);
673
+ const rel = relativePosix(fixtureDir, abs);
674
+ if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
675
+ writeNotFound(res);
676
+ return;
677
+ }
678
+ const mime = FIXTURE_MIME[extname(abs).toLowerCase()] ?? "application/octet-stream";
679
+ const rangeInfo = setRangeHeaders(req, res, abs, mime);
680
+ if (rangeInfo && "alreadyServed" in rangeInfo) return;
681
+ if (req.method === "HEAD") {
682
+ res.end();
683
+ return;
684
+ }
685
+ const startEnd = rangeInfo && "start" in rangeInfo ? rangeInfo : null;
686
+ const stream = startEnd ? createReadStream2(abs, { start: startEnd.start, end: startEnd.end }) : createReadStream2(abs);
687
+ stream.on("error", (err) => res.destroy(err));
688
+ stream.pipe(res);
689
+ }
690
+ function relativePosix(root, abs) {
691
+ if (abs === root) return "";
692
+ if (abs.startsWith(root + sep)) return abs.slice(root.length + 1);
693
+ if (abs.startsWith(root)) return abs.slice(root.length);
694
+ return abs;
695
+ }
696
+ function decodePath(rawTarget) {
697
+ const stripped = rawTarget.split(/[?#]/, 1)[0] || "/";
698
+ try {
699
+ return decodeURIComponent(stripped);
700
+ } catch {
701
+ return stripped;
702
+ }
703
+ }
704
+
705
+ // src/fixtures/generate.ts
706
+ import { createHash } from "node:crypto";
707
+ import {
708
+ existsSync as existsSync3,
709
+ mkdirSync,
710
+ readdirSync,
711
+ readFileSync as readFileSync4,
712
+ statSync as statSync3,
713
+ unlinkSync,
714
+ writeFileSync
715
+ } from "node:fs";
716
+ import { spawn as spawn2 } from "node:child_process";
717
+ import { join as join4, resolve as resolve3 } from "node:path";
718
+ var FFMPEG_PROBE_ARGS = ["-version"];
719
+ async function generateFixtures(opts = {}) {
720
+ const ffmpegPath = opts.ffmpegPath ?? await findFfmpeg();
721
+ if (!ffmpegPath) {
722
+ return {
723
+ fixtureDir: resolve3(opts.fixtureDir ?? DEFAULT_FIXTURE_DIR),
724
+ manifest: emptyManifest(opts.durationSeconds ?? DEFAULT_DURATION_SECONDS),
725
+ generated: 0,
726
+ skipped: 0,
727
+ ffmpegPath: "ffmpeg",
728
+ ffmpegMissing: true
729
+ };
730
+ }
731
+ const fixtureDir = resolve3(opts.fixtureDir ?? DEFAULT_FIXTURE_DIR);
732
+ mkdirSync(fixtureDir, { recursive: true });
733
+ const duration = opts.durationSeconds ?? DEFAULT_DURATION_SECONDS;
734
+ const plans = expandMatrix({
735
+ codecs: opts.codecs ?? ALL_CODECS,
736
+ formats: opts.formats ?? ALL_FORMATS,
737
+ durationSeconds: duration
738
+ });
739
+ const manifestItems = [];
740
+ let generated = 0;
741
+ let skipped = 0;
742
+ for (const plan of plans) {
743
+ const item = await generateOne(
744
+ plan,
745
+ fixtureDir,
746
+ ffmpegPath,
747
+ opts.quiet ?? false,
748
+ opts.overwrite ?? false
749
+ );
750
+ if (item) {
751
+ manifestItems.push(item);
752
+ generated += 1;
753
+ } else {
754
+ skipped += 1;
755
+ }
756
+ }
757
+ copyVodSamples(fixtureDir);
758
+ const manifest = {
759
+ version: 1,
760
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
761
+ durationSeconds: duration,
762
+ fixtures: manifestItems
763
+ };
764
+ writeFileSync(join4(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
765
+ return { fixtureDir, manifest, generated, skipped, ffmpegPath, ffmpegMissing: false };
766
+ }
767
+ async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
768
+ const outDir = join4(fixtureDir, plan.containerDir);
769
+ mkdirSync(outDir, { recursive: true });
770
+ const outFile = join4(fixtureDir, plan.mainFile);
771
+ if (!overwrite && existsSync3(outFile)) {
772
+ return collectExisting(plan, fixtureDir);
773
+ }
774
+ if (plan.layout.isContainerDirectory) {
775
+ for (const f of readdirSync(outDir)) {
776
+ if (f === plan.layout.mainFileName) continue;
777
+ rmIfExists(join4(outDir, f));
778
+ }
779
+ }
780
+ const args = plan.ffmpegOutputArgs.concat([outFile]);
781
+ if (!quiet) {
782
+ process.stdout.write(`[generate] ${plan.codec}/${plan.format} -> ${plan.mainFile}
783
+ `);
784
+ }
785
+ const code = await runFfmpeg(ffmpegPath, args, quiet);
786
+ if (code !== 0) {
787
+ throw new Error(`ffmpeg exited with code ${code} for ${plan.codec}/${plan.format}`);
788
+ }
789
+ return collectExisting(plan, fixtureDir);
790
+ }
791
+ function collectExisting(plan, fixtureDir) {
792
+ const entries = [];
793
+ for (const rel of plan.files) {
794
+ const abs = join4(fixtureDir, rel);
795
+ if (!existsSync3(abs)) continue;
796
+ entries.push({ path: rel, size: statSync3(abs).size, sha256: hashFile(abs) });
797
+ }
798
+ if (plan.layout.isContainerDirectory) {
799
+ const absDir = join4(fixtureDir, plan.containerDir);
800
+ if (existsSync3(absDir)) {
801
+ for (const name of readdirSync(absDir).sort()) {
802
+ if (name === plan.layout.mainFileName) continue;
803
+ const rel = join4(plan.containerDir, name);
804
+ if (entries.find((e) => e.path === rel)) continue;
805
+ const abs = join4(absDir, name);
806
+ if (!statSync3(abs).isFile()) continue;
807
+ entries.push({ path: rel, size: statSync3(abs).size, sha256: hashFile(abs) });
808
+ }
809
+ }
810
+ }
811
+ return { codec: plan.codec, format: plan.format, entries };
812
+ }
813
+ function copyVodSamples(fixtureDir) {
814
+ const src = join4(fixtureDir, VOD_SAMPLE_RELATIVE);
815
+ if (!existsSync3(src)) return;
816
+ const vodDir = join4(fixtureDir, "vod");
817
+ mkdirSync(vodDir, { recursive: true });
818
+ for (const name of ["sample-1.mp4", "sample-2.mp4"]) {
819
+ const dst = join4(vodDir, name);
820
+ writeFileSync(dst, readFileSync4(src));
821
+ }
822
+ }
823
+ function rmIfExists(abs) {
824
+ if (!existsSync3(abs)) return;
825
+ try {
826
+ unlinkSync(abs);
827
+ } catch {
828
+ }
829
+ }
830
+ function runFfmpeg(ffmpegPath, args, quiet) {
831
+ const { promise, resolve: resolve4, reject } = Promise.withResolvers();
832
+ const proc = spawn2(ffmpegPath, args, {
833
+ stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit"
834
+ });
835
+ proc.once("error", reject);
836
+ proc.once("exit", (code) => resolve4(code ?? 1));
837
+ return promise;
838
+ }
839
+ async function findFfmpeg() {
840
+ const candidates = [
841
+ "ffmpeg",
842
+ "/opt/homebrew/bin/ffmpeg",
843
+ "/usr/local/bin/ffmpeg",
844
+ "/usr/bin/ffmpeg"
845
+ ];
846
+ for (const candidate of candidates) {
847
+ if (await canRun(candidate)) return candidate;
848
+ }
849
+ return null;
850
+ }
851
+ async function canRun(path) {
852
+ const { promise, resolve: resolve4 } = Promise.withResolvers();
853
+ const proc = spawn2(path, FFMPEG_PROBE_ARGS, { stdio: "ignore" });
854
+ proc.once("error", () => resolve4(false));
855
+ proc.once("exit", (code) => resolve4(code === 0));
856
+ return promise;
857
+ }
858
+ function hashFile(abs) {
859
+ const hash = createHash("sha256");
860
+ hash.update(readFileSync4(abs));
861
+ return hash.digest("hex");
862
+ }
863
+ function emptyManifest(duration) {
864
+ return {
865
+ version: 1,
866
+ generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
867
+ durationSeconds: duration,
868
+ fixtures: []
869
+ };
870
+ }
871
+ export {
872
+ ALL_CODECS,
873
+ ALL_FORMATS,
874
+ CODEC_SPEC,
875
+ DEFAULT_DURATION_SECONDS,
876
+ DEFAULT_FIXTURE_DIR,
877
+ DEFAULT_HLS_SEGMENT_SECONDS,
878
+ DEFAULT_HOST,
879
+ DEFAULT_PORT,
880
+ VOD_SAMPLE_RELATIVE,
881
+ buildPlan,
882
+ containerRelativeDir,
883
+ containerRelativePath,
884
+ createStreamsServer,
885
+ expandMatrix,
886
+ generateFixtures,
887
+ handleLiveStream,
888
+ layoutFor,
889
+ loadManifest,
890
+ lookupFixture,
891
+ matchLivePath,
892
+ summarizeCatalog,
893
+ verifyEntryOnDisk
894
+ };