@flow-player/test-streams 0.14.2 → 0.15.0

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.
@@ -1,4 +1,5 @@
1
1
  import { type CodecId, type FormatId } from "../config.js";
2
+ import type { WindowEncodeParams } from "./window.js";
2
3
  /** manifest 内单条文件记录:相对 fixtureDir 的路径 + 字节数 + sha256。 */
3
4
  export type ManifestEntry = {
4
5
  path: string;
@@ -11,12 +12,29 @@ export type ManifestItem = {
11
12
  format: FormatId;
12
13
  entries: ReadonlyArray<ManifestEntry>;
13
14
  };
15
+ /**
16
+ * P3-H 烧录窗 fixture 的 manifest 条目:单 MP4 文件 + 编码参数。
17
+ * entries 复用 ManifestEntry 契约(size/sha256),encode 记录 GOP/B 帧/
18
+ * 首 PTS/稀疏等窗口测试关心的编码参数。
19
+ */
20
+ export type WindowManifestItem = {
21
+ /** 烧录窗 id(window/<id>.mp4 的文件名主体)。 */
22
+ id: string;
23
+ /** 视频编码。 */
24
+ codec: CodecId;
25
+ /** 单文件条目(仅主文件)。 */
26
+ entries: ReadonlyArray<ManifestEntry>;
27
+ /** 编码参数:GOP/B 帧/首 PTS/稀疏标记等。 */
28
+ encode: WindowEncodeParams;
29
+ };
14
30
  /** manifest.json 顶层结构:generate.ts 负责写,catalog 负责读 + 过滤。 */
15
31
  export type Manifest = {
16
32
  version: 1;
17
33
  generatedAt: string;
18
34
  durationSeconds: number;
19
35
  fixtures: ReadonlyArray<ManifestItem>;
36
+ /** P3-H 烧录窗序列(可选,老 manifest 缺省为空);只追加不改存量 fixtures。 */
37
+ windowFixtures?: ReadonlyArray<WindowManifestItem>;
20
38
  };
21
39
  /** lookupFixture 的查询结果:该组合的文件清单与主文件。 */
22
40
  export type FixtureLookup = {
@@ -37,6 +37,7 @@ function summarizeCatalog(fixtureDir) {
37
37
  }
38
38
  let total = 0;
39
39
  for (const item of manifest.fixtures) total += item.entries.length;
40
+ for (const item of manifest.windowFixtures ?? []) total += item.entries.length;
40
41
  return {
41
42
  codecs: ALL_CODECS,
42
43
  formats: ALL_FORMATS,
@@ -4,11 +4,11 @@ import { type Manifest } from "./catalog.js";
4
4
  export type GenerateResult = {
5
5
  /** fixture 根目录(绝对路径),manifest.json 写在其下。 */
6
6
  fixtureDir: string;
7
- /** 本次写盘的 manifest:每个 codec × format 组合一条,entries 带 size/sha256。 */
7
+ /** 本次写盘的 manifest:每个 codec × format 组合一条,entries 带 size/sha256;另附 windowFixtures 烧录窗序列。 */
8
8
  manifest: Manifest;
9
- /** 本次实际跑 ffmpeg 生成(或收集已有输出元信息)的组合数。 */
9
+ /** 本次实际跑 ffmpeg 生成(或收集已有输出元信息)的组合数(含烧录窗)。 */
10
10
  generated: number;
11
- /** 输出已存在且未开 overwrite 而跳过的组合数。 */
11
+ /** 输出已存在且未开 overwrite 而跳过的组合数(含烧录窗)。 */
12
12
  skipped: number;
13
13
  /** 实际使用的 ffmpeg 可执行路径。 */
14
14
  ffmpegPath: string;
@@ -21,6 +21,9 @@ export type GenerateResult = {
21
21
  * 写到 `fixtureDir/manifest.json`。输出已存在且未开 overwrite 的组合跳过生成,
22
22
  * 但仍从磁盘收集条目进 manifest;hls/fmp4 容器目录内 ffmpeg 实际产出的段文件
23
23
  * 一并收编。开头还会从 mp4/h264.mp4 复制出 vod/sample-*.mp4 样例。
24
+ * 矩阵之后按 WINDOW_SPECS 顺序生成 P3-H 烧录窗 MP4 序列(window/<id>.mp4,
25
+ * 帧号与媒体时间经 drawtext 烧录),条目进 manifest.windowFixtures;
26
+ * includeWindow=false 可跳过烧录窗(快速反馈用)。
24
27
  * 找不到 ffmpeg 时不创建目录、不写盘,返回 ffmpegMissing=true 的空结果。
25
28
  */
26
29
  export declare function generateFixtures(opts?: GenerateOptions): Promise<GenerateResult>;
@@ -10,7 +10,7 @@ import {
10
10
  writeFileSync
11
11
  } from "node:fs";
12
12
  import { spawn } from "node:child_process";
13
- import { join as join3, resolve } from "node:path";
13
+ import { join as join4, resolve } from "node:path";
14
14
 
15
15
  // src/config.ts
16
16
  var DEFAULT_FIXTURE_DIR = "fixtures";
@@ -202,6 +202,372 @@ function expandMatrix(opts) {
202
202
  }
203
203
  var VOD_SAMPLE_RELATIVE = "mp4/h264.mp4";
204
204
 
205
+ // src/fixtures/window.ts
206
+ import { join as join3 } from "node:path";
207
+ var DEFAULT_DRAWTEXT_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
208
+ var DRAWTEXT_FONT_CANDIDATES = [
209
+ "/System/Library/Fonts/Supplemental/Verdana.ttf",
210
+ "/System/Library/Fonts/Hiragino Sans GB.ttc",
211
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
212
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf"
213
+ ];
214
+ var WINDOW_CONTAINER_DIR = "window";
215
+ var WINDOW_SPECS = [
216
+ {
217
+ id: "h264-shortgop-bframes",
218
+ codec: "h264",
219
+ width: 1280,
220
+ height: 720,
221
+ fps: 25,
222
+ durationSeconds: 6,
223
+ gopFrames: 25,
224
+ gopMode: "short",
225
+ bframes: 3,
226
+ startPtsSeconds: 5,
227
+ sparse: false,
228
+ moovTrailing: true,
229
+ delayedTail: true,
230
+ usageP3: "\u77ED GOP \u6709 B \u5E27\u57FA\u7EBF\uFF1A\u7A97\u53E3\u9010 GOP \u524D\u8FDB\u65F6\u7684\u89E3\u7801\u8D77\u70B9\u5BF9\u9F50",
231
+ usageP5: "\u591A\u8DEF\u5E76\u53D1\u4E0B\u7684\u5E38\u89C4\u89E3\u7801\u8D1F\u8F7D\u57FA\u7EBF"
232
+ },
233
+ {
234
+ id: "h264-shortgop-nobframes",
235
+ codec: "h264",
236
+ width: 1280,
237
+ height: 720,
238
+ fps: 25,
239
+ durationSeconds: 6,
240
+ gopFrames: 25,
241
+ gopMode: "short",
242
+ bframes: 0,
243
+ startPtsSeconds: 5,
244
+ sparse: false,
245
+ moovTrailing: true,
246
+ delayedTail: false,
247
+ usageP3: "\u65E0 B \u5E27\u5BF9\u7167\uFF1A\u5E27\u8F93\u51FA\u65E0\u91CD\u6392\u5EF6\u8FDF\uFF0C\u6821\u9A8C PTS \u5355\u8C03\u6027",
248
+ usageP5: "\u4F4E\u5EF6\u8FDF\u94FE\u8DEF\u7684\u5BB9\u91CF\u5BF9\u7167\u7EC4"
249
+ },
250
+ {
251
+ id: "hevc-shortgop-bframes",
252
+ codec: "hevc",
253
+ width: 1280,
254
+ height: 720,
255
+ fps: 25,
256
+ durationSeconds: 6,
257
+ gopFrames: 25,
258
+ gopMode: "short",
259
+ bframes: 3,
260
+ startPtsSeconds: 5,
261
+ sparse: false,
262
+ moovTrailing: true,
263
+ delayedTail: true,
264
+ usageP3: "HEVC \u77ED GOP \u6709 B \u5E27\u57FA\u7EBF\uFF1Ahvc1 \u5C01\u88C5\u4E0B\u7A97\u53E3\u63A8\u8FDB",
265
+ usageP5: "HEVC \u8F6F\u89E3 / \u786C\u89E3\u5BB9\u91CF\u57FA\u7EBF"
266
+ },
267
+ {
268
+ id: "hevc-shortgop-nobframes",
269
+ codec: "hevc",
270
+ width: 1280,
271
+ height: 720,
272
+ fps: 25,
273
+ durationSeconds: 6,
274
+ gopFrames: 25,
275
+ gopMode: "short",
276
+ bframes: 0,
277
+ startPtsSeconds: 5,
278
+ sparse: false,
279
+ moovTrailing: true,
280
+ delayedTail: false,
281
+ usageP3: "HEVC \u65E0 B \u5E27\u5BF9\u7167\uFF1A\u91CD\u6392\u5173\u95ED\u65F6\u7684\u7A97\u53E3\u884C\u4E3A",
282
+ usageP5: "HEVC \u4F4E\u5EF6\u8FDF\u5BB9\u91CF\u5BF9\u7167\u7EC4"
283
+ },
284
+ {
285
+ id: "h264-longgop-bframes",
286
+ codec: "h264",
287
+ width: 1280,
288
+ height: 720,
289
+ fps: 25,
290
+ durationSeconds: 10,
291
+ gopFrames: 125,
292
+ gopMode: "long",
293
+ bframes: 3,
294
+ startPtsSeconds: 10,
295
+ sparse: false,
296
+ moovTrailing: true,
297
+ delayedTail: true,
298
+ usageP3: "\u957F GOP\uFF085s\uFF09\u8DE8\u7A97 seek\uFF1A\u7A97\u53E3\u843D\u5728 GOP \u4E2D\u90E8\u65F6\u7684\u56DE\u8DF3\u8DDD\u79BB",
299
+ usageP5: "\u957F GOP \u4F4E\u7801\u7387\u6863\u7684\u89E3\u7801\u5BB9\u91CF"
300
+ },
301
+ {
302
+ id: "hevc-longgop-bframes",
303
+ codec: "hevc",
304
+ width: 1280,
305
+ height: 720,
306
+ fps: 25,
307
+ durationSeconds: 10,
308
+ gopFrames: 125,
309
+ gopMode: "long",
310
+ bframes: 3,
311
+ startPtsSeconds: 10,
312
+ sparse: false,
313
+ moovTrailing: true,
314
+ delayedTail: true,
315
+ usageP3: "HEVC \u957F GOP \u8DE8\u7A97 seek \u5BF9\u7167",
316
+ usageP5: "HEVC \u957F GOP \u5BB9\u91CF\u6863"
317
+ },
318
+ {
319
+ id: "h264-singlegop",
320
+ codec: "h264",
321
+ width: 1280,
322
+ height: 720,
323
+ fps: 25,
324
+ durationSeconds: 8,
325
+ gopFrames: 200,
326
+ gopMode: "single",
327
+ bframes: 3,
328
+ startPtsSeconds: 20,
329
+ sparse: false,
330
+ moovTrailing: true,
331
+ delayedTail: true,
332
+ usageP3: "\u5355 GOP \u6781\u9650\uFF1A\u7A97\u53E3\u4EFB\u610F\u70B9 seek \u5FC5\u987B\u56DE\u8DF3\u5230\u6BB5\u9996 IDR",
333
+ usageP5: "\u5355 GOP \u6781\u4F4E\u7801\u7387\u6863\u5BB9\u91CF"
334
+ },
335
+ {
336
+ id: "hevc-singlegop",
337
+ codec: "hevc",
338
+ width: 1280,
339
+ height: 720,
340
+ fps: 25,
341
+ durationSeconds: 8,
342
+ gopFrames: 200,
343
+ gopMode: "single",
344
+ bframes: 3,
345
+ startPtsSeconds: 20,
346
+ sparse: false,
347
+ moovTrailing: true,
348
+ delayedTail: true,
349
+ usageP3: "HEVC \u5355 GOP \u6781\u9650\u5BF9\u7167",
350
+ usageP5: "HEVC \u5355 GOP \u5BB9\u91CF\u6863"
351
+ },
352
+ {
353
+ id: "h264-sparse-1fps",
354
+ codec: "h264",
355
+ width: 1280,
356
+ height: 720,
357
+ fps: 1,
358
+ durationSeconds: 8,
359
+ gopFrames: 4,
360
+ gopMode: "short",
361
+ bframes: 0,
362
+ startPtsSeconds: 30,
363
+ sparse: true,
364
+ moovTrailing: true,
365
+ delayedTail: false,
366
+ usageP3: "1fps \u7A00\u758F\u6D41\uFF1A\u7A97\u53E3\u6309\u79D2\u6B65\u8FDB\u65F6\u7684\u5E27\u53F7 / \u5A92\u4F53\u65F6\u95F4\u5BF9\u5E94",
367
+ usageP5: "\u7A00\u758F\u6D41\u6781\u4F4E\u5E27\u7387\u5BB9\u91CF\uFF08\u7F29\u7565\u56FE\u5F0F\u62C9\u6D41\uFF09"
368
+ },
369
+ {
370
+ id: "hevc-sparse-1fps",
371
+ codec: "hevc",
372
+ width: 1280,
373
+ height: 720,
374
+ fps: 1,
375
+ durationSeconds: 8,
376
+ gopFrames: 4,
377
+ gopMode: "short",
378
+ bframes: 0,
379
+ startPtsSeconds: 30,
380
+ sparse: true,
381
+ moovTrailing: true,
382
+ delayedTail: false,
383
+ usageP3: "HEVC 1fps \u7A00\u758F\u5BF9\u7167",
384
+ usageP5: "HEVC \u7A00\u758F\u6D41\u5BB9\u91CF\u6863"
385
+ },
386
+ {
387
+ id: "h264-gap-a",
388
+ codec: "h264",
389
+ width: 1280,
390
+ height: 720,
391
+ fps: 25,
392
+ durationSeconds: 6,
393
+ gopFrames: 25,
394
+ gopMode: "short",
395
+ bframes: 0,
396
+ startPtsSeconds: 100,
397
+ sparse: false,
398
+ moovTrailing: true,
399
+ delayedTail: false,
400
+ pairKind: "gap",
401
+ pairRole: "a",
402
+ usageP3: "gap \u53CC\u6BB5\u9996\u6BB5\uFF1A\u4E0E gap-b \u4E4B\u95F4\u7559 2s \u7A7A\u6D1E\uFF0C\u6D4B\u7A97\u53E3\u7A7A\u6D1E\u5C55\u793A",
403
+ usageP5: "\u65AD\u6D41\u7A7A\u6D1E\u573A\u666F\u7684\u5BB9\u91CF\u5360\u6BD4\u6A21\u578B\u8F93\u5165"
404
+ },
405
+ {
406
+ id: "h264-gap-b",
407
+ codec: "h264",
408
+ width: 1280,
409
+ height: 720,
410
+ fps: 25,
411
+ durationSeconds: 6,
412
+ gopFrames: 25,
413
+ gopMode: "short",
414
+ bframes: 0,
415
+ startPtsSeconds: 108,
416
+ sparse: false,
417
+ moovTrailing: true,
418
+ delayedTail: false,
419
+ pairKind: "gap",
420
+ pairRole: "b",
421
+ usageP3: "gap \u53CC\u6BB5\u6B21\u6BB5\uFF1A\u9996 PTS \u665A\u4E8E\u9996\u6BB5\u5C3E 2s\uFF0C\u7A97\u53E3\u5E94\u5448\u73B0\u7A7A\u6D1E",
422
+ usageP5: "\u540C gap-a \u914D\u5BF9\u4F7F\u7528"
423
+ },
424
+ {
425
+ id: "h264-overlap-a",
426
+ codec: "h264",
427
+ width: 1280,
428
+ height: 720,
429
+ fps: 25,
430
+ durationSeconds: 6,
431
+ gopFrames: 25,
432
+ gopMode: "short",
433
+ bframes: 0,
434
+ startPtsSeconds: 200,
435
+ sparse: false,
436
+ moovTrailing: true,
437
+ delayedTail: false,
438
+ pairKind: "overlap",
439
+ pairRole: "a",
440
+ usageP3: "overlap \u53CC\u6BB5\u9996\u6BB5\uFF1A\u4E0E overlap-b \u91CD\u53E0 2s\uFF0C\u6D4B\u7A97\u53E3\u53BB\u91CD",
441
+ usageP5: "\u91CD\u53E0\u5F55\u50CF\u573A\u666F\u7684\u5BB9\u91CF\u5360\u6BD4\u6A21\u578B\u8F93\u5165"
442
+ },
443
+ {
444
+ id: "h264-overlap-b",
445
+ codec: "h264",
446
+ width: 1280,
447
+ height: 720,
448
+ fps: 25,
449
+ durationSeconds: 6,
450
+ gopFrames: 25,
451
+ gopMode: "short",
452
+ bframes: 0,
453
+ startPtsSeconds: 204,
454
+ sparse: false,
455
+ moovTrailing: true,
456
+ delayedTail: false,
457
+ pairKind: "overlap",
458
+ pairRole: "b",
459
+ usageP3: "overlap \u53CC\u6BB5\u6B21\u6BB5\uFF1A\u9996 PTS \u65E9\u4E8E\u9996\u6BB5\u5C3E 2s\uFF0C\u7A97\u53E3\u5E94\u53BB\u91CD\u5408\u5E76",
460
+ usageP5: "\u540C overlap-a \u914D\u5BF9\u4F7F\u7528"
461
+ },
462
+ {
463
+ id: "h264-size-720p",
464
+ codec: "h264",
465
+ width: 1280,
466
+ height: 720,
467
+ fps: 25,
468
+ durationSeconds: 6,
469
+ gopFrames: 25,
470
+ gopMode: "short",
471
+ bframes: 0,
472
+ startPtsSeconds: 300,
473
+ sparse: false,
474
+ moovTrailing: true,
475
+ delayedTail: false,
476
+ pairKind: "sizeSwitch",
477
+ pairRole: "a",
478
+ 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",
479
+ usageP5: "\u6E05\u6670\u5EA6\u5207\u6362\u65F6\u7684\u77AC\u65F6\u89E3\u7801\u5CF0\u503C"
480
+ },
481
+ {
482
+ id: "h264-size-360p",
483
+ codec: "h264",
484
+ width: 640,
485
+ height: 360,
486
+ fps: 25,
487
+ durationSeconds: 6,
488
+ gopFrames: 25,
489
+ gopMode: "short",
490
+ bframes: 0,
491
+ startPtsSeconds: 300,
492
+ sparse: false,
493
+ moovTrailing: true,
494
+ delayedTail: false,
495
+ pairKind: "sizeSwitch",
496
+ pairRole: "b",
497
+ usageP3: "\u5C3A\u5BF8\u5207\u6362\u6B21\u6BB5\uFF08360p baseline\uFF09\uFF1A\u7F16\u7801\u914D\u7F6E\u4E0E\u5206\u8FA8\u7387\u540C\u65F6\u53D8\u5316",
498
+ usageP5: "\u540C size-720p \u914D\u5BF9\u4F7F\u7528"
499
+ }
500
+ ];
501
+ function drawtextFilter(fontfile) {
502
+ 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`;
503
+ }
504
+ function buildWindowPlan(spec, opts) {
505
+ const useDrawtext = opts?.useDrawtext ?? true;
506
+ const fontfile = opts?.fontfile ?? DEFAULT_DRAWTEXT_FONT;
507
+ const specCodec = CODEC_SPEC[spec.codec];
508
+ const videoInput = `testsrc2=size=${spec.width}x${spec.height}:rate=${spec.fps}`;
509
+ const audioInput = `sine=frequency=1000:sample_rate=44100`;
510
+ const videoFilter = useDrawtext ? `format=yuv420p,${drawtextFilter(fontfile)}` : "format=yuv420p";
511
+ const outputFps = spec.sparse ? 1 : spec.fps;
512
+ const sizeSwitchBaseline = spec.pairKind === "sizeSwitch" && spec.pairRole === "b" ? ["-profile:v", "baseline"] : specCodec.videoArgs;
513
+ const ffmpegOutputArgs = [
514
+ "-y",
515
+ "-f",
516
+ "lavfi",
517
+ "-i",
518
+ videoInput,
519
+ "-f",
520
+ "lavfi",
521
+ "-i",
522
+ audioInput,
523
+ "-t",
524
+ String(spec.durationSeconds),
525
+ "-vf",
526
+ videoFilter,
527
+ "-r",
528
+ String(outputFps),
529
+ "-c:v",
530
+ specCodec.encoder,
531
+ ...sizeSwitchBaseline,
532
+ "-pix_fmt",
533
+ specCodec.pixelFormat,
534
+ "-bf",
535
+ String(spec.bframes),
536
+ "-g",
537
+ String(spec.gopFrames),
538
+ "-keyint_min",
539
+ String(spec.gopFrames),
540
+ "-sc_threshold",
541
+ "0",
542
+ ...containerExtraArgs("mp4", spec.codec),
543
+ "-c:a",
544
+ "aac",
545
+ "-b:a",
546
+ "128k",
547
+ "-output_ts_offset",
548
+ String(spec.startPtsSeconds),
549
+ "-f",
550
+ "mp4",
551
+ "-movflags",
552
+ "-faststart"
553
+ ];
554
+ const mainFile = join3(WINDOW_CONTAINER_DIR, `${spec.id}.mp4`);
555
+ const encode = {
556
+ codec: spec.codec,
557
+ gopFrames: spec.gopFrames,
558
+ gopMode: spec.gopMode,
559
+ bframes: spec.bframes,
560
+ startPtsSeconds: spec.startPtsSeconds,
561
+ sparse: spec.sparse,
562
+ moov: "trailing",
563
+ delayedTail: spec.delayedTail,
564
+ width: spec.width,
565
+ height: spec.height,
566
+ fps: outputFps
567
+ };
568
+ return { id: spec.id, spec, mainFile, files: [mainFile], ffmpegOutputArgs, encode };
569
+ }
570
+
205
571
  // src/fixtures/generate.ts
206
572
  var FFMPEG_PROBE_ARGS = ["-version"];
207
573
  async function generateFixtures(opts = {}) {
@@ -243,26 +609,39 @@ async function generateFixtures(opts = {}) {
243
609
  }
244
610
  }
245
611
  copyVodSamples(fixtureDir);
612
+ let windowFixtures = [];
613
+ if (opts.includeWindow !== false) {
614
+ const windowResult = await generateWindowSet(
615
+ fixtureDir,
616
+ ffmpegPath,
617
+ opts.quiet ?? false,
618
+ opts.overwrite ?? false
619
+ );
620
+ windowFixtures = windowResult.items;
621
+ generated += windowResult.generated;
622
+ skipped += windowResult.skipped;
623
+ }
246
624
  const manifest = {
247
625
  version: 1,
248
626
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
249
627
  durationSeconds: duration,
250
- fixtures: manifestItems
628
+ fixtures: manifestItems,
629
+ windowFixtures
251
630
  };
252
- writeFileSync(join3(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
631
+ writeFileSync(join4(fixtureDir, "manifest.json"), JSON.stringify(manifest, null, 2));
253
632
  return { fixtureDir, manifest, generated, skipped, ffmpegPath, ffmpegMissing: false };
254
633
  }
255
634
  async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
256
- const outDir = join3(fixtureDir, plan.containerDir);
635
+ const outDir = join4(fixtureDir, plan.containerDir);
257
636
  mkdirSync(outDir, { recursive: true });
258
- const outFile = join3(fixtureDir, plan.mainFile);
637
+ const outFile = join4(fixtureDir, plan.mainFile);
259
638
  if (!overwrite && existsSync(outFile)) {
260
639
  return collectExisting(plan, fixtureDir);
261
640
  }
262
641
  if (plan.layout.isContainerDirectory) {
263
642
  for (const f of readdirSync(outDir)) {
264
643
  if (f === plan.layout.mainFileName) continue;
265
- rmIfExists(join3(outDir, f));
644
+ rmIfExists(join4(outDir, f));
266
645
  }
267
646
  }
268
647
  const args = plan.ffmpegOutputArgs.concat([outFile]);
@@ -276,21 +655,89 @@ async function generateOne(plan, fixtureDir, ffmpegPath, quiet, overwrite) {
276
655
  }
277
656
  return collectExisting(plan, fixtureDir);
278
657
  }
658
+ async function generateWindowSet(fixtureDir, ffmpegPath, quiet, overwrite) {
659
+ const outDir = join4(fixtureDir, WINDOW_CONTAINER_DIR);
660
+ mkdirSync(outDir, { recursive: true });
661
+ const useDrawtext = await supportsDrawtext(ffmpegPath);
662
+ const fontfile = useDrawtext ? pickFontfile() : void 0;
663
+ if (!useDrawtext && !quiet) {
664
+ process.stdout.write(
665
+ "[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"
666
+ );
667
+ }
668
+ const items = [];
669
+ let generated = 0;
670
+ let skipped = 0;
671
+ for (const spec of WINDOW_SPECS) {
672
+ const plan = buildWindowPlan(spec, { useDrawtext, fontfile });
673
+ const outFile = join4(fixtureDir, plan.mainFile);
674
+ if (!overwrite && existsSync(outFile)) {
675
+ items.push(collectWindowExisting(plan, fixtureDir));
676
+ skipped += 1;
677
+ continue;
678
+ }
679
+ if (!quiet) {
680
+ process.stdout.write(`[generate] window/${plan.id} -> ${plan.mainFile}
681
+ `);
682
+ }
683
+ const code = await runFfmpeg(ffmpegPath, plan.ffmpegOutputArgs.concat([outFile]), quiet);
684
+ if (code !== 0) {
685
+ throw new Error(`ffmpeg exited with code ${code} for window/${plan.id}`);
686
+ }
687
+ items.push(collectWindowExisting(plan, fixtureDir));
688
+ generated += 1;
689
+ }
690
+ return { items, generated, skipped };
691
+ }
692
+ function collectWindowExisting(plan, fixtureDir) {
693
+ const entries = [];
694
+ for (const rel of plan.files) {
695
+ const abs = join4(fixtureDir, rel);
696
+ if (!existsSync(abs)) continue;
697
+ entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
698
+ }
699
+ return { id: plan.id, codec: plan.spec.codec, entries, encode: plan.encode };
700
+ }
701
+ async function supportsDrawtext(ffmpegPath) {
702
+ const { promise, resolve: resolve2 } = Promise.withResolvers();
703
+ const proc = spawn(ffmpegPath, ["-hide_banner", "-filters"], {
704
+ stdio: ["ignore", "pipe", "ignore"]
705
+ });
706
+ let out = "";
707
+ proc.stdout?.on("data", (chunk) => {
708
+ out += chunk.toString("utf8");
709
+ });
710
+ proc.once("error", () => resolve2(false));
711
+ proc.once("exit", (code) => {
712
+ if (code !== 0) {
713
+ resolve2(false);
714
+ return;
715
+ }
716
+ resolve2(/^\s*\S{2,3}\s+drawtext\s/m.test(out));
717
+ });
718
+ return promise;
719
+ }
720
+ function pickFontfile() {
721
+ for (const candidate of DRAWTEXT_FONT_CANDIDATES) {
722
+ if (existsSync(candidate)) return candidate;
723
+ }
724
+ return DRAWTEXT_FONT_CANDIDATES[0] ?? "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
725
+ }
279
726
  function collectExisting(plan, fixtureDir) {
280
727
  const entries = [];
281
728
  for (const rel of plan.files) {
282
- const abs = join3(fixtureDir, rel);
729
+ const abs = join4(fixtureDir, rel);
283
730
  if (!existsSync(abs)) continue;
284
731
  entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
285
732
  }
286
733
  if (plan.layout.isContainerDirectory) {
287
- const absDir = join3(fixtureDir, plan.containerDir);
734
+ const absDir = join4(fixtureDir, plan.containerDir);
288
735
  if (existsSync(absDir)) {
289
736
  for (const name of readdirSync(absDir).sort()) {
290
737
  if (name === plan.layout.mainFileName) continue;
291
- const rel = join3(plan.containerDir, name);
738
+ const rel = join4(plan.containerDir, name);
292
739
  if (entries.find((e) => e.path === rel)) continue;
293
- const abs = join3(absDir, name);
740
+ const abs = join4(absDir, name);
294
741
  if (!statSync(abs).isFile()) continue;
295
742
  entries.push({ path: rel, size: statSync(abs).size, sha256: hashFile(abs) });
296
743
  }
@@ -299,12 +746,12 @@ function collectExisting(plan, fixtureDir) {
299
746
  return { codec: plan.codec, format: plan.format, entries };
300
747
  }
301
748
  function copyVodSamples(fixtureDir) {
302
- const src = join3(fixtureDir, VOD_SAMPLE_RELATIVE);
749
+ const src = join4(fixtureDir, VOD_SAMPLE_RELATIVE);
303
750
  if (!existsSync(src)) return;
304
- const vodDir = join3(fixtureDir, "vod");
751
+ const vodDir = join4(fixtureDir, "vod");
305
752
  mkdirSync(vodDir, { recursive: true });
306
753
  for (const name of ["sample-1.mp4", "sample-2.mp4"]) {
307
- const dst = join3(vodDir, name);
754
+ const dst = join4(vodDir, name);
308
755
  writeFileSync(dst, readFileSync(src));
309
756
  }
310
757
  }
@@ -353,7 +800,8 @@ function emptyManifest(duration) {
353
800
  version: 1,
354
801
  generatedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
355
802
  durationSeconds: duration,
356
- fixtures: []
803
+ fixtures: [],
804
+ windowFixtures: []
357
805
  };
358
806
  }
359
807
  export {