@flow-player/test-streams 0.14.2 → 0.15.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.
- package/README.md +63 -0
- package/dist/cli/generate.js +469 -15
- package/dist/cli/serve.js +14 -1
- package/dist/config.d.ts +2 -0
- package/dist/fixtures/catalog.d.ts +18 -0
- package/dist/fixtures/catalog.js +1 -0
- package/dist/fixtures/generate.d.ts +6 -3
- package/dist/fixtures/generate.js +462 -14
- package/dist/fixtures/window.d.ts +110 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +493 -22
- package/dist/server/routes.d.ts +5 -0
- package/dist/server/server.js +14 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -224,6 +224,7 @@ function summarizeCatalog(fixtureDir) {
|
|
|
224
224
|
}
|
|
225
225
|
let total = 0;
|
|
226
226
|
for (const item of manifest.fixtures) total += item.entries.length;
|
|
227
|
+
for (const item of manifest.windowFixtures ?? []) total += item.entries.length;
|
|
227
228
|
return {
|
|
228
229
|
codecs: ALL_CODECS,
|
|
229
230
|
formats: ALL_FORMATS,
|
|
@@ -247,6 +248,375 @@ function verifyEntryOnDisk(fixtureDir, entry) {
|
|
|
247
248
|
return { ok: true, size };
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
// src/fixtures/window.ts
|
|
252
|
+
import { join as join3 } from "node:path";
|
|
253
|
+
var DEFAULT_DRAWTEXT_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
|
|
254
|
+
var DRAWTEXT_FONT_CANDIDATES = [
|
|
255
|
+
"/System/Library/Fonts/Supplemental/Verdana.ttf",
|
|
256
|
+
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
|
257
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
258
|
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
|
|
259
|
+
];
|
|
260
|
+
var WINDOW_CONTAINER_DIR = "window";
|
|
261
|
+
var WINDOW_SPECS = [
|
|
262
|
+
{
|
|
263
|
+
id: "h264-shortgop-bframes",
|
|
264
|
+
codec: "h264",
|
|
265
|
+
width: 1280,
|
|
266
|
+
height: 720,
|
|
267
|
+
fps: 25,
|
|
268
|
+
durationSeconds: 6,
|
|
269
|
+
gopFrames: 25,
|
|
270
|
+
gopMode: "short",
|
|
271
|
+
bframes: 3,
|
|
272
|
+
startPtsSeconds: 5,
|
|
273
|
+
sparse: false,
|
|
274
|
+
moovTrailing: true,
|
|
275
|
+
delayedTail: true,
|
|
276
|
+
usageP3: "\u77ED GOP \u6709 B \u5E27\u57FA\u7EBF\uFF1A\u7A97\u53E3\u9010 GOP \u524D\u8FDB\u65F6\u7684\u89E3\u7801\u8D77\u70B9\u5BF9\u9F50",
|
|
277
|
+
usageP5: "\u591A\u8DEF\u5E76\u53D1\u4E0B\u7684\u5E38\u89C4\u89E3\u7801\u8D1F\u8F7D\u57FA\u7EBF"
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: "h264-shortgop-nobframes",
|
|
281
|
+
codec: "h264",
|
|
282
|
+
width: 1280,
|
|
283
|
+
height: 720,
|
|
284
|
+
fps: 25,
|
|
285
|
+
durationSeconds: 6,
|
|
286
|
+
gopFrames: 25,
|
|
287
|
+
gopMode: "short",
|
|
288
|
+
bframes: 0,
|
|
289
|
+
startPtsSeconds: 5,
|
|
290
|
+
sparse: false,
|
|
291
|
+
moovTrailing: true,
|
|
292
|
+
delayedTail: false,
|
|
293
|
+
usageP3: "\u65E0 B \u5E27\u5BF9\u7167\uFF1A\u5E27\u8F93\u51FA\u65E0\u91CD\u6392\u5EF6\u8FDF\uFF0C\u6821\u9A8C PTS \u5355\u8C03\u6027",
|
|
294
|
+
usageP5: "\u4F4E\u5EF6\u8FDF\u94FE\u8DEF\u7684\u5BB9\u91CF\u5BF9\u7167\u7EC4"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: "hevc-shortgop-bframes",
|
|
298
|
+
codec: "hevc",
|
|
299
|
+
width: 1280,
|
|
300
|
+
height: 720,
|
|
301
|
+
fps: 25,
|
|
302
|
+
durationSeconds: 6,
|
|
303
|
+
gopFrames: 25,
|
|
304
|
+
gopMode: "short",
|
|
305
|
+
bframes: 3,
|
|
306
|
+
startPtsSeconds: 5,
|
|
307
|
+
sparse: false,
|
|
308
|
+
moovTrailing: true,
|
|
309
|
+
delayedTail: true,
|
|
310
|
+
usageP3: "HEVC \u77ED GOP \u6709 B \u5E27\u57FA\u7EBF\uFF1Ahvc1 \u5C01\u88C5\u4E0B\u7A97\u53E3\u63A8\u8FDB",
|
|
311
|
+
usageP5: "HEVC \u8F6F\u89E3 / \u786C\u89E3\u5BB9\u91CF\u57FA\u7EBF"
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: "hevc-shortgop-nobframes",
|
|
315
|
+
codec: "hevc",
|
|
316
|
+
width: 1280,
|
|
317
|
+
height: 720,
|
|
318
|
+
fps: 25,
|
|
319
|
+
durationSeconds: 6,
|
|
320
|
+
gopFrames: 25,
|
|
321
|
+
gopMode: "short",
|
|
322
|
+
bframes: 0,
|
|
323
|
+
startPtsSeconds: 5,
|
|
324
|
+
sparse: false,
|
|
325
|
+
moovTrailing: true,
|
|
326
|
+
delayedTail: false,
|
|
327
|
+
usageP3: "HEVC \u65E0 B \u5E27\u5BF9\u7167\uFF1A\u91CD\u6392\u5173\u95ED\u65F6\u7684\u7A97\u53E3\u884C\u4E3A",
|
|
328
|
+
usageP5: "HEVC \u4F4E\u5EF6\u8FDF\u5BB9\u91CF\u5BF9\u7167\u7EC4"
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
id: "h264-longgop-bframes",
|
|
332
|
+
codec: "h264",
|
|
333
|
+
width: 1280,
|
|
334
|
+
height: 720,
|
|
335
|
+
fps: 25,
|
|
336
|
+
durationSeconds: 10,
|
|
337
|
+
gopFrames: 125,
|
|
338
|
+
gopMode: "long",
|
|
339
|
+
bframes: 3,
|
|
340
|
+
startPtsSeconds: 10,
|
|
341
|
+
sparse: false,
|
|
342
|
+
moovTrailing: true,
|
|
343
|
+
delayedTail: true,
|
|
344
|
+
usageP3: "\u957F GOP\uFF085s\uFF09\u8DE8\u7A97 seek\uFF1A\u7A97\u53E3\u843D\u5728 GOP \u4E2D\u90E8\u65F6\u7684\u56DE\u8DF3\u8DDD\u79BB",
|
|
345
|
+
usageP5: "\u957F GOP \u4F4E\u7801\u7387\u6863\u7684\u89E3\u7801\u5BB9\u91CF"
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: "hevc-longgop-bframes",
|
|
349
|
+
codec: "hevc",
|
|
350
|
+
width: 1280,
|
|
351
|
+
height: 720,
|
|
352
|
+
fps: 25,
|
|
353
|
+
durationSeconds: 10,
|
|
354
|
+
gopFrames: 125,
|
|
355
|
+
gopMode: "long",
|
|
356
|
+
bframes: 3,
|
|
357
|
+
startPtsSeconds: 10,
|
|
358
|
+
sparse: false,
|
|
359
|
+
moovTrailing: true,
|
|
360
|
+
delayedTail: true,
|
|
361
|
+
usageP3: "HEVC \u957F GOP \u8DE8\u7A97 seek \u5BF9\u7167",
|
|
362
|
+
usageP5: "HEVC \u957F GOP \u5BB9\u91CF\u6863"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
id: "h264-singlegop",
|
|
366
|
+
codec: "h264",
|
|
367
|
+
width: 1280,
|
|
368
|
+
height: 720,
|
|
369
|
+
fps: 25,
|
|
370
|
+
durationSeconds: 8,
|
|
371
|
+
gopFrames: 200,
|
|
372
|
+
gopMode: "single",
|
|
373
|
+
bframes: 3,
|
|
374
|
+
startPtsSeconds: 20,
|
|
375
|
+
sparse: false,
|
|
376
|
+
moovTrailing: true,
|
|
377
|
+
delayedTail: true,
|
|
378
|
+
usageP3: "\u5355 GOP \u6781\u9650\uFF1A\u7A97\u53E3\u4EFB\u610F\u70B9 seek \u5FC5\u987B\u56DE\u8DF3\u5230\u6BB5\u9996 IDR",
|
|
379
|
+
usageP5: "\u5355 GOP \u6781\u4F4E\u7801\u7387\u6863\u5BB9\u91CF"
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
id: "hevc-singlegop",
|
|
383
|
+
codec: "hevc",
|
|
384
|
+
width: 1280,
|
|
385
|
+
height: 720,
|
|
386
|
+
fps: 25,
|
|
387
|
+
durationSeconds: 8,
|
|
388
|
+
gopFrames: 200,
|
|
389
|
+
gopMode: "single",
|
|
390
|
+
bframes: 3,
|
|
391
|
+
startPtsSeconds: 20,
|
|
392
|
+
sparse: false,
|
|
393
|
+
moovTrailing: true,
|
|
394
|
+
delayedTail: true,
|
|
395
|
+
usageP3: "HEVC \u5355 GOP \u6781\u9650\u5BF9\u7167",
|
|
396
|
+
usageP5: "HEVC \u5355 GOP \u5BB9\u91CF\u6863"
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: "h264-sparse-1fps",
|
|
400
|
+
codec: "h264",
|
|
401
|
+
width: 1280,
|
|
402
|
+
height: 720,
|
|
403
|
+
fps: 1,
|
|
404
|
+
durationSeconds: 8,
|
|
405
|
+
gopFrames: 4,
|
|
406
|
+
gopMode: "short",
|
|
407
|
+
bframes: 0,
|
|
408
|
+
startPtsSeconds: 30,
|
|
409
|
+
sparse: true,
|
|
410
|
+
moovTrailing: true,
|
|
411
|
+
delayedTail: false,
|
|
412
|
+
usageP3: "1fps \u7A00\u758F\u6D41\uFF1A\u7A97\u53E3\u6309\u79D2\u6B65\u8FDB\u65F6\u7684\u5E27\u53F7 / \u5A92\u4F53\u65F6\u95F4\u5BF9\u5E94",
|
|
413
|
+
usageP5: "\u7A00\u758F\u6D41\u6781\u4F4E\u5E27\u7387\u5BB9\u91CF\uFF08\u7F29\u7565\u56FE\u5F0F\u62C9\u6D41\uFF09"
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
id: "hevc-sparse-1fps",
|
|
417
|
+
codec: "hevc",
|
|
418
|
+
width: 1280,
|
|
419
|
+
height: 720,
|
|
420
|
+
fps: 1,
|
|
421
|
+
durationSeconds: 8,
|
|
422
|
+
gopFrames: 4,
|
|
423
|
+
gopMode: "short",
|
|
424
|
+
bframes: 0,
|
|
425
|
+
startPtsSeconds: 30,
|
|
426
|
+
sparse: true,
|
|
427
|
+
moovTrailing: true,
|
|
428
|
+
delayedTail: false,
|
|
429
|
+
usageP3: "HEVC 1fps \u7A00\u758F\u5BF9\u7167",
|
|
430
|
+
usageP5: "HEVC \u7A00\u758F\u6D41\u5BB9\u91CF\u6863"
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
id: "h264-gap-a",
|
|
434
|
+
codec: "h264",
|
|
435
|
+
width: 1280,
|
|
436
|
+
height: 720,
|
|
437
|
+
fps: 25,
|
|
438
|
+
durationSeconds: 6,
|
|
439
|
+
gopFrames: 25,
|
|
440
|
+
gopMode: "short",
|
|
441
|
+
bframes: 0,
|
|
442
|
+
startPtsSeconds: 100,
|
|
443
|
+
sparse: false,
|
|
444
|
+
moovTrailing: true,
|
|
445
|
+
delayedTail: false,
|
|
446
|
+
pairKind: "gap",
|
|
447
|
+
pairRole: "a",
|
|
448
|
+
usageP3: "gap \u53CC\u6BB5\u9996\u6BB5\uFF1A\u4E0E gap-b \u4E4B\u95F4\u7559 2s \u7A7A\u6D1E\uFF0C\u6D4B\u7A97\u53E3\u7A7A\u6D1E\u5C55\u793A",
|
|
449
|
+
usageP5: "\u65AD\u6D41\u7A7A\u6D1E\u573A\u666F\u7684\u5BB9\u91CF\u5360\u6BD4\u6A21\u578B\u8F93\u5165"
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: "h264-gap-b",
|
|
453
|
+
codec: "h264",
|
|
454
|
+
width: 1280,
|
|
455
|
+
height: 720,
|
|
456
|
+
fps: 25,
|
|
457
|
+
durationSeconds: 6,
|
|
458
|
+
gopFrames: 25,
|
|
459
|
+
gopMode: "short",
|
|
460
|
+
bframes: 0,
|
|
461
|
+
startPtsSeconds: 108,
|
|
462
|
+
sparse: false,
|
|
463
|
+
moovTrailing: true,
|
|
464
|
+
delayedTail: false,
|
|
465
|
+
pairKind: "gap",
|
|
466
|
+
pairRole: "b",
|
|
467
|
+
usageP3: "gap \u53CC\u6BB5\u6B21\u6BB5\uFF1A\u9996 PTS \u665A\u4E8E\u9996\u6BB5\u5C3E 2s\uFF0C\u7A97\u53E3\u5E94\u5448\u73B0\u7A7A\u6D1E",
|
|
468
|
+
usageP5: "\u540C gap-a \u914D\u5BF9\u4F7F\u7528"
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: "h264-overlap-a",
|
|
472
|
+
codec: "h264",
|
|
473
|
+
width: 1280,
|
|
474
|
+
height: 720,
|
|
475
|
+
fps: 25,
|
|
476
|
+
durationSeconds: 6,
|
|
477
|
+
gopFrames: 25,
|
|
478
|
+
gopMode: "short",
|
|
479
|
+
bframes: 0,
|
|
480
|
+
startPtsSeconds: 200,
|
|
481
|
+
sparse: false,
|
|
482
|
+
moovTrailing: true,
|
|
483
|
+
delayedTail: false,
|
|
484
|
+
pairKind: "overlap",
|
|
485
|
+
pairRole: "a",
|
|
486
|
+
usageP3: "overlap \u53CC\u6BB5\u9996\u6BB5\uFF1A\u4E0E overlap-b \u91CD\u53E0 2s\uFF0C\u6D4B\u7A97\u53E3\u53BB\u91CD",
|
|
487
|
+
usageP5: "\u91CD\u53E0\u5F55\u50CF\u573A\u666F\u7684\u5BB9\u91CF\u5360\u6BD4\u6A21\u578B\u8F93\u5165"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
id: "h264-overlap-b",
|
|
491
|
+
codec: "h264",
|
|
492
|
+
width: 1280,
|
|
493
|
+
height: 720,
|
|
494
|
+
fps: 25,
|
|
495
|
+
durationSeconds: 6,
|
|
496
|
+
gopFrames: 25,
|
|
497
|
+
gopMode: "short",
|
|
498
|
+
bframes: 0,
|
|
499
|
+
startPtsSeconds: 204,
|
|
500
|
+
sparse: false,
|
|
501
|
+
moovTrailing: true,
|
|
502
|
+
delayedTail: false,
|
|
503
|
+
pairKind: "overlap",
|
|
504
|
+
pairRole: "b",
|
|
505
|
+
usageP3: "overlap \u53CC\u6BB5\u6B21\u6BB5\uFF1A\u9996 PTS \u65E9\u4E8E\u9996\u6BB5\u5C3E 2s\uFF0C\u7A97\u53E3\u5E94\u53BB\u91CD\u5408\u5E76",
|
|
506
|
+
usageP5: "\u540C overlap-a \u914D\u5BF9\u4F7F\u7528"
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
id: "h264-size-720p",
|
|
510
|
+
codec: "h264",
|
|
511
|
+
width: 1280,
|
|
512
|
+
height: 720,
|
|
513
|
+
fps: 25,
|
|
514
|
+
durationSeconds: 6,
|
|
515
|
+
gopFrames: 25,
|
|
516
|
+
gopMode: "short",
|
|
517
|
+
bframes: 0,
|
|
518
|
+
startPtsSeconds: 300,
|
|
519
|
+
sparse: false,
|
|
520
|
+
moovTrailing: true,
|
|
521
|
+
delayedTail: false,
|
|
522
|
+
pairKind: "sizeSwitch",
|
|
523
|
+
pairRole: "a",
|
|
524
|
+
usageP3: "\u5C3A\u5BF8\u5207\u6362\u9996\u6BB5\uFF08720p high\uFF09\uFF1A\u4E0E 360p \u6BB5\u540C\u8D77\u70B9\uFF0C\u6D4B\u7A97\u53E3\u5185\u5206\u8FA8\u7387\u5207\u6362",
|
|
525
|
+
usageP5: "\u6E05\u6670\u5EA6\u5207\u6362\u65F6\u7684\u77AC\u65F6\u89E3\u7801\u5CF0\u503C"
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
id: "h264-size-360p",
|
|
529
|
+
codec: "h264",
|
|
530
|
+
width: 640,
|
|
531
|
+
height: 360,
|
|
532
|
+
fps: 25,
|
|
533
|
+
durationSeconds: 6,
|
|
534
|
+
gopFrames: 25,
|
|
535
|
+
gopMode: "short",
|
|
536
|
+
bframes: 0,
|
|
537
|
+
startPtsSeconds: 300,
|
|
538
|
+
sparse: false,
|
|
539
|
+
moovTrailing: true,
|
|
540
|
+
delayedTail: false,
|
|
541
|
+
pairKind: "sizeSwitch",
|
|
542
|
+
pairRole: "b",
|
|
543
|
+
usageP3: "\u5C3A\u5BF8\u5207\u6362\u6B21\u6BB5\uFF08360p baseline\uFF09\uFF1A\u7F16\u7801\u914D\u7F6E\u4E0E\u5206\u8FA8\u7387\u540C\u65F6\u53D8\u5316",
|
|
544
|
+
usageP5: "\u540C size-720p \u914D\u5BF9\u4F7F\u7528"
|
|
545
|
+
}
|
|
546
|
+
];
|
|
547
|
+
function drawtextFilter(fontfile) {
|
|
548
|
+
return `drawtext=fontfile=${fontfile}:text='n=%{frame_num} t=%{pts\\:hms}':fontsize=28:fontcolor=white:box=1:boxcolor=black@0.6:x=16:y=16`;
|
|
549
|
+
}
|
|
550
|
+
function buildWindowPlan(spec, opts) {
|
|
551
|
+
const useDrawtext = opts?.useDrawtext ?? true;
|
|
552
|
+
const fontfile = opts?.fontfile ?? DEFAULT_DRAWTEXT_FONT;
|
|
553
|
+
const specCodec = CODEC_SPEC[spec.codec];
|
|
554
|
+
const videoInput = `testsrc2=size=${spec.width}x${spec.height}:rate=${spec.fps}`;
|
|
555
|
+
const audioInput = `sine=frequency=1000:sample_rate=44100`;
|
|
556
|
+
const videoFilter = useDrawtext ? `format=yuv420p,${drawtextFilter(fontfile)}` : "format=yuv420p";
|
|
557
|
+
const outputFps = spec.sparse ? 1 : spec.fps;
|
|
558
|
+
const sizeSwitchBaseline = spec.pairKind === "sizeSwitch" && spec.pairRole === "b" ? ["-profile:v", "baseline"] : specCodec.videoArgs;
|
|
559
|
+
const ffmpegOutputArgs = [
|
|
560
|
+
"-y",
|
|
561
|
+
"-f",
|
|
562
|
+
"lavfi",
|
|
563
|
+
"-i",
|
|
564
|
+
videoInput,
|
|
565
|
+
"-f",
|
|
566
|
+
"lavfi",
|
|
567
|
+
"-i",
|
|
568
|
+
audioInput,
|
|
569
|
+
"-t",
|
|
570
|
+
String(spec.durationSeconds),
|
|
571
|
+
"-vf",
|
|
572
|
+
videoFilter,
|
|
573
|
+
"-r",
|
|
574
|
+
String(outputFps),
|
|
575
|
+
"-c:v",
|
|
576
|
+
specCodec.encoder,
|
|
577
|
+
...sizeSwitchBaseline,
|
|
578
|
+
"-pix_fmt",
|
|
579
|
+
specCodec.pixelFormat,
|
|
580
|
+
"-bf",
|
|
581
|
+
String(spec.bframes),
|
|
582
|
+
"-g",
|
|
583
|
+
String(spec.gopFrames),
|
|
584
|
+
"-keyint_min",
|
|
585
|
+
String(spec.gopFrames),
|
|
586
|
+
"-sc_threshold",
|
|
587
|
+
"0",
|
|
588
|
+
...containerExtraArgs("mp4", spec.codec),
|
|
589
|
+
"-c:a",
|
|
590
|
+
"aac",
|
|
591
|
+
"-b:a",
|
|
592
|
+
"128k",
|
|
593
|
+
"-output_ts_offset",
|
|
594
|
+
String(spec.startPtsSeconds),
|
|
595
|
+
"-f",
|
|
596
|
+
"mp4",
|
|
597
|
+
"-movflags",
|
|
598
|
+
"-faststart"
|
|
599
|
+
];
|
|
600
|
+
const mainFile = join3(WINDOW_CONTAINER_DIR, `${spec.id}.mp4`);
|
|
601
|
+
const encode = {
|
|
602
|
+
codec: spec.codec,
|
|
603
|
+
gopFrames: spec.gopFrames,
|
|
604
|
+
gopMode: spec.gopMode,
|
|
605
|
+
bframes: spec.bframes,
|
|
606
|
+
startPtsSeconds: spec.startPtsSeconds,
|
|
607
|
+
sparse: spec.sparse,
|
|
608
|
+
moov: "trailing",
|
|
609
|
+
delayedTail: spec.delayedTail,
|
|
610
|
+
width: spec.width,
|
|
611
|
+
height: spec.height,
|
|
612
|
+
fps: outputFps
|
|
613
|
+
};
|
|
614
|
+
return { id: spec.id, spec, mainFile, files: [mainFile], ffmpegOutputArgs, encode };
|
|
615
|
+
}
|
|
616
|
+
function expandWindowMatrix(opts) {
|
|
617
|
+
return WINDOW_SPECS.map((spec) => buildWindowPlan(spec, opts));
|
|
618
|
+
}
|
|
619
|
+
|
|
250
620
|
// src/server/server.ts
|
|
251
621
|
import { createReadStream, readFileSync as readFileSync3 } from "node:fs";
|
|
252
622
|
import {
|
|
@@ -321,7 +691,7 @@ function setRangeHeaders(req, res, fullPath, mime) {
|
|
|
321
691
|
|
|
322
692
|
// src/server/routes.ts
|
|
323
693
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
324
|
-
import { join as
|
|
694
|
+
import { join as join4 } from "node:path";
|
|
325
695
|
var FORMAT_SET = Object.fromEntries(ALL_FORMATS.map((f) => [f, true]));
|
|
326
696
|
var CODEC_SET = Object.fromEntries(ALL_CODECS.map((c) => [c, true]));
|
|
327
697
|
var HLS_FMP4 = { hls: true, fmp4: true };
|
|
@@ -337,23 +707,23 @@ function resolveRoute(path) {
|
|
|
337
707
|
if (HLS_FMP4[formatRaw]) return null;
|
|
338
708
|
const format = formatRaw;
|
|
339
709
|
const codec = codecRaw;
|
|
340
|
-
return
|
|
710
|
+
return join4(format, `${codec}.${format}`);
|
|
341
711
|
}
|
|
342
712
|
const playlist = PLAYLIST.exec(path);
|
|
343
713
|
if (playlist) {
|
|
344
714
|
const [, formatRaw, codecRaw, rest] = playlist;
|
|
345
715
|
if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
|
|
346
|
-
return
|
|
716
|
+
return join4(formatRaw, codecRaw, rest);
|
|
347
717
|
}
|
|
348
718
|
const vod = VOD.exec(path);
|
|
349
719
|
if (vod) {
|
|
350
720
|
const [, name] = vod;
|
|
351
|
-
return
|
|
721
|
+
return join4("vod", name);
|
|
352
722
|
}
|
|
353
723
|
const vodHevc = VOD_HEVC.exec(path);
|
|
354
724
|
if (vodHevc) {
|
|
355
725
|
const [, name] = vodHevc;
|
|
356
|
-
return
|
|
726
|
+
return join4("vod-hevc", name);
|
|
357
727
|
}
|
|
358
728
|
return null;
|
|
359
729
|
}
|
|
@@ -376,7 +746,7 @@ function handleList(res, manifest) {
|
|
|
376
746
|
writeJson(res, 200, manifest);
|
|
377
747
|
}
|
|
378
748
|
function handleVodList(res, manifest, fixtureDir, codec = "h264") {
|
|
379
|
-
const vodListPath = codec === "hevc" ?
|
|
749
|
+
const vodListPath = codec === "hevc" ? join4(fixtureDir, "vod-hevc", "vod-list.json") : join4(fixtureDir, "vod", "vod-list.json");
|
|
380
750
|
if (existsSync2(vodListPath)) {
|
|
381
751
|
try {
|
|
382
752
|
const raw = JSON.parse(readFileSync2(vodListPath, "utf8"));
|
|
@@ -448,9 +818,15 @@ function parseChannelCount(raw) {
|
|
|
448
818
|
if (!Number.isFinite(value) || value < 1) return MULTI_VOD_DEFAULT_CHANNELS;
|
|
449
819
|
return Math.min(MULTI_VOD_MAX_CHANNELS, Math.floor(value));
|
|
450
820
|
}
|
|
821
|
+
function parseBoundedQueryInt(raw, min, max) {
|
|
822
|
+
if (raw == null || raw === "") return null;
|
|
823
|
+
const value = Number(raw);
|
|
824
|
+
if (!Number.isInteger(value) || value < min || value > max) return null;
|
|
825
|
+
return value;
|
|
826
|
+
}
|
|
451
827
|
function handleVodListMulti(res, manifest, fixtureDir, channels) {
|
|
452
828
|
const entriesSource = (() => {
|
|
453
|
-
const vodListPath =
|
|
829
|
+
const vodListPath = join4(fixtureDir, "vod", "vod-list.json");
|
|
454
830
|
if (existsSync2(vodListPath)) {
|
|
455
831
|
try {
|
|
456
832
|
const raw = JSON.parse(readFileSync2(vodListPath, "utf8"));
|
|
@@ -745,7 +1121,13 @@ function createStreamsServer(options) {
|
|
|
745
1121
|
}
|
|
746
1122
|
const liveMatch = matchLivePath(path);
|
|
747
1123
|
if (liveMatch) {
|
|
748
|
-
|
|
1124
|
+
const liveQuery = (req.url ?? "").split("?", 2)[1] ?? "";
|
|
1125
|
+
const liveParams = new URLSearchParams(liveQuery);
|
|
1126
|
+
handleLiveStream(req, res, liveMatch.format, liveMatch.codec, {
|
|
1127
|
+
width: parseBoundedQueryInt(liveParams.get("width"), 160, 3840) ?? void 0,
|
|
1128
|
+
height: parseBoundedQueryInt(liveParams.get("height"), 160, 3840) ?? void 0,
|
|
1129
|
+
fps: parseBoundedQueryInt(liveParams.get("fps"), 1, 60) ?? void 0
|
|
1130
|
+
});
|
|
749
1131
|
return;
|
|
750
1132
|
}
|
|
751
1133
|
writeNotFound(res);
|
|
@@ -832,7 +1214,7 @@ import {
|
|
|
832
1214
|
writeFileSync
|
|
833
1215
|
} from "node:fs";
|
|
834
1216
|
import { spawn as spawn2 } from "node:child_process";
|
|
835
|
-
import { join as
|
|
1217
|
+
import { join as join5, resolve as resolve3 } from "node:path";
|
|
836
1218
|
var FFMPEG_PROBE_ARGS = ["-version"];
|
|
837
1219
|
async function generateFixtures(opts = {}) {
|
|
838
1220
|
const ffmpegPath = opts.ffmpegPath ?? await findFfmpeg();
|
|
@@ -873,26 +1255,39 @@ async function generateFixtures(opts = {}) {
|
|
|
873
1255
|
}
|
|
874
1256
|
}
|
|
875
1257
|
copyVodSamples(fixtureDir);
|
|
1258
|
+
let windowFixtures = [];
|
|
1259
|
+
if (opts.includeWindow !== false) {
|
|
1260
|
+
const windowResult = await generateWindowSet(
|
|
1261
|
+
fixtureDir,
|
|
1262
|
+
ffmpegPath,
|
|
1263
|
+
opts.quiet ?? false,
|
|
1264
|
+
opts.overwrite ?? false
|
|
1265
|
+
);
|
|
1266
|
+
windowFixtures = windowResult.items;
|
|
1267
|
+
generated += windowResult.generated;
|
|
1268
|
+
skipped += windowResult.skipped;
|
|
1269
|
+
}
|
|
876
1270
|
const manifest = {
|
|
877
1271
|
version: 1,
|
|
878
1272
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
879
1273
|
durationSeconds: duration,
|
|
880
|
-
fixtures: manifestItems
|
|
1274
|
+
fixtures: manifestItems,
|
|
1275
|
+
windowFixtures
|
|
881
1276
|
};
|
|
882
|
-
writeFileSync(
|
|
1277
|
+
writeFileSync(join5(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
883
1278
|
return { fixtureDir, manifest, generated, skipped, ffmpegPath, ffmpegMissing: false };
|
|
884
1279
|
}
|
|
885
1280
|
async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
|
|
886
|
-
const outDir =
|
|
1281
|
+
const outDir = join5(fixtureDir, plan.containerDir);
|
|
887
1282
|
mkdirSync(outDir, { recursive: true });
|
|
888
|
-
const outFile =
|
|
1283
|
+
const outFile = join5(fixtureDir, plan.mainFile);
|
|
889
1284
|
if (!overwrite && existsSync3(outFile)) {
|
|
890
1285
|
return collectExisting(plan, fixtureDir);
|
|
891
1286
|
}
|
|
892
1287
|
if (plan.layout.isContainerDirectory) {
|
|
893
1288
|
for (const f of readdirSync(outDir)) {
|
|
894
1289
|
if (f === plan.layout.mainFileName) continue;
|
|
895
|
-
rmIfExists(
|
|
1290
|
+
rmIfExists(join5(outDir, f));
|
|
896
1291
|
}
|
|
897
1292
|
}
|
|
898
1293
|
const args = plan.ffmpegOutputArgs.concat([outFile]);
|
|
@@ -906,21 +1301,89 @@ async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
|
|
|
906
1301
|
}
|
|
907
1302
|
return collectExisting(plan, fixtureDir);
|
|
908
1303
|
}
|
|
1304
|
+
async function generateWindowSet(fixtureDir, ffmpegPath, quiet, overwrite) {
|
|
1305
|
+
const outDir = join5(fixtureDir, WINDOW_CONTAINER_DIR);
|
|
1306
|
+
mkdirSync(outDir, { recursive: true });
|
|
1307
|
+
const useDrawtext = await supportsDrawtext(ffmpegPath);
|
|
1308
|
+
const fontfile = useDrawtext ? pickFontfile() : void 0;
|
|
1309
|
+
if (!useDrawtext && !quiet) {
|
|
1310
|
+
process.stdout.write(
|
|
1311
|
+
"[generate] drawtext \u6EE4\u955C\u7F3A\u5931\uFF0C\u70E7\u5F55\u7A97\u964D\u7EA7\u4E3A\u88F8 testsrc2\uFF08\u65E0\u70E7\u5F55\u6587\u5B57\uFF0C\u7F16\u7801\u53C2\u6570\u4E0D\u53D8\uFF1B\u5B8C\u6574\u547D\u4EE4\u89C1 README P3-H \u4E00\u8282\uFF09\n"
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
const items = [];
|
|
1315
|
+
let generated = 0;
|
|
1316
|
+
let skipped = 0;
|
|
1317
|
+
for (const spec of WINDOW_SPECS) {
|
|
1318
|
+
const plan = buildWindowPlan(spec, { useDrawtext, fontfile });
|
|
1319
|
+
const outFile = join5(fixtureDir, plan.mainFile);
|
|
1320
|
+
if (!overwrite && existsSync3(outFile)) {
|
|
1321
|
+
items.push(collectWindowExisting(plan, fixtureDir));
|
|
1322
|
+
skipped += 1;
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
if (!quiet) {
|
|
1326
|
+
process.stdout.write(`[generate] window/${plan.id} -> ${plan.mainFile}
|
|
1327
|
+
`);
|
|
1328
|
+
}
|
|
1329
|
+
const code = await runFfmpeg(ffmpegPath, plan.ffmpegOutputArgs.concat([outFile]), quiet);
|
|
1330
|
+
if (code !== 0) {
|
|
1331
|
+
throw new Error(`ffmpeg exited with code ${code} for window/${plan.id}`);
|
|
1332
|
+
}
|
|
1333
|
+
items.push(collectWindowExisting(plan, fixtureDir));
|
|
1334
|
+
generated += 1;
|
|
1335
|
+
}
|
|
1336
|
+
return { items, generated, skipped };
|
|
1337
|
+
}
|
|
1338
|
+
function collectWindowExisting(plan, fixtureDir) {
|
|
1339
|
+
const entries = [];
|
|
1340
|
+
for (const rel of plan.files) {
|
|
1341
|
+
const abs = join5(fixtureDir, rel);
|
|
1342
|
+
if (!existsSync3(abs)) continue;
|
|
1343
|
+
entries.push({ path: rel, size: statSync3(abs).size, sha256: hashFile(abs) });
|
|
1344
|
+
}
|
|
1345
|
+
return { id: plan.id, codec: plan.spec.codec, entries, encode: plan.encode };
|
|
1346
|
+
}
|
|
1347
|
+
async function supportsDrawtext(ffmpegPath) {
|
|
1348
|
+
const { promise, resolve: resolve4 } = Promise.withResolvers();
|
|
1349
|
+
const proc = spawn2(ffmpegPath, ["-hide_banner", "-filters"], {
|
|
1350
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1351
|
+
});
|
|
1352
|
+
let out = "";
|
|
1353
|
+
proc.stdout?.on("data", (chunk) => {
|
|
1354
|
+
out += chunk.toString("utf8");
|
|
1355
|
+
});
|
|
1356
|
+
proc.once("error", () => resolve4(false));
|
|
1357
|
+
proc.once("exit", (code) => {
|
|
1358
|
+
if (code !== 0) {
|
|
1359
|
+
resolve4(false);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
resolve4(/^\s*\S{2,3}\s+drawtext\s/m.test(out));
|
|
1363
|
+
});
|
|
1364
|
+
return promise;
|
|
1365
|
+
}
|
|
1366
|
+
function pickFontfile() {
|
|
1367
|
+
for (const candidate of DRAWTEXT_FONT_CANDIDATES) {
|
|
1368
|
+
if (existsSync3(candidate)) return candidate;
|
|
1369
|
+
}
|
|
1370
|
+
return DRAWTEXT_FONT_CANDIDATES[0] ?? "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
|
|
1371
|
+
}
|
|
909
1372
|
function collectExisting(plan, fixtureDir) {
|
|
910
1373
|
const entries = [];
|
|
911
1374
|
for (const rel of plan.files) {
|
|
912
|
-
const abs =
|
|
1375
|
+
const abs = join5(fixtureDir, rel);
|
|
913
1376
|
if (!existsSync3(abs)) continue;
|
|
914
1377
|
entries.push({ path: rel, size: statSync3(abs).size, sha256: hashFile(abs) });
|
|
915
1378
|
}
|
|
916
1379
|
if (plan.layout.isContainerDirectory) {
|
|
917
|
-
const absDir =
|
|
1380
|
+
const absDir = join5(fixtureDir, plan.containerDir);
|
|
918
1381
|
if (existsSync3(absDir)) {
|
|
919
1382
|
for (const name of readdirSync(absDir).sort()) {
|
|
920
1383
|
if (name === plan.layout.mainFileName) continue;
|
|
921
|
-
const rel =
|
|
1384
|
+
const rel = join5(plan.containerDir, name);
|
|
922
1385
|
if (entries.find((e) => e.path === rel)) continue;
|
|
923
|
-
const abs =
|
|
1386
|
+
const abs = join5(absDir, name);
|
|
924
1387
|
if (!statSync3(abs).isFile()) continue;
|
|
925
1388
|
entries.push({ path: rel, size: statSync3(abs).size, sha256: hashFile(abs) });
|
|
926
1389
|
}
|
|
@@ -929,12 +1392,12 @@ function collectExisting(plan, fixtureDir) {
|
|
|
929
1392
|
return { codec: plan.codec, format: plan.format, entries };
|
|
930
1393
|
}
|
|
931
1394
|
function copyVodSamples(fixtureDir) {
|
|
932
|
-
const src =
|
|
1395
|
+
const src = join5(fixtureDir, VOD_SAMPLE_RELATIVE);
|
|
933
1396
|
if (!existsSync3(src)) return;
|
|
934
|
-
const vodDir =
|
|
1397
|
+
const vodDir = join5(fixtureDir, "vod");
|
|
935
1398
|
mkdirSync(vodDir, { recursive: true });
|
|
936
1399
|
for (const name of ["sample-1.mp4", "sample-2.mp4"]) {
|
|
937
|
-
const dst =
|
|
1400
|
+
const dst = join5(vodDir, name);
|
|
938
1401
|
writeFileSync(dst, readFileSync4(src));
|
|
939
1402
|
}
|
|
940
1403
|
}
|
|
@@ -983,7 +1446,8 @@ function emptyManifest(duration) {
|
|
|
983
1446
|
version: 1,
|
|
984
1447
|
generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
985
1448
|
durationSeconds: duration,
|
|
986
|
-
fixtures: []
|
|
1449
|
+
fixtures: [],
|
|
1450
|
+
windowFixtures: []
|
|
987
1451
|
};
|
|
988
1452
|
}
|
|
989
1453
|
|
|
@@ -1004,17 +1468,24 @@ export {
|
|
|
1004
1468
|
ALL_CODECS,
|
|
1005
1469
|
ALL_FORMATS,
|
|
1006
1470
|
CODEC_SPEC,
|
|
1471
|
+
DEFAULT_DRAWTEXT_FONT,
|
|
1007
1472
|
DEFAULT_DURATION_SECONDS,
|
|
1008
1473
|
DEFAULT_FIXTURE_DIR,
|
|
1009
1474
|
DEFAULT_HLS_SEGMENT_SECONDS,
|
|
1010
1475
|
DEFAULT_HOST,
|
|
1011
1476
|
DEFAULT_PORT,
|
|
1477
|
+
DRAWTEXT_FONT_CANDIDATES,
|
|
1012
1478
|
VOD_SAMPLE_RELATIVE,
|
|
1479
|
+
WINDOW_CONTAINER_DIR,
|
|
1480
|
+
WINDOW_SPECS,
|
|
1013
1481
|
buildPlan,
|
|
1482
|
+
buildWindowPlan,
|
|
1014
1483
|
containerRelativeDir,
|
|
1015
1484
|
containerRelativePath,
|
|
1016
1485
|
createStreamsServer,
|
|
1486
|
+
drawtextFilter,
|
|
1017
1487
|
expandMatrix,
|
|
1488
|
+
expandWindowMatrix,
|
|
1018
1489
|
generateFixtures,
|
|
1019
1490
|
handleLiveStream,
|
|
1020
1491
|
layoutFor,
|
package/dist/server/routes.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export type MultiVodListResponse = VodListResponse & {
|
|
|
22
22
|
export declare const MULTI_VOD_MAX_CHANNELS = 9;
|
|
23
23
|
export declare const MULTI_VOD_DEFAULT_CHANNELS = 4;
|
|
24
24
|
export declare function parseChannelCount(raw: string | null): number;
|
|
25
|
+
/**
|
|
26
|
+
* live 流分辨率/帧率 query 解析:整数且落在 [min, max] 才采纳,否则返回 null
|
|
27
|
+
* (调用方回退默认值)。供 /streams/live/* 的 ?width/?height/?fps 使用。
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseBoundedQueryInt(raw: string | null, min: number, max: number): number | null;
|
|
25
30
|
/**
|
|
26
31
|
* /streams/vod-list-multi?channels=N:返回 N 个通道的录像段列表。
|
|
27
32
|
* 时间基准对齐当天上海时区 00:00(与 demo 时间轴的 24h 视野对齐);
|
package/dist/server/server.js
CHANGED
|
@@ -38,6 +38,7 @@ function summarizeCatalog(fixtureDir) {
|
|
|
38
38
|
}
|
|
39
39
|
let total = 0;
|
|
40
40
|
for (const item of manifest.fixtures) total += item.entries.length;
|
|
41
|
+
for (const item of manifest.windowFixtures ?? []) total += item.entries.length;
|
|
41
42
|
return {
|
|
42
43
|
codecs: ALL_CODECS,
|
|
43
44
|
formats: ALL_FORMATS,
|
|
@@ -239,6 +240,12 @@ function parseChannelCount(raw) {
|
|
|
239
240
|
if (!Number.isFinite(value) || value < 1) return MULTI_VOD_DEFAULT_CHANNELS;
|
|
240
241
|
return Math.min(MULTI_VOD_MAX_CHANNELS, Math.floor(value));
|
|
241
242
|
}
|
|
243
|
+
function parseBoundedQueryInt(raw, min, max) {
|
|
244
|
+
if (raw == null || raw === "") return null;
|
|
245
|
+
const value = Number(raw);
|
|
246
|
+
if (!Number.isInteger(value) || value < min || value > max) return null;
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
242
249
|
function handleVodListMulti(res, manifest, fixtureDir, channels) {
|
|
243
250
|
const entriesSource = (() => {
|
|
244
251
|
const vodListPath = join(fixtureDir, "vod", "vod-list.json");
|
|
@@ -558,7 +565,13 @@ function createStreamsServer(options) {
|
|
|
558
565
|
}
|
|
559
566
|
const liveMatch = matchLivePath(path);
|
|
560
567
|
if (liveMatch) {
|
|
561
|
-
|
|
568
|
+
const liveQuery = (req.url ?? "").split("?", 2)[1] ?? "";
|
|
569
|
+
const liveParams = new URLSearchParams(liveQuery);
|
|
570
|
+
handleLiveStream(req, res, liveMatch.format, liveMatch.codec, {
|
|
571
|
+
width: parseBoundedQueryInt(liveParams.get("width"), 160, 3840) ?? void 0,
|
|
572
|
+
height: parseBoundedQueryInt(liveParams.get("height"), 160, 3840) ?? void 0,
|
|
573
|
+
fps: parseBoundedQueryInt(liveParams.get("fps"), 1, 60) ?? void 0
|
|
574
|
+
});
|
|
562
575
|
return;
|
|
563
576
|
}
|
|
564
577
|
writeNotFound(res);
|