demowright 2.0.6 → 2.0.8

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/dist/config.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as QaHudOptions } from "./setup-D-Ut4FYK.mjs";
1
+ import { t as QaHudOptions } from "./setup-Dwx0Dt54.mjs";
2
2
  import { PlaywrightTestConfig } from "@playwright/test";
3
3
 
4
4
  //#region src/config.d.ts
package/dist/config.mjs CHANGED
@@ -1,5 +1,8 @@
1
- import { n as defaultOptions } from "./setup-B6UqVosV.mjs";
1
+ import { n as defaultOptions } from "./setup-BwdzChHS.mjs";
2
2
  import { createRequire } from "node:module";
3
+ import { execSync } from "node:child_process";
4
+ import { mkdirSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
3
6
  //#region src/config.ts
4
7
  /**
5
8
  * Approach 3: Config helper (one line in playwright.config.ts).
@@ -30,7 +33,71 @@ function withDemowright(config, options) {
30
33
  const flag = `--require ${registerPath}`;
31
34
  const existing = process.env.NODE_OPTIONS || "";
32
35
  if (!existing.includes(flag)) process.env.NODE_OPTIONS = existing ? `${existing} ${flag}` : flag;
36
+ if (opts.audio) {
37
+ const display = findLocalDisplay();
38
+ if (display) {
39
+ process.env.DISPLAY = display;
40
+ const use = config.use ??= {};
41
+ const launch = use.launchOptions ??= {};
42
+ if (launch.headless === void 0) launch.headless = false;
43
+ }
44
+ const pipePath = setupPulsePipeSink(opts.outputDir);
45
+ if (pipePath) process.env.__DEMOWRIGHT_PULSE_PIPE = pipePath;
46
+ }
33
47
  return config;
34
48
  }
49
+ /**
50
+ * Create a PulseAudio pipe-sink in the main process.
51
+ * Returns the FIFO path, or undefined if PulseAudio is unavailable.
52
+ */
53
+ function setupPulsePipeSink(outputDir) {
54
+ try {
55
+ execSync("pactl info", { stdio: "pipe" });
56
+ } catch {
57
+ return;
58
+ }
59
+ const tmpDir = join(process.cwd(), outputDir, "tmp");
60
+ mkdirSync(tmpDir, { recursive: true });
61
+ const pipePath = join(tmpDir, `pulse-pipe-${Date.now()}.raw`);
62
+ try {
63
+ const modules = execSync("pactl list modules short", { encoding: "utf-8" });
64
+ for (const line of modules.split("\n")) if (line.includes("demowright_sink")) {
65
+ const modId = line.split(" ")[0];
66
+ try {
67
+ execSync(`pactl unload-module ${modId}`, { stdio: "pipe" });
68
+ } catch {}
69
+ }
70
+ } catch {}
71
+ try {
72
+ execSync(`pactl load-module module-pipe-sink sink_name=demowright_sink file="${pipePath}" rate=44100 channels=2 format=s16le sink_properties=device.description="Demowright_Audio_Capture"`, { stdio: "pipe" });
73
+ execSync("pactl set-default-sink demowright_sink", { stdio: "pipe" });
74
+ return pipePath;
75
+ } catch {
76
+ return;
77
+ }
78
+ }
79
+ /**
80
+ * Find a local X display (Xvfb) suitable for headed browser.
81
+ * Prefers :99, then :98, then any local display.
82
+ */
83
+ function findLocalDisplay() {
84
+ try {
85
+ const displays = readdirSync("/tmp/.X11-unix").filter((s) => s.startsWith("X")).map((s) => `:${s.slice(1)}`).sort((a, b) => {
86
+ const na = parseInt(a.slice(1)), nb = parseInt(b.slice(1));
87
+ if (na >= 98) return nb >= 98 ? na - nb : -1;
88
+ if (nb >= 98) return 1;
89
+ return na - nb;
90
+ });
91
+ for (const d of displays) try {
92
+ execSync(`DISPLAY=${d} xdpyinfo`, {
93
+ stdio: "pipe",
94
+ timeout: 2e3
95
+ });
96
+ return d;
97
+ } catch {}
98
+ } catch {}
99
+ const cur = process.env.DISPLAY;
100
+ if (cur && !cur.includes("localhost") && !cur.includes(":10")) return cur;
101
+ }
35
102
  //#endregion
36
103
  export { withDemowright };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { installAutoAnnotate } from "./auto-annotate.mjs";
2
- import { a as AudioWriter, n as TtsProvider, r as applyHud, t as QaHudOptions } from "./setup-D-Ut4FYK.mjs";
2
+ import { a as AudioWriter, n as TtsProvider, r as applyHud, t as QaHudOptions } from "./setup-Dwx0Dt54.mjs";
3
3
  import { annotate, caption, clickEl, hudWait, moveTo, moveToEl, narrate, subtitle, typeKeys } from "./helpers.mjs";
4
4
  import { OutroOptions, PaceFn, RenderOptions, TimelineEntry, TitleOptions, VideoScriptResult, buildFfmpegCommand, createVideoScript } from "./video-script.mjs";
5
5
  import { Page as Page$1, expect } from "@playwright/test";
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { r as AudioWriter, t as applyHud } from "./setup-B6UqVosV.mjs";
1
+ import { r as AudioWriter, t as applyHud } from "./setup-BwdzChHS.mjs";
2
2
  import { i as getGlobalTtsProvider, s as init_hud_registry } from "./hud-registry-Wfd4b4Nu.mjs";
3
3
  import { buildFfmpegCommand, createVideoScript, t as init_video_script } from "./video-script.mjs";
4
4
  import { annotate, caption, clickEl, hudWait, moveTo, moveToEl, narrate, subtitle, typeKeys } from "./helpers.mjs";
@@ -1,8 +1,8 @@
1
1
  import { r as __toCommonJS } from "./chunk-C0p4GxOx.mjs";
2
2
  import { a as getRenderJob, l as registerHudPage, n as getCurrentSpec, s as init_hud_registry, t as getAudioSegments, u as setGlobalOutputDir } from "./hud-registry-Wfd4b4Nu.mjs";
3
3
  import { n as video_script_exports, t as init_video_script } from "./video-script.mjs";
4
- import { execSync } from "node:child_process";
5
- import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { execSync, spawn } from "node:child_process";
5
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  //#region src/hud-overlay.ts
8
8
  /**
@@ -253,6 +253,7 @@ function audioCaptureMain() {
253
253
  origConnect.call(gain, processor);
254
254
  origConnect.call(processor, dest);
255
255
  interceptors.set(dest, gain);
256
+ if (ctx.state === "suspended") ctx.resume?.();
256
257
  return gain;
257
258
  }
258
259
  AudioNode.prototype.connect = function(dest, output, input) {
@@ -277,6 +278,7 @@ function audioCaptureMain() {
277
278
  try {
278
279
  const ctx = new AudioContext();
279
280
  ctx.createMediaElementSource(this).connect(ctx.destination);
281
+ if (ctx.state === "suspended") ctx.resume();
280
282
  } catch {}
281
283
  }
282
284
  return origPlay.call(this);
@@ -294,14 +296,24 @@ var AudioWriter = class {
294
296
  chunks = [];
295
297
  sampleRate = 44100;
296
298
  channels = 2;
299
+ startMs = 0;
297
300
  /**
298
301
  * Called from the browser via page.exposeFunction.
299
302
  * Receives interleaved stereo float32 samples.
300
303
  */
301
304
  addChunk(samples, sampleRate) {
305
+ if (this.chunks.length === 0) this.startMs = Date.now();
302
306
  this.sampleRate = sampleRate;
303
307
  this.chunks.push(new Float32Array(samples));
304
308
  }
309
+ /** Wall-clock time when first chunk arrived */
310
+ get captureStartMs() {
311
+ return this.startMs;
312
+ }
313
+ /** Sample rate of captured audio */
314
+ get rate() {
315
+ return this.sampleRate;
316
+ }
305
317
  /** Total samples collected (interleaved, so / channels for per-channel) */
306
318
  get totalSamples() {
307
319
  return this.chunks.reduce((sum, c) => sum + c.length, 0);
@@ -339,6 +351,20 @@ var AudioWriter = class {
339
351
  Buffer.from(int16.buffer).copy(buffer, 44);
340
352
  writeFileSync(filePath, buffer);
341
353
  }
354
+ /**
355
+ * Return all chunks concatenated as interleaved stereo float32.
356
+ */
357
+ toFloat32() {
358
+ const total = this.totalSamples;
359
+ if (total === 0) return new Float32Array(0);
360
+ const out = new Float32Array(total);
361
+ let off = 0;
362
+ for (const chunk of this.chunks) {
363
+ out.set(chunk, off);
364
+ off += chunk.length;
365
+ }
366
+ return out;
367
+ }
342
368
  /** Reset for reuse */
343
369
  clear() {
344
370
  this.chunks = [];
@@ -374,9 +400,11 @@ async function applyHud(context, options) {
374
400
  const contextStartMs = Date.now();
375
401
  await context.addInitScript(generateListenerScript());
376
402
  let audioWriter;
403
+ let pulseCapture;
377
404
  if (opts.audio) {
378
405
  audioWriter = new AudioWriter();
379
406
  await context.addInitScript(generateAudioCaptureScript());
407
+ pulseCapture = startPulseCapture(opts.outputDir);
380
408
  }
381
409
  const hudOpts = {
382
410
  cursor: opts.cursor,
@@ -405,10 +433,13 @@ async function applyHud(context, options) {
405
433
  const outDir = join(process.cwd(), opts.outputDir);
406
434
  const tmpDir = join(outDir, "tmp");
407
435
  mkdirSync(tmpDir, { recursive: true });
436
+ const gitignorePath = join(outDir, ".gitignore");
437
+ if (!existsSync(gitignorePath)) writeFileSync(gitignorePath, "*\n");
408
438
  const audioPath = typeof opts.audio === "string" ? opts.audio : join(tmpDir, `demowright-audio-${Date.now()}.wav`);
409
439
  const allPages = [...context.pages()];
410
440
  context.on("page", (pg) => allPages.push(pg));
411
441
  context.on("close", () => {
442
+ const pulseWavPath = pulseCapture?.stop();
412
443
  for (const pg of allPages) {
413
444
  const job = getRenderJob(pg);
414
445
  if (job) {
@@ -418,19 +449,23 @@ async function applyHud(context, options) {
418
449
  }
419
450
  const segments = [];
420
451
  for (const pg of allPages) segments.push(...getAudioSegments(pg));
452
+ const hasTts = segments.length > 0;
453
+ const hasBrowserAudio = audioWriter.totalSamples > 0;
454
+ const hasPulseAudio = pulseWavPath && existsSync(pulseWavPath);
455
+ if (!hasTts && !hasBrowserAudio && !hasPulseAudio) return;
421
456
  let audioOffsetMs = 0;
422
- if (segments.length > 0) {
423
- const firstSegMs = segments[0].timestampMs;
457
+ if (hasTts || hasPulseAudio) {
458
+ const firstSegMs = hasTts ? segments[0].timestampMs : contextStartMs;
424
459
  audioOffsetMs = firstSegMs - contextStartMs;
425
- buildAndSaveAudioTrack(segments, audioPath, firstSegMs);
426
- } else if (audioWriter.totalSamples > 0) audioWriter.save(audioPath);
427
- else return;
460
+ buildAndSaveAudioTrack(segments, audioPath, firstSegMs, hasBrowserAudio ? audioWriter : void 0, contextStartMs, hasPulseAudio ? pulseWavPath : void 0);
461
+ } else audioWriter.save(audioPath);
462
+ if (hasPulseAudio) console.log(`[demowright] Pulse audio captured: ${pulseWavPath}`);
428
463
  const mp4Path = join(outDir, `${getCurrentSpec() ?? pageNames[0] ?? `demowright-${Date.now()}`}.mp4`);
429
464
  const trimSec = (audioOffsetMs / 1e3).toFixed(3);
430
465
  let muxed = false;
431
466
  for (const videoPath of videoPaths) try {
432
467
  if (!existsSync(videoPath)) continue;
433
- execSync(`ffmpeg -y -ss ${trimSec} -i "${videoPath}" -i "${audioPath}" -c:v libx264 -preset fast -c:a aac -shortest "${mp4Path}"`, { stdio: "pipe" });
468
+ execSync(`ffmpeg -y -ss ${trimSec} -i "${videoPath}" -i "${audioPath}" -c:v libx264 -preset fast -crf 28 -c:a aac -b:a 64k -shortest "${mp4Path}"`, { stdio: "pipe" });
434
469
  muxed = true;
435
470
  try {
436
471
  unlinkSync(audioPath);
@@ -439,7 +474,7 @@ async function applyHud(context, options) {
439
474
  } catch {}
440
475
  if (!muxed) {
441
476
  console.log(`[demowright] Audio saved: ${audioPath}`);
442
- console.log(`[demowright] Mux: ffmpeg -y -ss ${trimSec} -i <video.webm> -i "${audioPath}" -c:v libx264 -preset fast -c:a aac -shortest "${mp4Path}"`);
477
+ console.log(`[demowright] Mux: ffmpeg -y -ss ${trimSec} -i <video.webm> -i "${audioPath}" -c:v libx264 -preset fast -crf 28 -c:a aac -b:a 64k -shortest "${mp4Path}"`);
443
478
  }
444
479
  });
445
480
  }
@@ -548,11 +583,12 @@ async function setupAudioCapture(page, writer) {
548
583
  * wall-clock timestamps. Silence fills gaps between segments.
549
584
  * This eliminates drift caused by page.evaluate overhead.
550
585
  */
551
- function buildAndSaveAudioTrack(segments, outputPath, contextStartMs) {
552
- if (segments.length === 0) return;
553
- const firstBuf = segments[0].wavBuf;
554
- if (firstBuf.indexOf("data") + 8 < 8) return;
555
- const sampleRate = firstBuf.readUInt32LE(24);
586
+ function buildAndSaveAudioTrack(segments, outputPath, contextStartMs, browserAudio, contextCreationMs, pulseWavPath) {
587
+ if (segments.length === 0 && !browserAudio && !pulseWavPath) return;
588
+ const firstBuf = segments[0]?.wavBuf;
589
+ const dataOffset0 = firstBuf ? firstBuf.indexOf("data") + 8 : -1;
590
+ if (segments.length > 0 && dataOffset0 < 8) return;
591
+ const sampleRate = firstBuf ? firstBuf.readUInt32LE(24) : browserAudio?.rate ?? 44100;
556
592
  const channels = 2;
557
593
  const baseMs = contextStartMs;
558
594
  let totalMs = 0;
@@ -565,6 +601,22 @@ function buildAndSaveAudioTrack(segments, outputPath, contextStartMs) {
565
601
  const endMs = seg.timestampMs - baseMs + segDur;
566
602
  if (endMs > totalMs) totalMs = endMs;
567
603
  }
604
+ if (browserAudio && browserAudio.totalSamples > 0) {
605
+ const browserStartMs = browserAudio.captureStartMs;
606
+ const browserDurMs = browserAudio.duration * 1e3;
607
+ const browserEndMs = browserStartMs - baseMs + browserDurMs;
608
+ if (browserEndMs > totalMs) totalMs = browserEndMs;
609
+ }
610
+ if (pulseWavPath && existsSync(pulseWavPath)) try {
611
+ const pBuf = readFileSync(pulseWavPath);
612
+ const pDoff = pBuf.indexOf("data");
613
+ if (pDoff >= 0) {
614
+ const pSr = pBuf.readUInt32LE(24);
615
+ const pCh = pBuf.readUInt16LE(22);
616
+ const pDurMs = pBuf.readUInt32LE(pDoff + 4) / 2 / pCh / pSr * 1e3;
617
+ if (pDurMs > totalMs) totalMs = pDurMs;
618
+ }
619
+ } catch {}
568
620
  const totalSamples = Math.ceil(totalMs / 1e3 * sampleRate * channels);
569
621
  const trackBuffer = new Float32Array(totalSamples);
570
622
  for (const seg of segments) {
@@ -588,6 +640,58 @@ function buildAndSaveAudioTrack(segments, outputPath, contextStartMs) {
588
640
  const offsetSamples = Math.floor(offsetMs / 1e3 * sampleRate) * channels;
589
641
  for (let i = 0; i < stereo.length && offsetSamples + i < trackBuffer.length; i++) trackBuffer[offsetSamples + i] += stereo[i];
590
642
  }
643
+ if (browserAudio && browserAudio.totalSamples > 0) {
644
+ const browserPcm = browserAudio.toFloat32();
645
+ const browserOffsetMs = browserAudio.captureStartMs - baseMs;
646
+ const browserOffsetSamples = Math.max(0, Math.floor(browserOffsetMs / 1e3 * sampleRate) * channels);
647
+ if (browserAudio.rate === sampleRate) for (let i = 0; i < browserPcm.length && browserOffsetSamples + i < trackBuffer.length; i++) trackBuffer[browserOffsetSamples + i] += browserPcm[i];
648
+ else {
649
+ const ratio = browserAudio.rate / sampleRate;
650
+ const outLen = Math.floor(browserPcm.length / ratio);
651
+ for (let i = 0; i < outLen && browserOffsetSamples + i < trackBuffer.length; i++) {
652
+ const srcIdx = i * ratio;
653
+ const lo = Math.floor(srcIdx);
654
+ const hi = Math.min(lo + 1, browserPcm.length - 1);
655
+ const frac = srcIdx - lo;
656
+ trackBuffer[browserOffsetSamples + i] += browserPcm[lo] * (1 - frac) + browserPcm[hi] * frac;
657
+ }
658
+ }
659
+ }
660
+ if (pulseWavPath && existsSync(pulseWavPath)) try {
661
+ const pulseBuf = readFileSync(pulseWavPath);
662
+ const pDoff = pulseBuf.indexOf("data");
663
+ if (pDoff >= 0) {
664
+ const pSr = pulseBuf.readUInt32LE(24);
665
+ const pCh = pulseBuf.readUInt16LE(22);
666
+ const pBps = pulseBuf.readUInt16LE(34);
667
+ const pcmData = pulseBuf.subarray(pDoff + 8);
668
+ const bytesPerSample = pBps / 8;
669
+ const sampleCount = Math.floor(pcmData.length / bytesPerSample);
670
+ const float32 = new Float32Array(sampleCount);
671
+ for (let i = 0; i < sampleCount; i++) if (pBps === 16) float32[i] = pcmData.readInt16LE(i * 2) / 32768;
672
+ else if (pBps === 32) float32[i] = pcmData.readFloatLE(i * 4);
673
+ const stereo = pCh === 1 ? (() => {
674
+ const s = new Float32Array(sampleCount * 2);
675
+ for (let i = 0; i < sampleCount; i++) {
676
+ s[i * 2] = float32[i];
677
+ s[i * 2 + 1] = float32[i];
678
+ }
679
+ return s;
680
+ })() : float32;
681
+ if (pSr === sampleRate) for (let i = 0; i < stereo.length && i < trackBuffer.length; i++) trackBuffer[i] += stereo[i];
682
+ else {
683
+ const ratio = pSr / sampleRate;
684
+ const outLen = Math.min(Math.floor(stereo.length / ratio), trackBuffer.length);
685
+ for (let i = 0; i < outLen; i++) {
686
+ const srcIdx = i * ratio;
687
+ const lo = Math.floor(srcIdx);
688
+ const hi = Math.min(lo + 1, stereo.length - 1);
689
+ const frac = srcIdx - lo;
690
+ trackBuffer[i] += stereo[lo] * (1 - frac) + stereo[hi] * frac;
691
+ }
692
+ }
693
+ }
694
+ } catch {}
591
695
  const int16 = new Int16Array(trackBuffer.length);
592
696
  for (let i = 0; i < trackBuffer.length; i++) {
593
697
  const s = Math.max(-1, Math.min(1, trackBuffer[i]));
@@ -639,7 +743,7 @@ function finalizeRenderJob(job, videoPaths) {
639
743
  `-i "${job.wavPath}"`,
640
744
  chapterArgs,
641
745
  vf,
642
- `-c:v libx264 -preset fast`,
746
+ `-c:v libx264 -preset fast -crf 28`,
643
747
  `-c:a aac`,
644
748
  `-shortest`,
645
749
  `"${job.mp4Path}"`
@@ -664,5 +768,114 @@ function finalizeRenderJob(job, videoPaths) {
664
768
  }
665
769
  }
666
770
  }
771
+ function startPulseCapture(outputDir) {
772
+ const g = globalThis;
773
+ if (g.__qaHudPulseCapture) return g.__qaHudPulseCapture;
774
+ const preCreatedPipe = process.env.__DEMOWRIGHT_PULSE_PIPE;
775
+ if (!preCreatedPipe) return startPulseCaptureDirectly(outputDir);
776
+ const tmpDir = join(process.cwd(), outputDir, "tmp");
777
+ mkdirSync(tmpDir, { recursive: true });
778
+ const wavPath = join(tmpDir, `pulse-capture-${Date.now()}.wav`);
779
+ const pipePath = preCreatedPipe;
780
+ const MAX_BYTES = 44100 * 2 * 2 * 300;
781
+ let ringBuffer = Buffer.alloc(0);
782
+ let totalBytesReceived = 0;
783
+ let readerProc;
784
+ try {
785
+ readerProc = spawn("cat", [pipePath], { stdio: [
786
+ "ignore",
787
+ "pipe",
788
+ "ignore"
789
+ ] });
790
+ readerProc.stdout.on("data", (chunk) => {
791
+ totalBytesReceived += chunk.length;
792
+ ringBuffer = Buffer.concat([ringBuffer, chunk]);
793
+ if (ringBuffer.length > MAX_BYTES) {
794
+ const excess = ringBuffer.length - MAX_BYTES;
795
+ const alignedExcess = excess - excess % 4;
796
+ ringBuffer = ringBuffer.subarray(alignedExcess);
797
+ }
798
+ });
799
+ console.log(`[demowright] Pulse pipe-sink capture started: pipe=${pipePath}, PID=${readerProc.pid}`);
800
+ } catch (e) {
801
+ console.log(`[demowright] Pulse pipe reader failed: ${e.message}`);
802
+ return;
803
+ }
804
+ const handle = { stop() {
805
+ g.__qaHudPulseCapture = void 0;
806
+ try {
807
+ readerProc?.kill("SIGTERM");
808
+ } catch {}
809
+ try {
810
+ const modules = execSync("pactl list modules short", { encoding: "utf-8" });
811
+ for (const line of modules.split("\n")) if (line.includes("demowright_sink")) {
812
+ const modId = line.split(" ")[0];
813
+ try {
814
+ execSync(`pactl unload-module ${modId}`, { stdio: "pipe" });
815
+ } catch {}
816
+ }
817
+ } catch {}
818
+ const raw = ringBuffer;
819
+ const durSec = raw.length / 44100 / 4;
820
+ console.log(`[demowright] Pulse audio: ${(totalBytesReceived / 1024 / 1024).toFixed(1)}MB received, ${raw.length} bytes kept, ${durSec.toFixed(1)}s`);
821
+ try {
822
+ unlinkSync(pipePath);
823
+ } catch {}
824
+ if (raw.length === 0) return void 0;
825
+ const maxWavData = 4294967259;
826
+ const pcmData = raw.length > maxWavData ? raw.subarray(raw.length - maxWavData) : raw;
827
+ const hdr = Buffer.alloc(44);
828
+ hdr.write("RIFF", 0);
829
+ hdr.writeUInt32LE(36 + pcmData.length, 4);
830
+ hdr.write("WAVE", 8);
831
+ hdr.write("fmt ", 12);
832
+ hdr.writeUInt32LE(16, 16);
833
+ hdr.writeUInt16LE(1, 20);
834
+ hdr.writeUInt16LE(2, 22);
835
+ hdr.writeUInt32LE(44100, 24);
836
+ hdr.writeUInt32LE(44100 * 2 * 2, 28);
837
+ hdr.writeUInt16LE(4, 32);
838
+ hdr.writeUInt16LE(16, 34);
839
+ hdr.write("data", 36);
840
+ hdr.writeUInt32LE(pcmData.length, 40);
841
+ writeFileSync(wavPath, Buffer.concat([hdr, pcmData]));
842
+ return wavPath;
843
+ } };
844
+ g.__qaHudPulseCapture = handle;
845
+ return handle;
846
+ }
847
+ /**
848
+ * Fallback: create pipe-sink directly (for programmatic applyHud without config.ts).
849
+ */
850
+ function startPulseCaptureDirectly(outputDir) {
851
+ try {
852
+ execSync("pactl info", { stdio: "pipe" });
853
+ } catch {
854
+ return;
855
+ }
856
+ const tmpDir = join(process.cwd(), outputDir, "tmp");
857
+ mkdirSync(tmpDir, { recursive: true });
858
+ const pipePath = join(tmpDir, `pulse-pipe-${Date.now()}.raw`);
859
+ try {
860
+ const modules = execSync("pactl list modules short", { encoding: "utf-8" });
861
+ for (const line of modules.split("\n")) if (line.includes("demowright_sink")) {
862
+ const modId = line.split(" ")[0];
863
+ try {
864
+ execSync(`pactl unload-module ${modId}`, { stdio: "pipe" });
865
+ } catch {}
866
+ }
867
+ } catch {}
868
+ try {
869
+ execSync(`pactl load-module module-pipe-sink sink_name=demowright_sink file="${pipePath}" rate=44100 channels=2 format=s16le sink_properties=device.description="Demowright_Audio_Capture"`, {
870
+ stdio: "pipe",
871
+ encoding: "utf-8"
872
+ }).trim();
873
+ execSync("pactl set-default-sink demowright_sink", { stdio: "pipe" });
874
+ } catch {
875
+ return;
876
+ }
877
+ process.env.__DEMOWRIGHT_PULSE_PIPE = pipePath;
878
+ return startPulseCapture(outputDir);
879
+ }
667
880
  //#endregion
668
881
  export { defaultOptions as n, AudioWriter as r, applyHud as t };
@@ -20,11 +20,16 @@ declare class AudioWriter {
20
20
  private chunks;
21
21
  private sampleRate;
22
22
  private channels;
23
+ private startMs;
23
24
  /**
24
25
  * Called from the browser via page.exposeFunction.
25
26
  * Receives interleaved stereo float32 samples.
26
27
  */
27
28
  addChunk(samples: number[], sampleRate: number): void;
29
+ /** Wall-clock time when first chunk arrived */
30
+ get captureStartMs(): number;
31
+ /** Sample rate of captured audio */
32
+ get rate(): number;
28
33
  /** Total samples collected (interleaved, so / channels for per-channel) */
29
34
  get totalSamples(): number;
30
35
  get duration(): number;
@@ -32,6 +37,10 @@ declare class AudioWriter {
32
37
  * Write collected audio to a WAV file.
33
38
  */
34
39
  save(filePath: string): void;
40
+ /**
41
+ * Return all chunks concatenated as interleaved stereo float32.
42
+ */
43
+ toFloat32(): Float32Array;
35
44
  /** Reset for reuse */
36
45
  clear(): void;
37
46
  }
package/dist/setup.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { i as defaultOptions, n as TtsProvider, r as applyHud, t as QaHudOptions } from "./setup-D-Ut4FYK.mjs";
1
+ import { i as defaultOptions, n as TtsProvider, r as applyHud, t as QaHudOptions } from "./setup-Dwx0Dt54.mjs";
2
2
  export { QaHudOptions, TtsProvider, applyHud, defaultOptions };
package/dist/setup.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as defaultOptions, t as applyHud } from "./setup-B6UqVosV.mjs";
1
+ import { n as defaultOptions, t as applyHud } from "./setup-BwdzChHS.mjs";
2
2
  export { applyHud, defaultOptions };
@@ -1,6 +1,6 @@
1
1
  import { n as __exportAll, t as __esmMin } from "./chunk-C0p4GxOx.mjs";
2
2
  import { c as isHudActive, d as storeAudioSegment, f as storeRenderJob, i as getGlobalTtsProvider, o as getTtsProvider, r as getGlobalOutputDir, s as init_hud_registry } from "./hud-registry-Wfd4b4Nu.mjs";
3
- import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  //#region src/video-script.ts
6
6
  /**
@@ -388,6 +388,8 @@ var init_video_script = __esmMin((() => {
388
388
  const baseName = opts?.baseName ?? `demowright-video-${Date.now()}`;
389
389
  mkdirSync(outputDir, { recursive: true });
390
390
  mkdirSync(tmpDir, { recursive: true });
391
+ const gitignorePath = join(outputDir, ".gitignore");
392
+ if (!existsSync(gitignorePath)) writeFileSync(gitignorePath, "*\n");
391
393
  const wavPath = join(tmpDir, `${baseName}.wav`);
392
394
  const srtPath = join(tmpDir, `${baseName}.srt`);
393
395
  const chaptersPath = join(tmpDir, `${baseName}-chapters.txt`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demowright",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "Playwright video production plugin — cursor overlay, keystroke badges, TTS narration, and narration-driven video scripts for test recordings",
5
5
  "license": "MIT",
6
6
  "repository": {