@outerlayer/cli 0.34.0-next.20261026184303 → 0.34.0-next.20261026214534

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 (113) hide show
  1. package/dist/{agent-setup-WOEZELVB.js → agent-setup-2A6P2SYL.js} +10 -10
  2. package/dist/{artifact-link-cmd-LWPY3V4N.js → artifact-link-cmd-4XQJO55P.js} +2 -2
  3. package/dist/{build-UDOBVX5J.js → build-BBWAD5LR.js} +4 -4
  4. package/dist/{build-disk-C3WIAHCL.js → build-disk-TGLM5XK7.js} +4 -4
  5. package/dist/build-info.json +1 -1
  6. package/dist/{builder-YNXWVH2V.js → builder-FD53GTWL.js} +3 -3
  7. package/dist/{check-G2K76NEM.js → check-FOWN6M3C.js} +34 -34
  8. package/dist/{chunk-GHFI5GBF.js → chunk-25XBOX3K.js} +3 -3
  9. package/dist/{chunk-VEJWLQ4G.js → chunk-2GDKM2O4.js} +12 -12
  10. package/dist/{chunk-IWVGALDC.js → chunk-3HJMY3H5.js} +1 -1
  11. package/dist/{chunk-V2VJYK46.js → chunk-3NCWHEGM.js} +2 -2
  12. package/dist/{chunk-CNZRUVZ5.js → chunk-3PUOHONX.js} +11 -11
  13. package/dist/{chunk-P3CHDKXD.js → chunk-5ZBVKRDK.js} +2 -2
  14. package/dist/{chunk-5JGPTYLT.js → chunk-66LH5QAV.js} +3 -3
  15. package/dist/{chunk-6D7H5DMZ.js → chunk-6CK2OC4Q.js} +2 -2
  16. package/dist/{chunk-7FQYTM7M.js → chunk-72CJOYN6.js} +7 -7
  17. package/dist/{chunk-EPFFYDNX.js → chunk-77VBS2RT.js} +2 -2
  18. package/dist/{chunk-XSFR76J7.js → chunk-7DK2FOZ4.js} +4 -4
  19. package/dist/{chunk-ZIKZZ4LK.js → chunk-BQQPDBSF.js} +2 -2
  20. package/dist/{chunk-NHEU5CON.js → chunk-CCHSD7RS.js} +2 -2
  21. package/dist/{chunk-EOTMDIYC.js → chunk-DINRXUQA.js} +43 -2
  22. package/dist/{chunk-JSZRBQY3.js → chunk-EBVK3QBO.js} +3 -3
  23. package/dist/{chunk-LTN2YFLR.js → chunk-FVF2LMVG.js} +12 -12
  24. package/dist/{chunk-MECW77XP.js → chunk-IXPH4EXK.js} +1 -1
  25. package/dist/{chunk-4P6QGMPN.js → chunk-J7EJMIDR.js} +2 -2
  26. package/dist/{chunk-LTYBHCL4.js → chunk-JA2ARVY6.js} +6 -6
  27. package/dist/{chunk-SZU4YCBH.js → chunk-JKLSQOYO.js} +16 -16
  28. package/dist/{chunk-ELO5XEES.js → chunk-K3ACC2CV.js} +475 -103
  29. package/dist/{chunk-GYVMMU3U.js → chunk-KR2TIRNQ.js} +7 -7
  30. package/dist/{chunk-RCRE42V6.js → chunk-LASKTDCR.js} +1 -1
  31. package/dist/{chunk-JWNH4ZTF.js → chunk-LMYRR5MO.js} +2 -2
  32. package/dist/{chunk-WE4C3ODP.js → chunk-LY62UHRR.js} +1 -1
  33. package/dist/{chunk-NKOM4JKY.js → chunk-LYCJQOO2.js} +2 -2
  34. package/dist/{chunk-4J2FF5HZ.js → chunk-MYLOOVRJ.js} +3 -3
  35. package/dist/{chunk-Y5YCX6AL.js → chunk-NWHU5G6R.js} +1 -1
  36. package/dist/{chunk-EUQZAH2W.js → chunk-O7QPHJWN.js} +4 -4
  37. package/dist/{chunk-IO2BBKG4.js → chunk-OKGHWCSK.js} +2 -2
  38. package/dist/{chunk-Z7WMU3RA.js → chunk-OOD5NDQA.js} +1 -1
  39. package/dist/{chunk-ZQSANKS4.js → chunk-OVNF4LLC.js} +1 -1
  40. package/dist/{chunk-JKNOJKVD.js → chunk-QABXXQ67.js} +5 -5
  41. package/dist/{chunk-VUQKGBIQ.js → chunk-QRJIBTNQ.js} +5 -5
  42. package/dist/{chunk-XL2MPORM.js → chunk-QWXBWOVV.js} +5 -5
  43. package/dist/{chunk-ERTIEYDG.js → chunk-RNMCFAYH.js} +3 -3
  44. package/dist/{chunk-HVPW272H.js → chunk-T2HH7IHT.js} +1 -1
  45. package/dist/{chunk-KPHRRCUY.js → chunk-TWFBOZQ5.js} +4 -4
  46. package/dist/{chunk-XKJOIQ2Q.js → chunk-TZMDRHWB.js} +4 -4
  47. package/dist/{chunk-DQ6YO7FH.js → chunk-UPA5KOOX.js} +1 -1
  48. package/dist/{chunk-LD3BHLK7.js → chunk-WBTPI5MP.js} +3 -3
  49. package/dist/{chunk-OQT7RT5I.js → chunk-WYR4INK3.js} +2 -2
  50. package/dist/{chunk-FGXT5OXS.js → chunk-XFJ7YOPD.js} +1 -1
  51. package/dist/{chunk-H47VGSCW.js → chunk-XHZBLIB2.js} +1 -1
  52. package/dist/{chunk-HVQMIZ22.js → chunk-XYLHSCLR.js} +1 -1
  53. package/dist/{chunk-JC6AQ6PC.js → chunk-ZADRW2V2.js} +4 -4
  54. package/dist/{claude-launcher-6KGANL7E.js → claude-launcher-EO3ALKBS.js} +5 -5
  55. package/dist/{cli-5KY3TE3S.js → cli-3RDQSLYI.js} +115 -110
  56. package/dist/{command-VQPQDPCZ.js → command-C5YKBTK4.js} +20 -20
  57. package/dist/{config-WSZIWPP4.js → config-EBT5VYPX.js} +5 -5
  58. package/dist/{connect-cmd-LI34HS5Q.js → connect-cmd-65YBQRSY.js} +8 -8
  59. package/dist/{container-start-XRR4VC5Z.js → container-start-RLX2PL3C.js} +31 -31
  60. package/dist/{context-cleanup-LUBEZB3L.js → context-cleanup-VJXMM27E.js} +3 -3
  61. package/dist/{context-show-cmd-X27GMOT6.js → context-show-cmd-S5SEBOI2.js} +9 -9
  62. package/dist/{control-plane-launch-74EIIB5Z.js → control-plane-launch-BDSUP4DK.js} +6 -6
  63. package/dist/demo-voice-cmd-DF6C7EST.js +83 -0
  64. package/dist/{dist-HVXLUMRV.js → dist-ADKRGE7I.js} +2 -2
  65. package/dist/{docker-KZ3OQTPW.js → docker-CP656XNO.js} +3 -3
  66. package/dist/{doctor-KPKLFIJH.js → doctor-EQIAHHZD.js} +21 -21
  67. package/dist/{emit-artifact-cmd-BJICHW2E.js → emit-artifact-cmd-56C6DBUP.js} +13 -13
  68. package/dist/{emit-cmd-7TU3JBJ2.js → emit-cmd-C3BUFAUR.js} +6 -6
  69. package/dist/{emit-criteria-cmd-MUWPT25P.js → emit-criteria-cmd-HRCWXD3W.js} +10 -10
  70. package/dist/{emit-demo-cmd-YQN7VKQH.js → emit-demo-cmd-YKTL6XWP.js} +15 -14
  71. package/dist/{emit-finding-cmd-NPBWVY44.js → emit-finding-cmd-6MVFN4BO.js} +10 -10
  72. package/dist/{emit-result-cmd-7CJZZQGK.js → emit-result-cmd-E3YYBXRY.js} +12 -12
  73. package/dist/{emit-step-cmd-ZPLHZAMA.js → emit-step-cmd-DM4CRYYI.js} +10 -10
  74. package/dist/{find-unit-UKH6JH4S.js → find-unit-O6WL6UIW.js} +7 -7
  75. package/dist/{findings-cmd-DZ4ESTHX.js → findings-cmd-K2OYBTUH.js} +10 -10
  76. package/dist/{fleet-readiness-YSI4NOHT.js → fleet-readiness-VIAVQYRP.js} +8 -8
  77. package/dist/{governing-source-JPRMOB3O.js → governing-source-MQA2SDJT.js} +13 -13
  78. package/dist/{hook-fast-6EW2MGA2.js → hook-fast-KXAQ22LZ.js} +1 -1
  79. package/dist/{host-checks-NDXNYOMA.js → host-checks-V25YUP4Q.js} +29 -29
  80. package/dist/index.js +4 -4
  81. package/dist/{init-V6Y2VNKW.js → init-KO3FT2XP.js} +20 -20
  82. package/dist/{init-MN3MNG7V.js → init-YYZ7B4ZD.js} +20 -20
  83. package/dist/{init-cmd-QCWLJDIF.js → init-cmd-D3BDS7KY.js} +18 -18
  84. package/dist/{init-update-GTFLPDEL.js → init-update-JMVLGBDE.js} +17 -17
  85. package/dist/{layout-7B3O3FT3.js → layout-KRWLUGYZ.js} +4 -4
  86. package/dist/{login-browser-DQ6YRXUB.js → login-browser-BAFZYJBR.js} +2 -2
  87. package/dist/{loop-NGFNQF3Y.js → loop-UVONXWXB.js} +33 -33
  88. package/dist/{machine-XG54XYYZ.js → machine-2BI76JZO.js} +20 -20
  89. package/dist/{machine-skills-3FEGR376.js → machine-skills-YVS4ZYCT.js} +4 -4
  90. package/dist/{runner-env-YPXWTNP3.js → runner-env-QN4BLTVZ.js} +7 -7
  91. package/dist/{runner-identity-XPZO3TG4.js → runner-identity-53I4UTVY.js} +3 -3
  92. package/dist/{runner-key-B5EJH25Z.js → runner-key-JKXJWGGY.js} +20 -20
  93. package/dist/{session-sender-XED7CKHP.js → session-sender-KLOMLL7X.js} +13 -13
  94. package/dist/{spec-cmd-TRXAGQYM.js → spec-cmd-MX5QFW32.js} +11 -11
  95. package/dist/{starter-pack-LEL3Q2DB.js → starter-pack-QWDE6FDK.js} +5 -5
  96. package/dist/{status-WQ53MWC3.js → status-YCQHAY45.js} +29 -29
  97. package/dist/{sync-cmd-UQVENF25.js → sync-cmd-CO4DFMHU.js} +12 -12
  98. package/dist/{update-steps-IQVAL64Y.js → update-steps-MC3IUHW7.js} +30 -30
  99. package/dist/{watch-EZGDDRTQ.js → watch-GU62YVQY.js} +12 -12
  100. package/dist/{work-claim-cmd-YSAJ7DAK.js → work-claim-cmd-O54L5DSL.js} +15 -15
  101. package/dist/{work-cmd-V5FHLWJM.js → work-cmd-VR7PPSP6.js} +10 -10
  102. package/dist/{work-comment-cmd-KLEMDYI4.js → work-comment-cmd-HVZF72B4.js} +11 -11
  103. package/dist/{work-demo-cmd-XYRKFP4G.js → work-demo-cmd-EDGNM5VJ.js} +10 -10
  104. package/dist/{work-logs-cmd-DOGPK7Q6.js → work-logs-cmd-ZZVAHX36.js} +10 -10
  105. package/dist/{work-open-pr-cmd-UAUEEP3V.js → work-open-pr-cmd-AQWZRJHZ.js} +11 -11
  106. package/dist/{work-pr-cmd-LYWL7ZLF.js → work-pr-cmd-UJ7VWCJN.js} +10 -10
  107. package/dist/{work-proofs-cmd-YMMDAA6W.js → work-proofs-cmd-LIXV252G.js} +12 -12
  108. package/dist/{work-proofs-record-NUCWR7ZJ.js → work-proofs-record-MCSA56F5.js} +19 -18
  109. package/dist/{work-stop-cmd-MRLJEQRP.js → work-stop-cmd-SAT4XVM2.js} +10 -10
  110. package/package.json +1 -1
  111. package/skill-pack/maintained/outerlayer-build/references/agent-briefs.md +5 -2
  112. package/skill-pack/maintained/outerlayer-evidence/SKILL.md +19 -40
  113. package/skill-pack/maintained/outerlayer-evidence/references/demo.md +75 -0
@@ -1,23 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
3
  import { readNewestDemo } from './chunk-ER6KQCVJ.js';
4
- import { runEmitArtifact } from './chunk-XL2MPORM.js';
4
+ import { runEmitArtifact } from './chunk-QWXBWOVV.js';
5
5
  import { VoiceDownloadError, downloadPinned, sha256OfFile } from './chunk-2PGI6RDE.js';
6
+ import { compareVersions } from './chunk-TQMI335S.js';
6
7
  import { VOICE_PACK_FILES, VOICE_PACK_VERSION } from './chunk-4UPET3R7.js';
7
8
  import { detectActiveSession } from './chunk-IP7BIBXJ.js';
8
- import { autoConnect } from './chunk-IO2BBKG4.js';
9
+ import { autoConnect } from './chunk-OKGHWCSK.js';
9
10
  import { C } from './chunk-ANPNT6PM.js';
10
11
  import { defaultFetch, resolveGatewayCredentials } from './chunk-7QAJQDHS.js';
11
- import { resolveRepoIdentity } from './chunk-Z7WMU3RA.js';
12
+ import { resolveRepoIdentity } from './chunk-OOD5NDQA.js';
13
+ import { resolvePackageVersion } from './chunk-RA3O54FC.js';
12
14
  import { launchedItemForSession } from './chunk-T2Q7ISPE.js';
13
- import { ARTIFACT_MAX_BYTES, ARTIFACT_MAX_CAPTION_LENGTH, DEMO_RECORD_MEDIA_TYPE, ARTIFACT_MAX_REPLACES_TARGETS, DemoManifestSchema, ARTIFACT_KINDS } from './chunk-EOTMDIYC.js';
15
+ import { ARTIFACT_MAX_BYTES, ARTIFACT_MAX_CAPTION_LENGTH, DEMO_RECORD_MEDIA_TYPE, ARTIFACT_MAX_REPLACES_TARGETS, DEMO_MANIFEST_LATEST_VERSION, DemoManifestSchema, ARTIFACT_KINDS } from './chunk-DINRXUQA.js';
14
16
  import { homeFromEnv } from './chunk-VNVZDWO3.js';
15
17
  import { __commonJS, __require, __toESM } from './chunk-Z5GEFMRW.js';
16
18
  import { createHash } from 'crypto';
17
- import { mkdtempSync, writeFileSync, statSync, readFileSync, rmSync, mkdirSync, accessSync, constants, realpathSync, existsSync, createReadStream, createWriteStream } from 'fs';
19
+ import { mkdtempSync, writeFileSync, statSync, readFileSync, rmSync, mkdirSync, existsSync, copyFileSync, realpathSync, accessSync, constants, createReadStream, createWriteStream } from 'fs';
18
20
  import { tmpdir } from 'os';
19
21
  import { resolve, dirname, join, relative, sep, isAbsolute, delimiter, normalize } from 'path';
20
- import { execFileSync } from 'child_process';
22
+ import { execFileSync, spawn } from 'child_process';
21
23
  import { createRequire as createRequire$1 } from 'module';
22
24
  import { createGunzip } from 'zlib';
23
25
 
@@ -2286,8 +2288,154 @@ function missingProgramMessage(name, narrated) {
2286
2288
  const base = `install ${hint}, then run the command again`;
2287
2289
  return name === "espeak-ng" && narrated ? `espeak-ng is not on PATH \u2014 ${base}, or pass --no-narration to store the clips without speech` : `${name} is not on PATH \u2014 ${base}`;
2288
2290
  }
2291
+ var MOTION_FPS = 10;
2292
+ var WIDTH = 320;
2293
+ var HEIGHT = 200;
2294
+ var PIXEL_STEP = 8;
2295
+ var MOVING_PIXELS = 3;
2296
+ function changedPixels(a, b) {
2297
+ let changed = 0;
2298
+ for (let index = 0; index < a.length; index++) if (Math.abs(a[index] - b[index]) > PIXEL_STEP) changed++;
2299
+ return changed;
2300
+ }
2301
+ async function readMotion(frames, size = WIDTH * HEIGHT) {
2302
+ const moving = [];
2303
+ let previous;
2304
+ let pending = Buffer.alloc(0);
2305
+ for await (const chunk of frames) {
2306
+ pending = Buffer.concat([pending, chunk]);
2307
+ while (pending.length >= size) {
2308
+ const frame = new Uint8Array(pending.subarray(0, size));
2309
+ moving.push(previous === void 0 || changedPixels(frame, previous) >= MOVING_PIXELS);
2310
+ previous = frame;
2311
+ pending = pending.subarray(size);
2312
+ }
2313
+ }
2314
+ return moving;
2315
+ }
2316
+ function motionArgs(video) {
2317
+ return ["-v", "error", "-i", video, "-vf", `fps=${MOTION_FPS},scale=${WIDTH}:${HEIGHT}:flags=area,format=gray`, "-f", "rawvideo", "-"];
2318
+ }
2319
+ async function motionMap(video, ffmpeg = "ffmpeg", spawnImpl = spawn) {
2320
+ const child = spawnImpl(ffmpeg, motionArgs(video), { stdio: ["ignore", "pipe", "pipe"] });
2321
+ let errors = "";
2322
+ child.stderr.on("data", (chunk) => {
2323
+ errors += chunk.toString();
2324
+ });
2325
+ const exited = new Promise((resolve3, reject) => {
2326
+ child.on("error", reject);
2327
+ child.on("close", resolve3);
2328
+ });
2329
+ const moving = await readMotion(child.stdout);
2330
+ const code = await exited;
2331
+ if (code !== 0) throw new Error(`ffmpeg could not read ${video}: ${errors.trim() || `exit ${code}`}`);
2332
+ return { fps: MOTION_FPS, moving };
2333
+ }
2334
+ function frozenSpans(map, minSeconds) {
2335
+ const spans = [];
2336
+ let runStart;
2337
+ const close = (index) => {
2338
+ if (runStart === void 0) return;
2339
+ const start = runStart / map.fps;
2340
+ const end = index / map.fps;
2341
+ if (end - start >= minSeconds) spans.push({ start, end });
2342
+ runStart = void 0;
2343
+ };
2344
+ map.moving.forEach((moves, index) => {
2345
+ if (moves) close(index);
2346
+ else runStart ??= index;
2347
+ });
2348
+ close(map.moving.length);
2349
+ return spans;
2350
+ }
2351
+ var DEAD_TRIM_MIN_SECONDS = 1.6;
2352
+ var DEAD_KEEP_SECONDS = 0.4;
2353
+ var MIN_PART_SECONDS = 0.8;
2354
+ function keepParts(beat, speech, map) {
2355
+ const parts = [];
2356
+ let partStart = beat.start;
2357
+ const first = Math.ceil(beat.start * map.fps);
2358
+ const last = Math.floor(beat.end * map.fps);
2359
+ let runStart;
2360
+ const dead = (index) => {
2361
+ const at = index / map.fps;
2362
+ const spoken = speech !== void 0 && at >= speech.start && at <= speech.end;
2363
+ return !spoken && map.moving[index] === false;
2364
+ };
2365
+ const ms = (seconds) => Math.round(seconds * 1e3) / 1e3;
2366
+ const closeRun = (endIndex) => {
2367
+ if (runStart === void 0) return;
2368
+ const frames = endIndex - runStart;
2369
+ const runFrom = runStart / map.fps;
2370
+ const runTo = endIndex / map.fps;
2371
+ runStart = void 0;
2372
+ if (frames < Math.round(DEAD_TRIM_MIN_SECONDS * map.fps)) return;
2373
+ const cutFrom = ms(Math.max(runFrom + DEAD_KEEP_SECONDS, partStart + MIN_PART_SECONDS));
2374
+ const cutTo = ms(Math.min(runTo - DEAD_KEEP_SECONDS, beat.end - MIN_PART_SECONDS));
2375
+ if (Math.round((cutTo - cutFrom) * 1e3) < 600) return;
2376
+ parts.push({ start: partStart, end: cutFrom });
2377
+ partStart = cutTo;
2378
+ };
2379
+ for (let index = first; index <= last; index++) {
2380
+ if (dead(index)) runStart ??= index;
2381
+ else closeRun(index);
2382
+ }
2383
+ closeRun(last + 1);
2384
+ parts.push({ start: partStart, end: beat.end });
2385
+ return parts;
2386
+ }
2387
+
2388
+ // src/demo/measure.ts
2389
+ var DEAD_AIR_MAX_SECONDS = 2;
2390
+ var SPOKEN_SHARE_MIN = 0.6;
2391
+ function parseSpans(log, startKey, endKey, duration) {
2392
+ const spans = [];
2393
+ let open;
2394
+ for (const line of log.split("\n")) {
2395
+ const start = new RegExp(`${startKey}[:=]\\s*(-?[0-9.]+)`).exec(line);
2396
+ if (start) open = Math.max(0, Number(start[1]));
2397
+ const end = new RegExp(`${endKey}[:=]\\s*(-?[0-9.]+)`).exec(line);
2398
+ if (end && open !== void 0) {
2399
+ spans.push({ start: open, end: Number(end[1]) });
2400
+ open = void 0;
2401
+ }
2402
+ }
2403
+ if (open !== void 0) spans.push({ start: open, end: duration });
2404
+ return spans;
2405
+ }
2406
+ function intersect(a, b) {
2407
+ const both = [];
2408
+ for (const x of a) {
2409
+ for (const y of b) {
2410
+ const start = Math.max(x.start, y.start);
2411
+ const end = Math.min(x.end, y.end);
2412
+ if (end > start) both.push({ start, end });
2413
+ }
2414
+ }
2415
+ return both.sort((p, q) => q.end - q.start - (p.end - p.start));
2416
+ }
2417
+ var total = (spans) => spans.reduce((sum, span) => sum + (span.end - span.start), 0);
2418
+ function summarize(duration, frozen, silent, hasAudio) {
2419
+ const quiet = hasAudio ? silent : [{ start: 0, end: duration }];
2420
+ return { duration, frozen: total(frozen), spoken: Math.max(0, duration - total(quiet)), deadAir: intersect(frozen, quiet) };
2421
+ }
2422
+ function deadAirProblems(measure) {
2423
+ return measure.deadAir.filter((span) => span.end - span.start > DEAD_AIR_MAX_SECONDS).map((span) => `the screen is frozen with nothing said for ${(span.end - span.start).toFixed(1)} s, from ${span.start.toFixed(1)} s`);
2424
+ }
2425
+ function speechNote(measure, narrated) {
2426
+ if (!narrated || measure.spoken >= SPOKEN_SHARE_MIN * measure.duration) return void 0;
2427
+ return `speech covers ${Math.round(measure.spoken / measure.duration * 100)}% of the demo, under ${Math.round(SPOKEN_SHARE_MIN * 100)}%`;
2428
+ }
2429
+ async function measureDemo(video, duration, tools) {
2430
+ const hasAudio = tools.run(tools.ffprobe, ["-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_type", "-of", "csv=p=0", video]).trim() !== "";
2431
+ const log = hasAudio ? tools.run(tools.ffmpeg, ["-hide_banner", "-nostats", "-v", "error", "-i", video, "-vn", "-af", "silencedetect=n=-42dB:d=0.5,ametadata=mode=print:file=-", "-f", "null", "-"]) : "";
2432
+ const silent = parseSpans(log, "lavfi\\.silence_start", "lavfi\\.silence_end", duration);
2433
+ const frozen = frozenSpans(await tools.motion(video), 0.5);
2434
+ return summarize(duration, frozen, silent, hasAudio);
2435
+ }
2289
2436
 
2290
2437
  // src/demo/media.ts
2438
+ var SEGMENT_FPS = 25;
2291
2439
  function encodeArgs(duration, output) {
2292
2440
  return [
2293
2441
  "-c:v",
@@ -2322,57 +2470,64 @@ function encodeArgs(duration, output) {
2322
2470
  function tempoStage(tempo) {
2323
2471
  return tempo === 1 ? "" : `atempo=${tempo.toFixed(4)},`;
2324
2472
  }
2325
- function clipArgs(plan) {
2326
- const duration = plan.end - plan.start;
2327
- const audio = plan.audio;
2328
- const pad = `apad=whole_dur=${duration.toFixed(3)}`;
2329
- const audioInput = audio === void 0 ? [] : audio === "silence" ? ["-f", "lavfi", "-t", duration.toFixed(3), "-i", "anullsrc=channel_layout=mono:sample_rate=48000"] : ["-i", audio.wavPath];
2330
- const audioFilter = audio === void 0 ? void 0 : audio === "silence" ? `[1:a]${pad}[speech]` : `[1:a]${tempoStage(audio.tempo)}adelay=${Math.round(audio.delaySeconds * 1e3)}:all=1,aresample=48000,${pad}[speech]`;
2331
- const speech = audioFilter === void 0 ? [] : ["-filter_complex", audioFilter, "-map", "[speech]", "-c:a", "libopus", "-b:a", "48k"];
2473
+ var KEY_FRAME_LEAD_SECONDS = 0.1;
2474
+ function segmentsArgs(plan) {
2475
+ const count = plan.spans.length;
2476
+ const parts = plan.spans.map((_, index) => `[p${index}]`).join("");
2477
+ const crop = plan.crop === void 0 ? "" : `crop=${plan.crop.width}:${plan.crop.height}:0:0,`;
2478
+ const video = [`[0:v]${crop}fps=${SEGMENT_FPS},format=yuv420p,split=${count}${parts}`];
2479
+ for (const [index, span] of plan.spans.entries()) {
2480
+ video.push(`[p${index}]trim=start=${span.start.toFixed(3)}:end=${span.end.toFixed(3)},setpts=PTS-STARTPTS[s${index}]`);
2481
+ }
2482
+ let joined = "[s0]";
2483
+ for (let index = 1; index < count; index++) {
2484
+ const label = index === count - 1 ? "[vout]" : `[x${index}]`;
2485
+ video.push(`${joined}[s${index}]xfade=transition=fade:duration=${plan.crossfade.toFixed(3)}:offset=${plan.outStarts[index].toFixed(3)}${label}`);
2486
+ joined = label;
2487
+ }
2488
+ if (count === 1) video.push("[s0]null[vout]");
2489
+ const pad = `apad=whole_dur=${plan.duration.toFixed(3)}`;
2490
+ const audio = plan.speech.map(
2491
+ (speech, index) => `[${index + 1}:a]${tempoStage(speech.tempo)}adelay=${Math.round(speech.delaySeconds * 1e3)}:all=1[line${index}]`
2492
+ );
2493
+ if (plan.narrated) {
2494
+ audio.push(
2495
+ plan.speech.length === 0 ? `anullsrc=channel_layout=mono:sample_rate=48000,${pad}[speech]` : plan.speech.length === 1 ? `[line0]aresample=48000,${pad}[speech]` : `${plan.speech.map((_, index) => `[line${index}]`).join("")}amix=inputs=${plan.speech.length}:normalize=0,aresample=48000,${pad}[speech]`
2496
+ );
2497
+ }
2498
+ const keyFrames = plan.chapterStarts.map((at) => at - KEY_FRAME_LEAD_SECONDS).filter((at) => at > 0);
2332
2499
  return [
2333
2500
  "-y",
2334
2501
  "-v",
2335
2502
  "error",
2336
- // The cut sits before the input and the clip is re-encoded, so it starts on the exact frame, not the nearest key frame.
2337
- "-ss",
2338
- plan.start.toFixed(3),
2339
- "-t",
2340
- duration.toFixed(3),
2341
2503
  "-i",
2342
2504
  plan.video,
2343
- ...audioInput,
2505
+ ...plan.speech.flatMap((speech) => ["-i", speech.wavPath]),
2506
+ "-filter_complex",
2507
+ [...video, ...audio].join(";"),
2344
2508
  "-map",
2345
- "0:v:0",
2346
- ...audio === void 0 ? ["-an"] : speech,
2347
- ...encodeArgs(duration, plan.output)
2509
+ "[vout]",
2510
+ ...plan.narrated ? ["-map", "[speech]", "-c:a", "libopus", "-b:a", "48k"] : ["-an"],
2511
+ ...keyFrames.length > 0 ? ["-force_key_frames", keyFrames.map((at) => at.toFixed(3)).join(",")] : [],
2512
+ ...encodeArgs(plan.duration, plan.output)
2348
2513
  ];
2349
2514
  }
2350
- var KEY_FRAME_LEAD_SECONDS = 0.1;
2351
- function demoArgs(plan) {
2515
+ function rangeArgs(plan) {
2352
2516
  const duration = plan.end - plan.start;
2353
- const pad = `apad=whole_dur=${duration.toFixed(3)}`;
2354
- const stages = plan.speech.map(
2355
- (speech, index) => `[${index + 1}:a]${tempoStage(speech.tempo)}adelay=${Math.round(speech.delaySeconds * 1e3)}:all=1[line${index}]`
2356
- );
2357
- const mixed = plan.speech.length === 0 ? `anullsrc=channel_layout=mono:sample_rate=48000,${pad}[speech]` : plan.speech.length === 1 ? `[line0]aresample=48000,${pad}[speech]` : `${plan.speech.map((_, index) => `[line${index}]`).join("")}amix=inputs=${plan.speech.length}:normalize=0,aresample=48000,${pad}[speech]`;
2358
- const filter = [...stages, mixed].join(";");
2359
- const keyFrames = plan.chapterStarts.map((at) => at - plan.start - KEY_FRAME_LEAD_SECONDS).filter((at) => at > 0);
2360
2517
  return [
2361
2518
  "-y",
2362
2519
  "-v",
2363
2520
  "error",
2364
- // The cut sits before the input and the demo is re-encoded, so it starts on the exact frame, not the nearest key frame.
2521
+ // The cut sits before the input and the chapter is encoded again, so it starts on the exact frame, not the nearest key frame.
2365
2522
  "-ss",
2366
2523
  plan.start.toFixed(3),
2367
2524
  "-t",
2368
2525
  duration.toFixed(3),
2369
2526
  "-i",
2370
2527
  plan.video,
2371
- ...plan.speech.flatMap((speech) => ["-i", speech.wavPath]),
2372
2528
  "-map",
2373
2529
  "0:v:0",
2374
- ...plan.narrated ? ["-filter_complex", filter, "-map", "[speech]", "-c:a", "libopus", "-b:a", "48k"] : ["-an"],
2375
- ...keyFrames.length > 0 ? ["-force_key_frames", keyFrames.map((at) => at.toFixed(3)).join(",")] : [],
2530
+ ...plan.narrated ? ["-map", "0:a:0", "-c:a", "libopus", "-b:a", "48k"] : ["-an"],
2376
2531
  ...encodeArgs(duration, plan.output)
2377
2532
  ];
2378
2533
  }
@@ -2385,7 +2540,7 @@ function durationFromProbe(formatDuration, lastPacketLines) {
2385
2540
  return last[0] + (Number.isFinite(last[1]) ? last[1] : 0);
2386
2541
  }
2387
2542
  var TOOL_TIMEOUT_MS = 10 * 60 * 1e3;
2388
- function createMediaTools(env, exec = execFileSync) {
2543
+ function createMediaTools(env, exec = execFileSync, spawnImpl = spawn) {
2389
2544
  const ffmpeg = findProgram("ffmpeg", env) ?? "ffmpeg";
2390
2545
  const ffprobe = findProgram("ffprobe", env) ?? "ffprobe";
2391
2546
  const run = (program, args) => exec(program, args, {
@@ -2405,11 +2560,17 @@ function createMediaTools(env, exec = execFileSync) {
2405
2560
  if (duration === void 0) throw new Error(`cannot read the length of ${video}`);
2406
2561
  return duration;
2407
2562
  },
2408
- renderClip(plan) {
2409
- run(ffmpeg, clipArgs(plan));
2563
+ renderSegments(plan) {
2564
+ run(ffmpeg, segmentsArgs(plan));
2565
+ },
2566
+ renderRange(plan) {
2567
+ run(ffmpeg, rangeArgs(plan));
2410
2568
  },
2411
- renderDemo(plan) {
2412
- run(ffmpeg, demoArgs(plan));
2569
+ motion(video) {
2570
+ return motionMap(video, ffmpeg, spawnImpl);
2571
+ },
2572
+ measure(video, duration) {
2573
+ return measureDemo(video, duration, { ffmpeg, ffprobe, run, motion: (sampled) => motionMap(sampled, ffmpeg, spawnImpl) });
2413
2574
  }
2414
2575
  };
2415
2576
  }
@@ -2485,6 +2646,75 @@ function recordedFrame(chapter, stored) {
2485
2646
  if (frame === void 0 || stored.screenshot === void 0) return {};
2486
2647
  return { screenshot: { artifactId: stored.screenshot.artifactId, check: frame.check, sha256: frame.sha256, fullPage: frame.fullPage, masked: frame.masked } };
2487
2648
  }
2649
+ var RECORDER_PACKAGE = "@outerlayer/playwright";
2650
+ function installedRecorderVersion(dir) {
2651
+ for (let current = dir; ; current = dirname(current)) {
2652
+ let real;
2653
+ try {
2654
+ real = realpathSync(join(current, "node_modules", "@outerlayer", "playwright"));
2655
+ } catch {
2656
+ real = void 0;
2657
+ }
2658
+ if (real !== void 0) {
2659
+ if (!real.split(sep).includes("node_modules")) return void 0;
2660
+ try {
2661
+ const version = JSON.parse(readFileSync(join(real, "package.json"), "utf8")).version;
2662
+ return typeof version === "string" ? version : void 0;
2663
+ } catch {
2664
+ return void 0;
2665
+ }
2666
+ }
2667
+ if (dirname(current) === current) return void 0;
2668
+ }
2669
+ }
2670
+ function recorderVersionNote(recorder, cli) {
2671
+ if (recorder === void 0) return void 0;
2672
+ const order = compareVersions(recorder, cli);
2673
+ if (order === void 0 || order === 0) return void 0;
2674
+ return order < 0 ? `this demo was recorded with ${RECORDER_PACKAGE} ${recorder}, and this CLI is ${cli}: install ${RECORDER_PACKAGE}@${cli}, which releases with this CLI` : `this demo was recorded with ${RECORDER_PACKAGE} ${recorder}, newer than this CLI (${cli}): update the CLI to ${recorder}`;
2675
+ }
2676
+
2677
+ // src/demo/timeline.ts
2678
+ var JOIN_GAP = 0.25;
2679
+ var CROSSFADE = 0.3;
2680
+ function shownParts(chapter) {
2681
+ if (chapter.beats === void 0 || chapter.beats.length === 0) return [{ start: chapter.start, end: chapter.end }];
2682
+ return chapter.beats.map((beat) => ({ start: beat.start, end: beat.end }));
2683
+ }
2684
+ var millis = (seconds) => Math.round(seconds * 1e3) / 1e3;
2685
+ function planTimeline(chapters) {
2686
+ const spans = [];
2687
+ for (const chapter of chapters) {
2688
+ for (const part of shownParts(chapter)) {
2689
+ const last = spans.at(-1);
2690
+ if (last !== void 0 && part.start - last.end < JOIN_GAP) last.end = Math.max(last.end, part.end);
2691
+ else spans.push({ ...part });
2692
+ }
2693
+ }
2694
+ const outStarts = [];
2695
+ let cursor = 0;
2696
+ for (const [index, span] of spans.entries()) {
2697
+ const start = index === 0 ? 0 : millis(cursor - CROSSFADE);
2698
+ outStarts.push(start);
2699
+ cursor = millis(start + (span.end - span.start));
2700
+ }
2701
+ const duration = cursor;
2702
+ const recorded = spans.length === 0 ? 0 : spans.at(-1).end - spans[0].start;
2703
+ const shown = spans.reduce((sum, span) => sum + (span.end - span.start), 0);
2704
+ const placed = { spans, outStarts };
2705
+ const chapterRanges = chapters.map((chapter) => {
2706
+ const parts = shownParts(chapter);
2707
+ return { start: mapTime(placed, parts[0].start), end: mapTime(placed, parts.at(-1).end) };
2708
+ });
2709
+ return { spans, outStarts, duration, chapters: chapterRanges, leftOut: recorded - shown };
2710
+ }
2711
+ function mapTime(timeline, at) {
2712
+ for (const [index, span] of timeline.spans.entries()) {
2713
+ if (at <= span.end + 1e-6) return timeline.outStarts[index] + Math.max(0, at - span.start);
2714
+ }
2715
+ const last = timeline.spans.length - 1;
2716
+ return last < 0 ? 0 : timeline.outStarts[last] + (timeline.spans[last].end - timeline.spans[last].start);
2717
+ }
2488
2718
  var PUNCTUATION = ';:,.!?\xA1\xBF\u2014\u2026"\xAB\xBB\u201C\u201D(){}[]';
2489
2719
  var PUNCTUATION_RUN = new RegExp(`(\\s*[${PUNCTUATION.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}]+\\s*)+`, "g");
2490
2720
  function splitYear(text) {
@@ -2831,6 +3061,12 @@ function parseManifest(path, options) {
2831
3061
  } catch (err) {
2832
3062
  throw new EmitDemoError(`the manifest "${path}" is not valid JSON: ${String(err)}`);
2833
3063
  }
3064
+ const version = typeof raw === "object" && raw !== null ? raw.schemaVersion : void 0;
3065
+ if (typeof version === "number" && version > DEMO_MANIFEST_LATEST_VERSION) {
3066
+ throw new EmitDemoError(
3067
+ `the manifest "${path}" is version ${version}, and this CLI reads versions 1 to ${DEMO_MANIFEST_LATEST_VERSION}: update the CLI to the version of the recorder that wrote it`
3068
+ );
3069
+ }
2834
3070
  const parsed = DemoManifestSchema.safeParse(raw);
2835
3071
  if (!parsed.success) {
2836
3072
  const issue = parsed.error.issues[0];
@@ -2856,13 +3092,16 @@ async function runEmitDemo(opts) {
2856
3092
  };
2857
3093
  const manifestPath = resolve(cwd, opts.manifest);
2858
3094
  const { manifest, videoPath, text: manifestText } = parseManifest(manifestPath, { needVideo: true });
3095
+ const recorderNote = recorderVersionNote(installedRecorderVersion(dirname(manifestPath)), opts.cliVersion ?? resolvePackageVersion());
3096
+ if (recorderNote !== void 0) say(`${C.yellow}!${C.reset} ${recorderNote}`);
2859
3097
  const comparisons = compareRuns({ first: { manifest, dir: dirname(manifestPath) }, firstPath: manifestPath, secondPath: opts.compare === void 0 ? void 0 : resolve(cwd, opts.compare) });
2860
- const narrated = opts.noNarration !== true && manifest.chapters.some((chapter) => chapter.narration.trim() !== "");
3098
+ const narrated = opts.noNarration !== true && manifest.chapters.some((chapter) => chapter.narration.trim() !== "" || (beatsOf(chapter) ?? []).some((beat) => beat.narration.trim() !== ""));
2861
3099
  for (const program of narrated ? ["ffmpeg", "ffprobe", "espeak-ng"] : ["ffmpeg", "ffprobe"]) {
2862
3100
  if (findProgram(program, env) === void 0) throw new EmitDemoError(missingProgramMessage(program, narrated));
2863
3101
  }
2864
3102
  const fetchImpl = opts.fetchImpl ?? defaultFetch;
2865
3103
  const media = opts.media ?? createMediaTools(env);
3104
+ if (opts.output !== void 0) return renderOnly({ opts, manifest, manifestPath, videoPath, narrated, env, home, media, say, output: resolve(cwd, opts.output) });
2866
3105
  const item = resolveItem({ opts, env, cwd, home });
2867
3106
  const retiring = opts.replaces === void 0 ? void 0 : await readRetiring({ opts, item, env, cwd, home, fetchImpl });
2868
3107
  const proofs = await recordedCriteria({ opts, item, env, cwd, home, fetchImpl, say });
@@ -2885,66 +3124,24 @@ async function runEmitDemo(opts) {
2885
3124
  writeFileSync(path, frame.bytes);
2886
3125
  return path;
2887
3126
  });
2888
- const speaker = narrated ? opts.speaker ?? await realSpeaker({ env, home, work, say, installedRoot: opts.installedVoiceRoot }) : void 0;
2889
- const spoken = [];
2890
- for (const [index, chapter] of manifest.chapters.entries()) {
2891
- if (speaker === void 0 || chapter.narration.trim() === "") continue;
2892
- let speech;
2893
- try {
2894
- speech = await speaker(chapter.narration);
2895
- } catch (err) {
2896
- throw new EmitDemoError(`${chapterLabel(index, chapter.title)}: the narration could not be spoken: ${err instanceof Error ? err.message : String(err)}`);
2897
- }
2898
- const fit = fitSpeech(speech.seconds, chapter.end - chapter.start);
2899
- if (!fit.ok) {
2900
- throw new EmitDemoError(
2901
- `${chapterLabel(index, chapter.title)}: the narration runs ${fit.overrunSeconds.toFixed(1)} s too long even at 1.3 times speed \u2014 shorten the line or lengthen the chapter`
2902
- );
2903
- }
2904
- spoken.push({ index, wavPath: speech.wavPath, tempo: fit.tempo });
2905
- }
2906
- const first = manifest.chapters[0];
2907
- const last = manifest.chapters[manifest.chapters.length - 1];
2908
- const wholePlan = {
2909
- video: videoPath,
2910
- start: first.start,
2911
- end: last.end,
2912
- chapterStarts: manifest.chapters.map((chapter) => chapter.start),
2913
- output: join(work, "demo.webm"),
2914
- narrated: speaker !== void 0,
2915
- speech: spoken.map((line) => ({
2916
- wavPath: line.wavPath,
2917
- tempo: line.tempo,
2918
- delaySeconds: manifest.chapters[line.index].start - first.start + SPEECH_LEAD_IN
2919
- }))
2920
- };
2921
- try {
2922
- media.renderDemo(wholePlan);
2923
- } catch (err) {
2924
- throw new EmitDemoError(`ffmpeg could not cut the demo: ${String(err)}`);
2925
- }
3127
+ const speakerFor = lazySpeaker(opts, { env, home, work, say });
3128
+ const whole = await renderWhole({ manifest, manifestDir, videoPath, work, speakerFor, narrated, media });
3129
+ const deadAir = deadAirProblems(whole.measure);
3130
+ if (deadAir.length > 0) throw new EmitDemoError(deadAirRefusal(deadAir));
3131
+ const note = speechNote(whole.measure, narrated);
3132
+ if (note !== void 0) say(`${C.yellow}!${C.reset} ${note}: tell each step as a beat with demo.say()`);
3133
+ const wholePlan = { output: whole.output };
2926
3134
  const wholeBytes = statSync(wholePlan.output).size;
2927
3135
  const wholeFits = wholeBytes <= ARTIFACT_MAX_BYTES;
2928
3136
  const clipPlans = [];
2929
3137
  if (!wholeFits) {
2930
- for (const [index, chapter] of manifest.chapters.entries()) {
2931
- const clipStart = index === 0 ? chapter.start : manifest.chapters[index - 1].end;
2932
- const line = spoken.find((entry) => entry.index === index);
2933
- clipPlans.push({
2934
- video: videoPath,
2935
- start: clipStart,
2936
- end: chapter.end,
2937
- output: join(work, `chapter-${String(index + 1).padStart(2, "0")}.webm`),
2938
- ...line !== void 0 ? { audio: { wavPath: line.wavPath, tempo: line.tempo, delaySeconds: chapter.start - clipStart + SPEECH_LEAD_IN } } : (
2939
- // A chapter with no line still carries a track in a narrated demo, so every clip of the demo has audio.
2940
- speaker !== void 0 ? { audio: "silence" } : {}
2941
- )
2942
- });
3138
+ for (const [index, range] of whole.ranges.entries()) {
3139
+ clipPlans.push({ video: whole.output, start: range.start, end: range.end, output: join(work, `chapter-${String(index + 1).padStart(2, "0")}.webm`), narrated });
2943
3140
  }
2944
3141
  for (const [index, plan] of clipPlans.entries()) {
2945
3142
  const chapter = manifest.chapters[index];
2946
3143
  try {
2947
- media.renderClip(plan);
3144
+ media.renderRange(plan);
2948
3145
  } catch (err) {
2949
3146
  throw new EmitDemoError(`${chapterLabel(index, chapter.title)}: ffmpeg could not cut the clip: ${String(err)}`);
2950
3147
  }
@@ -2963,16 +3160,16 @@ async function runEmitDemo(opts) {
2963
3160
  let stored;
2964
3161
  if (wholeFits) {
2965
3162
  video = { artifactId: idFor("video", wholePlan.output) };
2966
- stored = manifest.chapters.map((chapter, index) => ({
3163
+ stored = manifest.chapters.map((_chapter, index) => ({
2967
3164
  artifactId: idFor(`chapter-${index + 1}`, wholePlan.output),
2968
- range: { start: chapter.start - first.start, end: chapter.end - first.start },
3165
+ range: whole.ranges[index],
2969
3166
  bound: bound[index]
2970
3167
  }));
2971
3168
  } else {
2972
3169
  video = { omitted: "size", bytes: wholeBytes };
2973
- stored = manifest.chapters.map((chapter, index) => ({
3170
+ stored = manifest.chapters.map((_chapter, index) => ({
2974
3171
  artifactId: idFor(`clip-${index + 1}`, clipPlans[index].output),
2975
- range: { start: chapter.start - clipPlans[index].start, end: chapter.end - clipPlans[index].start },
3172
+ range: { start: 0, end: Number((clipPlans[index].end - clipPlans[index].start).toFixed(3)) },
2976
3173
  bound: bound[index]
2977
3174
  }));
2978
3175
  }
@@ -3005,11 +3202,11 @@ async function runEmitDemo(opts) {
3005
3202
  fetchImpl: opts.fetchImpl
3006
3203
  };
3007
3204
  const replacing = retiring === void 0 ? void 0 : planReplaces(retiring, manifest, wholeFits, frames.map((frame) => frame.kind === "bind"));
3008
- const total = manifest.chapters.length + (wholeFits ? 1 : 0) + 1 + frameIds.filter((id) => id !== void 0).length;
3205
+ const total2 = manifest.chapters.length + (wholeFits ? 1 : 0) + 1 + frameIds.filter((id) => id !== void 0).length;
3009
3206
  const unit = "artifacts";
3010
3207
  let emitted = 0;
3011
3208
  const emitStep = async (params) => {
3012
- const result = await emitOrExplain(emit, { ...base, ...params }, emitted, total, unit);
3209
+ const result = await emitOrExplain(emit, { ...base, ...params }, emitted, total2, unit);
3013
3210
  emitted += 1;
3014
3211
  return result;
3015
3212
  };
@@ -3017,7 +3214,7 @@ async function runEmitDemo(opts) {
3017
3214
  const wholeResult = await emitStep({
3018
3215
  file: wholePlan.output,
3019
3216
  artifactId: video.artifactId,
3020
- caption: `Demo: ${manifest.chapters.length} chapter${manifest.chapters.length === 1 ? "" : "s"}, ${(wholePlan.end - wholePlan.start).toFixed(1)} s`,
3217
+ caption: `Demo: ${manifest.chapters.length} chapter${manifest.chapters.length === 1 ? "" : "s"}, ${whole.duration.toFixed(1)} s`,
3021
3218
  blobOptional: true,
3022
3219
  ...replaceOption(replacing?.video)
3023
3220
  });
@@ -3147,13 +3344,13 @@ function artifactIdOf(result) {
3147
3344
  if (typeof id !== "string") throw new EmitDemoError("the artifact was emitted but its id was not returned");
3148
3345
  return id;
3149
3346
  }
3150
- async function emitOrExplain(emit, opts, emitted, total, unit) {
3347
+ async function emitOrExplain(emit, opts, emitted, total2, unit) {
3151
3348
  try {
3152
3349
  return await emit(opts);
3153
3350
  } catch (err) {
3154
3351
  const message = err instanceof Error ? err.message : String(err);
3155
3352
  throw new EmitDemoError(
3156
- emitted > 0 ? `${message} \u2014 ${emitted} of ${total} ${unit} were already emitted; run the command again to emit the whole demo` : message
3353
+ emitted > 0 ? `${message} \u2014 ${emitted} of ${total2} ${unit} were already emitted; run the command again to emit the whole demo` : message
3157
3354
  );
3158
3355
  }
3159
3356
  }
@@ -3299,5 +3496,180 @@ function decideFrames(params) {
3299
3496
  return { kind: "bind", index, frame: decision.bind, bytes };
3300
3497
  });
3301
3498
  }
3499
+ var BEAT_LEAD_IN = 0.15;
3500
+ function beatsOf(chapter) {
3501
+ return "beats" in chapter ? chapter.beats : void 0;
3502
+ }
3503
+ function lazySpeaker(opts, params) {
3504
+ let made;
3505
+ return () => {
3506
+ made ??= opts.speaker !== void 0 ? Promise.resolve(opts.speaker) : realSpeaker({ ...params, installedRoot: opts.installedVoiceRoot });
3507
+ return made;
3508
+ };
3509
+ }
3510
+ async function speak(speakerFor, label, line) {
3511
+ const speaker = await speakerFor();
3512
+ try {
3513
+ return await speaker(line);
3514
+ } catch (err) {
3515
+ throw new EmitDemoError(`${label}: the narration could not be spoken: ${err instanceof Error ? err.message : String(err)}`);
3516
+ }
3517
+ }
3518
+ function deadAirRefusal(problems) {
3519
+ return `the demo holds dead air: ${problems.join("; ")} \u2014 tell each step as a beat with demo.say(), so the demo shows only narrated steps`;
3520
+ }
3521
+ var millis2 = (seconds) => Math.round(seconds * 1e3) / 1e3;
3522
+ async function renderWhole(params) {
3523
+ const { manifest, manifestDir, videoPath, work, speakerFor, narrated, media } = params;
3524
+ const output = join(work, "demo.webm");
3525
+ const chapters = manifest.chapters;
3526
+ const chapterLines = [];
3527
+ const beatLines = [];
3528
+ for (const [index, chapter] of chapters.entries()) {
3529
+ const beats = beatsOf(chapter) ?? [];
3530
+ if (beats.length === 0 && narrated && chapter.narration.trim() !== "") {
3531
+ const speech2 = await speak(speakerFor, chapterLabel(index, chapter.title), chapter.narration);
3532
+ const fit = fitSpeech(speech2.seconds, chapter.end - chapter.start);
3533
+ if (!fit.ok) {
3534
+ throw new EmitDemoError(
3535
+ `${chapterLabel(index, chapter.title)}: the narration runs ${fit.overrunSeconds.toFixed(1)} s too long even at 1.3 times speed \u2014 shorten the line or lengthen the chapter`
3536
+ );
3537
+ }
3538
+ chapterLines.push({ ...speech2, tempo: fit.tempo });
3539
+ } else {
3540
+ chapterLines.push(void 0);
3541
+ }
3542
+ const lines = [];
3543
+ for (const [beatIndex, beat] of beats.entries()) {
3544
+ if (!narrated || beat.narration.trim() === "") {
3545
+ lines.push(void 0);
3546
+ continue;
3547
+ }
3548
+ const label = `${chapterLabel(index, chapter.title)}, beat ${beatIndex + 1}`;
3549
+ const recorded = beat.voice === void 0 ? void 0 : resolve(manifestDir, beat.voice.file);
3550
+ const voice = recorded !== void 0 && existsSync(recorded) ? { wavPath: recorded, seconds: beat.voice.seconds } : await speak(speakerFor, label, beat.narration);
3551
+ const budget = beat.end - beat.start - BEAT_LEAD_IN;
3552
+ const tempo = voice.seconds <= budget ? 1 : voice.seconds / budget;
3553
+ if (tempo > MAX_TEMPO) throw new EmitDemoError(`${label}: the line runs ${(voice.seconds / MAX_TEMPO - budget).toFixed(1)} s too long for its beat even at ${MAX_TEMPO} times speed`);
3554
+ lines.push({ ...voice, tempo });
3555
+ }
3556
+ beatLines.push(lines);
3557
+ }
3558
+ const frame = (at) => Math.round(at * SEGMENT_FPS) / SEGMENT_FPS;
3559
+ const snap = (span) => ({ start: frame(span.start), end: Math.max(frame(span.end), frame(span.start) + 1 / SEGMENT_FPS) });
3560
+ const spoken = (start, line, lead) => line === void 0 ? void 0 : { start: start + lead, end: start + lead + line.seconds / line.tempo };
3561
+ let motion;
3562
+ try {
3563
+ motion = await media.motion(videoPath);
3564
+ } catch (err) {
3565
+ throw new EmitDemoError(`cannot read the motion of ${videoPath}: ${err instanceof Error ? err.message : String(err)}`);
3566
+ }
3567
+ let trimmed = 0;
3568
+ const keep = (span, speech2) => {
3569
+ const kept = keepParts(span, speech2, motion);
3570
+ trimmed += span.end - span.start - kept.reduce((sum, part) => sum + (part.end - part.start), 0);
3571
+ return kept.map(snap);
3572
+ };
3573
+ const shown = chapters.map((chapter, index) => {
3574
+ const beats = beatsOf(chapter) ?? [];
3575
+ const parts = beats.length === 0 ? keep(chapter, spoken(chapter.start, chapterLines[index], SPEECH_LEAD_IN)) : beats.flatMap((beat, beatIndex) => keep(beat, spoken(beat.start, beatLines[index][beatIndex], BEAT_LEAD_IN)));
3576
+ return { ...snap(chapter), beats: parts };
3577
+ });
3578
+ const timeline = planTimeline(shown);
3579
+ const speech = [];
3580
+ for (const [index, chapter] of chapters.entries()) {
3581
+ const line = chapterLines[index];
3582
+ if (line !== void 0) speech.push({ wavPath: line.wavPath, tempo: line.tempo, delaySeconds: millis2(mapTime(timeline, frame(chapter.start)) + SPEECH_LEAD_IN) });
3583
+ for (const [beatIndex, beat] of (beatsOf(chapter) ?? []).entries()) {
3584
+ const beatLine = beatLines[index][beatIndex];
3585
+ if (beatLine !== void 0) speech.push({ wavPath: beatLine.wavPath, tempo: beatLine.tempo, delaySeconds: millis2(mapTime(timeline, frame(beat.start)) + BEAT_LEAD_IN) });
3586
+ }
3587
+ }
3588
+ const viewport = manifest.viewport;
3589
+ try {
3590
+ media.renderSegments({
3591
+ video: videoPath,
3592
+ spans: timeline.spans,
3593
+ outStarts: timeline.outStarts,
3594
+ duration: timeline.duration,
3595
+ crossfade: CROSSFADE,
3596
+ chapterStarts: timeline.chapters.map((range) => range.start),
3597
+ output,
3598
+ narrated,
3599
+ speech,
3600
+ ...viewport === void 0 ? {} : { crop: viewport }
3601
+ });
3602
+ } catch (err) {
3603
+ throw new EmitDemoError(`ffmpeg could not cut the demo: ${String(err)}`);
3604
+ }
3605
+ let measure;
3606
+ try {
3607
+ measure = await media.measure(output, timeline.duration);
3608
+ } catch (err) {
3609
+ throw new EmitDemoError(`cannot measure the demo: ${err instanceof Error ? err.message : String(err)}`);
3610
+ }
3611
+ return {
3612
+ output,
3613
+ duration: timeline.duration,
3614
+ ranges: timeline.chapters.map((range) => ({ start: Number(range.start.toFixed(3)), end: Number(range.end.toFixed(3)) })),
3615
+ leftOut: timeline.leftOut,
3616
+ trimmed,
3617
+ measure
3618
+ };
3619
+ }
3620
+ async function renderOnly(params) {
3621
+ const { opts, manifest, manifestPath, videoPath, narrated, env, home, media, say, output } = params;
3622
+ let videoSeconds;
3623
+ try {
3624
+ videoSeconds = media.probe(videoPath);
3625
+ } catch (err) {
3626
+ throw new EmitDemoError(`cannot read the video ${videoPath}: ${String(err)}`);
3627
+ }
3628
+ const problem = checkManifest({ manifest, videoSeconds });
3629
+ if (problem !== void 0) throw new EmitDemoError(problem);
3630
+ const work = mkdtempSync(join(tmpdir(), "outerlayer-demo-"));
3631
+ try {
3632
+ const speakerFor = lazySpeaker(opts, { env, home, work, say });
3633
+ const whole = await renderWhole({ manifest, manifestDir: dirname(manifestPath), videoPath, work, speakerFor, narrated, media });
3634
+ mkdirSync(dirname(output), { recursive: true });
3635
+ copyFileSync(whole.output, output);
3636
+ const deadAir = deadAirProblems(whole.measure);
3637
+ const note = speechNote(whole.measure, narrated);
3638
+ const failures = manifest.chapters.flatMap((chapter, index) => {
3639
+ const failed = chapter.checks.filter((check) => !check.passed);
3640
+ return failed.length === 0 ? [] : [`${chapterLabel(index, chapter.title)} failed: ${failed.map((check) => `"${check.text}"`).join(", ")}`];
3641
+ });
3642
+ const longest = whole.measure.deadAir[0];
3643
+ const summary = {
3644
+ output,
3645
+ seconds: Number(whole.duration.toFixed(2)),
3646
+ leftOutSeconds: Number(whole.leftOut.toFixed(2)),
3647
+ deadAirCutSeconds: Number(whole.trimmed.toFixed(2)),
3648
+ chapters: manifest.chapters.map((chapter, index) => ({ criterionId: chapter.criterionId, title: chapter.title, range: whole.ranges[index] })),
3649
+ measure: {
3650
+ frozenSeconds: Number(whole.measure.frozen.toFixed(2)),
3651
+ spokenSeconds: Number(whole.measure.spoken.toFixed(2)),
3652
+ longestDeadAir: longest === void 0 ? 0 : Number((longest.end - longest.start).toFixed(2))
3653
+ },
3654
+ deadAir,
3655
+ ...note === void 0 ? {} : { note },
3656
+ failures
3657
+ };
3658
+ writeFileSync(`${output}.json`, `${JSON.stringify(summary, null, 2)}
3659
+ `);
3660
+ const share = (part) => `${whole.duration === 0 ? 0 : Math.round(part / whole.duration * 100)}%`;
3661
+ const text = opts.json ? JSON.stringify(summary) : [
3662
+ ...deadAir.map((line) => `${C.red}\u2717${C.reset} ${line}`),
3663
+ ...note === void 0 ? [] : [`${C.yellow}!${C.reset} ${note}`],
3664
+ `${deadAir.length === 0 ? `${C.green}\u2713${C.reset}` : `${C.red}\u2717${C.reset}`} demo rendered ${C.dim}\xB7${C.reset} ${output} ${C.dim}\xB7${C.reset} ${summary.seconds} s ${C.dim}\xB7${C.reset} speech ${share(whole.measure.spoken)} ${C.dim}\xB7${C.reset} ${summary.leftOutSeconds} s of setup and dead air left out`
3665
+ ].join("\n");
3666
+ if (!opts.quiet) process.stdout.write(`${text}
3667
+ `);
3668
+ const exitCode = deadAir.length > 0 || failures.length > 0 ? 1 : 0;
3669
+ return { exitCode, output: text, failures, recordArtifactId: "", video: { omitted: "size", bytes: 0 }, clips: [], screenshots: [], differences: [] };
3670
+ } finally {
3671
+ rmSync(work, { recursive: true });
3672
+ }
3673
+ }
3302
3674
 
3303
- export { EmitDemoError, runEmitDemo };
3675
+ export { EmitDemoError, realSpeaker, runEmitDemo };