recordable 0.1.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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +303 -0
  3. package/dist/actions.d.ts +140 -0
  4. package/dist/actions.d.ts.map +1 -0
  5. package/dist/actions.js +184 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/audio/track.d.ts +45 -0
  8. package/dist/audio/track.d.ts.map +1 -0
  9. package/dist/audio/track.js +61 -0
  10. package/dist/audio/track.js.map +1 -0
  11. package/dist/browser/cursor.d.ts +33 -0
  12. package/dist/browser/cursor.d.ts.map +1 -0
  13. package/dist/browser/cursor.js +118 -0
  14. package/dist/browser/cursor.js.map +1 -0
  15. package/dist/browser/dom.d.ts +31 -0
  16. package/dist/browser/dom.d.ts.map +1 -0
  17. package/dist/browser/dom.js +134 -0
  18. package/dist/browser/dom.js.map +1 -0
  19. package/dist/browser/play-button.d.ts +11 -0
  20. package/dist/browser/play-button.d.ts.map +1 -0
  21. package/dist/browser/play-button.js +87 -0
  22. package/dist/browser/play-button.js.map +1 -0
  23. package/dist/browser/runtime.d.ts +66 -0
  24. package/dist/browser/runtime.d.ts.map +1 -0
  25. package/dist/browser/runtime.js +271 -0
  26. package/dist/browser/runtime.js.map +1 -0
  27. package/dist/cli.d.ts +3 -0
  28. package/dist/cli.d.ts.map +1 -0
  29. package/dist/cli.js +131 -0
  30. package/dist/cli.js.map +1 -0
  31. package/dist/compose/mix.d.ts +13 -0
  32. package/dist/compose/mix.d.ts.map +1 -0
  33. package/dist/compose/mix.js +50 -0
  34. package/dist/compose/mix.js.map +1 -0
  35. package/dist/compose/recordable.d.ts +149 -0
  36. package/dist/compose/recordable.d.ts.map +1 -0
  37. package/dist/compose/recordable.js +337 -0
  38. package/dist/compose/recordable.js.map +1 -0
  39. package/dist/compose/session.d.ts +38 -0
  40. package/dist/compose/session.d.ts.map +1 -0
  41. package/dist/compose/session.js +122 -0
  42. package/dist/compose/session.js.map +1 -0
  43. package/dist/config.d.ts +93 -0
  44. package/dist/config.d.ts.map +1 -0
  45. package/dist/config.js +64 -0
  46. package/dist/config.js.map +1 -0
  47. package/dist/errors.d.ts +13 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +21 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/ffmpeg.d.ts +8 -0
  52. package/dist/ffmpeg.d.ts.map +1 -0
  53. package/dist/ffmpeg.js +55 -0
  54. package/dist/ffmpeg.js.map +1 -0
  55. package/dist/formats/json.d.ts +12 -0
  56. package/dist/formats/json.d.ts.map +1 -0
  57. package/dist/formats/json.js +20 -0
  58. package/dist/formats/json.js.map +1 -0
  59. package/dist/formats/markdown/method.d.ts +25 -0
  60. package/dist/formats/markdown/method.d.ts.map +1 -0
  61. package/dist/formats/markdown/method.js +48 -0
  62. package/dist/formats/markdown/method.js.map +1 -0
  63. package/dist/formats/markdown/parse.d.ts +44 -0
  64. package/dist/formats/markdown/parse.d.ts.map +1 -0
  65. package/dist/formats/markdown/parse.js +143 -0
  66. package/dist/formats/markdown/parse.js.map +1 -0
  67. package/dist/fs.d.ts +9 -0
  68. package/dist/fs.d.ts.map +1 -0
  69. package/dist/fs.js +30 -0
  70. package/dist/fs.js.map +1 -0
  71. package/dist/index.d.ts +10 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +6 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/logger.d.ts +21 -0
  76. package/dist/logger.d.ts.map +1 -0
  77. package/dist/logger.js +45 -0
  78. package/dist/logger.js.map +1 -0
  79. package/dist/schema.d.ts +5 -0
  80. package/dist/schema.d.ts.map +1 -0
  81. package/dist/schema.js +100 -0
  82. package/dist/schema.js.map +1 -0
  83. package/dist/script.d.ts +21 -0
  84. package/dist/script.d.ts.map +1 -0
  85. package/dist/script.js +26 -0
  86. package/dist/script.js.map +1 -0
  87. package/dist/targets.d.ts +6 -0
  88. package/dist/targets.d.ts.map +1 -0
  89. package/dist/targets.js +13 -0
  90. package/dist/targets.js.map +1 -0
  91. package/dist/timing.d.ts +41 -0
  92. package/dist/timing.d.ts.map +1 -0
  93. package/dist/timing.js +149 -0
  94. package/dist/timing.js.map +1 -0
  95. package/dist/utils.d.ts +3 -0
  96. package/dist/utils.d.ts.map +1 -0
  97. package/dist/utils.js +8 -0
  98. package/dist/utils.js.map +1 -0
  99. package/dist/validate.d.ts +8 -0
  100. package/dist/validate.d.ts.map +1 -0
  101. package/dist/validate.js +54 -0
  102. package/dist/validate.js.map +1 -0
  103. package/dist/video/recorder.d.ts +57 -0
  104. package/dist/video/recorder.d.ts.map +1 -0
  105. package/dist/video/recorder.js +238 -0
  106. package/dist/video/recorder.js.map +1 -0
  107. package/dist/video/stitch.d.ts +15 -0
  108. package/dist/video/stitch.d.ts.map +1 -0
  109. package/dist/video/stitch.js +111 -0
  110. package/dist/video/stitch.js.map +1 -0
  111. package/dist/voiceover/alignment.d.ts +14 -0
  112. package/dist/voiceover/alignment.d.ts.map +1 -0
  113. package/dist/voiceover/alignment.js +13 -0
  114. package/dist/voiceover/alignment.js.map +1 -0
  115. package/dist/voiceover/cache.d.ts +22 -0
  116. package/dist/voiceover/cache.d.ts.map +1 -0
  117. package/dist/voiceover/cache.js +55 -0
  118. package/dist/voiceover/cache.js.map +1 -0
  119. package/dist/voiceover/compile.d.ts +35 -0
  120. package/dist/voiceover/compile.d.ts.map +1 -0
  121. package/dist/voiceover/compile.js +194 -0
  122. package/dist/voiceover/compile.js.map +1 -0
  123. package/dist/voiceover/elevenlabs.d.ts +16 -0
  124. package/dist/voiceover/elevenlabs.d.ts.map +1 -0
  125. package/dist/voiceover/elevenlabs.js +66 -0
  126. package/dist/voiceover/elevenlabs.js.map +1 -0
  127. package/dist/voiceover/index.d.ts +7 -0
  128. package/dist/voiceover/index.d.ts.map +1 -0
  129. package/dist/voiceover/index.js +8 -0
  130. package/dist/voiceover/index.js.map +1 -0
  131. package/dist/voiceover/mock.d.ts +15 -0
  132. package/dist/voiceover/mock.d.ts.map +1 -0
  133. package/dist/voiceover/mock.js +41 -0
  134. package/dist/voiceover/mock.js.map +1 -0
  135. package/dist/voiceover/types.d.ts +31 -0
  136. package/dist/voiceover/types.d.ts.map +1 -0
  137. package/dist/voiceover/types.js +10 -0
  138. package/dist/voiceover/types.js.map +1 -0
  139. package/package.json +86 -0
  140. package/recordable.schema.json +738 -0
package/dist/timing.js ADDED
@@ -0,0 +1,149 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+ import { getDuration } from "./ffmpeg.js";
3
+ // ─── Gesture timing (single source of truth) ─────────────────────────────────
4
+ //
5
+ // An interactive action isn't instantaneous: the cursor eases to the target,
6
+ // dips to "press", and a click waits a beat to see if it navigated. The runtime
7
+ // *spends* this time; the voiceover compiler must *predict* it, or every wait it
8
+ // computes is short by a gesture and actions drift late. Both import these
9
+ // constants so the prediction can't silently fall out of step.
10
+ /** Cursor "press" dip on click — scale down… */
11
+ export const PRESS_DOWN_MS = 120;
12
+ /** …then settle back. clickEffect spends their sum. */
13
+ export const PRESS_SETTLE_MS = 60;
14
+ /** Total clickEffect cost. */
15
+ const CLICK_PRESS_MS = PRESS_DOWN_MS + PRESS_SETTLE_MS;
16
+ /** Settle beat between arriving at a target and pressing (jitter base). */
17
+ export const PRE_CLICK_MS = 100;
18
+ /** Post-click probe: how long a click waits for a possible navigation to begin. */
19
+ export const NAV_PROBE_MS = 200;
20
+ /** Cursor-move duration bounds; the move eases from its current position. */
21
+ const CURSOR_MOVE_MIN_MS = 150;
22
+ const CURSOR_MOVE_MAX_MS = 700;
23
+ /** Cursor-move duration for a known pixel distance — eased, clamped. */
24
+ export function cursorMoveMs(dist) {
25
+ return Math.min(CURSOR_MOVE_MAX_MS, Math.max(CURSOR_MOVE_MIN_MS, dist * 0.5));
26
+ }
27
+ /** Compile-time estimate of a cursor move when the distance can't be known (no
28
+ * DOM at compile). A single representative value: the true move is distance-
29
+ * based, so a marker may still land a few hundred ms off — the overrun warning
30
+ * catches the cases that matter. */
31
+ const CURSOR_MOVE_ESTIMATE_MS = 350;
32
+ /** Estimated wall-clock an interactive action spends getting the cursor to its
33
+ * target and pressing — *before* its payload (the keystrokes of a `type`, the
34
+ * value-set of a `select`). The compiler adds this to elapsed so the next
35
+ * narrated word is placed after the gesture, not on top of it. With the cursor
36
+ * overlay off, only the real (non-animated) costs remain. */
37
+ export function gestureLeadMs(step, cfg) {
38
+ const cursor = cfg.cursor ?? true;
39
+ const move = cursor ? CURSOR_MOVE_ESTIMATE_MS : 0;
40
+ const press = cursor ? PRE_CLICK_MS + CLICK_PRESS_MS : 0;
41
+ switch (step.action) {
42
+ case "click":
43
+ case "type":
44
+ case "clear":
45
+ // type/clear focus the field with the same move-press-probe as a click.
46
+ return move + press + NAV_PROBE_MS;
47
+ case "select":
48
+ // Animates to the control and presses, but sets the value directly — no
49
+ // mouse click, so no navigation probe.
50
+ return move + press;
51
+ case "hover":
52
+ return move; // moves only — no press
53
+ default:
54
+ return 0; // key / waitFor / pause / … — no cursor travel
55
+ }
56
+ }
57
+ /** How long an action occupies the timeline, so the next wait measures from its end.
58
+ * Omitted durations use the config default, never an elastic fit. The cursor's
59
+ * travel-and-press to a target (`gestureLeadMs`) is added on top, so a click/type
60
+ * doesn't silently push the rest of the paragraph late. */
61
+ export async function actionDurationMs(step, cfg) {
62
+ const lead = gestureLeadMs(step, cfg);
63
+ switch (step.action) {
64
+ case "wait":
65
+ return step.ms ?? 0;
66
+ case "insert": {
67
+ // An inserted clip advances the recorded timeline by its full length; the
68
+ // overlaid narration plays across it, so this much audio-relative time is
69
+ // consumed and the next marker's wait is only the remainder. Resolve the
70
+ // clip against baseDir, the same as the runtime's `_resolveFile`.
71
+ const p = step.path;
72
+ const file = isAbsolute(p) ? p : resolve(cfg.baseDir ?? "", p);
73
+ return (await getDuration(file)) * 1000;
74
+ }
75
+ case "zoom":
76
+ case "resetZoom":
77
+ return step.duration ?? cfg.zoomDuration ?? 600;
78
+ case "scroll":
79
+ return step.duration ?? cfg.scrollDuration ?? 1200;
80
+ case "type": {
81
+ // Travel to the field (lead) then the keystrokes. The runtime's `type` sums
82
+ // its jittered delays to exactly `typingDuration`, so that part agrees.
83
+ const keys = step.duration ??
84
+ typingDuration(step.text ?? "", cfg.typingSpeed ?? 7);
85
+ return lead + keys;
86
+ }
87
+ default:
88
+ return lead; // click / select / hover travel; key / waitFor … are 0
89
+ }
90
+ }
91
+ // ─── Randomness ──────────────────────────────────────────────────────────────
92
+ /** Returns `base` ± `variance` (defaults to ±50% of base). */
93
+ export function jitter(base, variance = 0.5) {
94
+ return base + (Math.random() - 0.5) * base * variance * 2;
95
+ }
96
+ // ─── Deterministic typing ──────────────────────────────────────────────────────
97
+ // `type` is jittered for realism yet deterministic in *total* time: the keystroke
98
+ // delays vary but always sum to `typingDuration`. So the voiceover compiler can
99
+ // predict a `type` action's length from the text alone (no stored duration), and
100
+ // the runtime delivers exactly that.
101
+ /** Deterministic 32-bit string hash (FNV-1a). Seeds the typing PRNG so the same
102
+ * text always types with the same rhythm (reproducible recordings). */
103
+ export function hashString(s) {
104
+ let h = 0x811c9dc5;
105
+ for (let i = 0; i < s.length; i++) {
106
+ h ^= s.charCodeAt(i);
107
+ h = Math.imul(h, 0x01000193);
108
+ }
109
+ return h >>> 0;
110
+ }
111
+ /** mulberry32 PRNG → a function yielding floats in [0, 1). Pure integer math,
112
+ * platform-independent, so a given seed reproduces the same sequence anywhere. */
113
+ export function rng(seed) {
114
+ let a = seed >>> 0;
115
+ return () => {
116
+ a = (a + 0x6d2b79f5) | 0;
117
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
118
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
119
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
120
+ };
121
+ }
122
+ /** Total time (ms) a `type` action occupies — a pure function of text length and
123
+ * speed (cps). This is the contract the voiceover compiler estimates against, so
124
+ * it MUST stay identical to the compiler's `type` estimate. Jitter never alters it. */
125
+ export function typingDuration(text, speed) {
126
+ return Math.round((text.length / (speed > 0 ? speed : 1)) * 1000);
127
+ }
128
+ /** Per-keystroke delays (ms) that sum to exactly `total`, with seeded, zero-sum
129
+ * jitter. Returns `[leadPause, delayAfterChar1, …]` (lead beat + one per code
130
+ * point). Punctuation gets a heavier structural weight (a natural micro-pause),
131
+ * but all weights are normalised back onto `total` so the sum is invariant. */
132
+ export function typingGaps(text, speed, total = typingDuration(text, speed), amount = 0.35) {
133
+ const chars = [...text];
134
+ if (chars.length === 0)
135
+ return [];
136
+ const a = Math.min(Math.max(amount, 0), 0.95); // keep weights strictly positive
137
+ const next = rng(hashString(text));
138
+ const LEAD_W = 1.2;
139
+ const PUNCT_W = 1.8;
140
+ const perturb = (w) => w * (1 + a * (next() - 0.5) * 2);
141
+ const weights = [perturb(LEAD_W)];
142
+ for (const ch of chars) {
143
+ const structural = ch === " " || ch === "." || ch === "," || ch === "\n" ? PUNCT_W : 1;
144
+ weights.push(perturb(structural));
145
+ }
146
+ const sum = weights.reduce((acc, w) => acc + w, 0);
147
+ return weights.map((w) => (total * w) / sum);
148
+ }
149
+ //# sourceMappingURL=timing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timing.js","sourceRoot":"","sources":["../src/timing.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ export declare function sleep(ms: number): Promise<void>;
2
+ export declare function truncate(text: string, max?: number): string;
3
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAEA,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/C;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,SAAK,GAAG,MAAM,CAEvD"}
package/dist/utils.js ADDED
@@ -0,0 +1,8 @@
1
+ // Tiny, dependency-free helpers shared across layers.
2
+ export function sleep(ms) {
3
+ return new Promise((r) => setTimeout(r, ms));
4
+ }
5
+ export function truncate(text, max = 40) {
6
+ return text.length > max ? text.slice(0, max) + "…" : text;
7
+ }
8
+ //# sourceMappingURL=utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,sDAAsD;AAEtD,MAAM,UAAU,KAAK,CAAC,EAAU;IAC9B,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,IAAY,EAAE,GAAG,GAAG,EAAE;IAC7C,OAAO,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7D,CAAC"}
@@ -0,0 +1,8 @@
1
+ import type { RecordableConfig, VoiceoverConfig } from "./config.js";
2
+ /** Validate a recording-config object (JSON `config`, frontmatter, or a caller).
3
+ * Returns it typed; throws {@link RecordableError} `CONFIG_INVALID` on a bad shape. */
4
+ export declare function parseConfig(input: unknown): RecordableConfig;
5
+ /** Validate a `voiceover` frontmatter block. Returns it typed; throws
6
+ * {@link RecordableError} `CONFIG_INVALID` on a bad shape. */
7
+ export declare function parseVoiceover(input: unknown): VoiceoverConfig;
8
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AA0CrE;wFACwF;AACxF,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,CAS5D;AAED;+DAC+D;AAC/D,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,eAAe,CAS9D"}
@@ -0,0 +1,54 @@
1
+ import * as z from "zod";
2
+ import { RecordableError } from "./errors.js";
3
+ import { ConfigSchema } from "./config.js";
4
+ // ─── Boundary validation ─────────────────────────────────────────────────────
5
+ //
6
+ // Untrusted config enters from JSON `config` blocks, Markdown frontmatter, and
7
+ // programmatic callers. Zod checks the shape *at the boundary* so a bad value
8
+ // (or a typo'd key) fails with a clear message here, not as a confusing crash
9
+ // deep in a run. Action shapes are validated separately by the manifest in
10
+ // `actions.ts` (which also generates the published JSON Schema) — not duplicated here.
11
+ // Validate against a copy of the config schema with every `.default()` stripped
12
+ // and each field made optional. A provided config then passes through with only
13
+ // its own keys (defaults are applied later, when resolving against DEFAULT_CONFIG),
14
+ // so the config-layering in `Recordable` stays intact. `.partial()` alone is not
15
+ // enough — the inner `.default()` still fills missing keys.
16
+ const ConfigInputSchema = z.strictObject(Object.fromEntries(Object.entries(ConfigSchema.shape).map(([key, field]) => [
17
+ key,
18
+ (field instanceof z.ZodDefault ? field.def.innerType : field).optional(),
19
+ ])));
20
+ const VoiceoverSchema = z.strictObject({
21
+ provider: z.string().optional(),
22
+ voiceId: z.string().optional(),
23
+ modelId: z.string().optional(),
24
+ apiKey: z.string().optional(),
25
+ voiceSettings: z.record(z.string(), z.number()).optional(),
26
+ format: z.string().optional(),
27
+ });
28
+ /** One readable line per issue: `<label>.<path>: <message>`. */
29
+ function describe(label, issues) {
30
+ const parts = issues.map((issue) => {
31
+ const path = issue.path.join(".");
32
+ return `${path ? `${label}.${path}` : label}: ${issue.message}`;
33
+ });
34
+ return parts.join("; ");
35
+ }
36
+ /** Validate a recording-config object (JSON `config`, frontmatter, or a caller).
37
+ * Returns it typed; throws {@link RecordableError} `CONFIG_INVALID` on a bad shape. */
38
+ export function parseConfig(input) {
39
+ const result = ConfigInputSchema.safeParse(input ?? {});
40
+ if (!result.success) {
41
+ throw new RecordableError("CONFIG_INVALID", `Invalid config — ${describe("config", result.error.issues)}`);
42
+ }
43
+ return result.data;
44
+ }
45
+ /** Validate a `voiceover` frontmatter block. Returns it typed; throws
46
+ * {@link RecordableError} `CONFIG_INVALID` on a bad shape. */
47
+ export function parseVoiceover(input) {
48
+ const result = VoiceoverSchema.safeParse(input ?? {});
49
+ if (!result.success) {
50
+ throw new RecordableError("CONFIG_INVALID", `Invalid voiceover config — ${describe("voiceover", result.error.issues)}`);
51
+ }
52
+ return result.data;
53
+ }
54
+ //# sourceMappingURL=validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG3C,gFAAgF;AAChF,EAAE;AACF,+EAA+E;AAC/E,8EAA8E;AAC9E,8EAA8E;AAC9E,2EAA2E;AAC3E,uFAAuF;AAEvF,gFAAgF;AAChF,gFAAgF;AAChF,oFAAoF;AACpF,iFAAiF;AACjF,4DAA4D;AAC5D,MAAM,iBAAiB,GAAG,CAAC,CAAC,YAAY,CACtC,MAAM,CAAC,WAAW,CAChB,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC;IACvD,GAAG;IACH,CAAC,KAAK,YAAY,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,QAAQ,EAAE;CACzE,CAAC,CACH,CACF,CAAC;AAEF,MAAM,eAAe,GAAG,CAAC,CAAC,YAAY,CAAC;IACrC,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC/B,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC9B,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC9B,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC7B,aAAa,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,QAAQ,EAAE;IAC1D,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CAC9B,CAAC,CAAC;AAEH,gEAAgE;AAChE,SAAS,QAAQ,CAAC,KAAa,EAAE,MAA0B;IACzD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;QACjC,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAClC,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,KAAK,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC;IAClE,CAAC,CAAC,CAAC;IACH,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC;AAED;wFACwF;AACxF,MAAM,UAAU,WAAW,CAAC,KAAc;IACxC,MAAM,MAAM,GAAG,iBAAiB,CAAC,SAAS,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;IACxD,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,MAAM,IAAI,eAAe,CACvB,gBAAgB,EAChB,oBAAoB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAC9D,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC,IAAwB,CAAC;AACzC,CAAC;AAED;+DAC+D;AAC/D,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,MAAM,MAAM,GAAG,eAAe,CAAC,SAAS,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;IACtD,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,MAAM,IAAI,eAAe,CACvB,gBAAgB,EAChB,8BAA8B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAC3E,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAAC;AACrB,CAAC"}
@@ -0,0 +1,57 @@
1
+ import { type Page } from "puppeteer";
2
+ import { type Logger } from "../logger.js";
3
+ import { type InsertOptions, type ResolvedConfig } from "../config.js";
4
+ import { type Segment } from "./stitch.js";
5
+ /**
6
+ * Recorded-time position now: finalised segment time plus the in-flight segment's
7
+ * elapsed time (`frames / fps`). Off-camera (paused) stretches capture no frames,
8
+ * so they never advance this clock — audio lands in recorded time.
9
+ */
10
+ export declare function timelineMs(completedMs: number, segmentFrames: number, fps: number, capturing: boolean): number;
11
+ export declare class Recorder {
12
+ private readonly getCfg;
13
+ private readonly log;
14
+ private tmpDirPath;
15
+ private segmentList;
16
+ private currentSegment;
17
+ private cdp;
18
+ private ffmpegProc;
19
+ private frameTicker;
20
+ private latestFrame;
21
+ private segmentFrames;
22
+ private segmentFps;
23
+ private captureError;
24
+ private completedMs;
25
+ constructor(getCfg: () => ResolvedConfig, log: Logger);
26
+ /** True while a segment is actively capturing frames. */
27
+ get capturing(): boolean;
28
+ /** The captured + inserted segments, in timeline order (for stitching). */
29
+ get segments(): Segment[];
30
+ /** The temp working directory holding segment files. */
31
+ get tmpDir(): string;
32
+ /** Recorded-time position now: finalised segments + the in-flight segment. */
33
+ currentTimelineMs(): number;
34
+ /** Create the temp working directory for segment files. Call once before use. */
35
+ init(): void;
36
+ /** Lazily create the CDP session used for screencast capture. */
37
+ private _ensureCdp;
38
+ /** Begin capturing into a fresh segment. No-op if already capturing. */
39
+ begin(page: Page): Promise<void>;
40
+ /** End the active segment, flushing ffmpeg and keeping it only if it has frames.
41
+ * Pass `silent` to skip the "pause" log (used by `insert`, which seals the
42
+ * current segment as an internal step rather than a user-visible pause). */
43
+ end(silent?: boolean): Promise<void>;
44
+ /**
45
+ * Seal the active segment (silently), normalize the clip to the recording's
46
+ * resolution / fps / codec / pixel format, and append it as the next segment.
47
+ * Fades (ms) are recorded on the segment and applied at stitch time. Doesn't
48
+ * touch recording *intent* — if capture was active the run loop lazily begins a
49
+ * fresh segment before the next action, so recording resumes on its own.
50
+ */
51
+ insert(path: string, options?: InsertOptions): Promise<void>;
52
+ /** Detach the CDP session. Call after the final segment is sealed. */
53
+ dispose(): Promise<void>;
54
+ /** Remove the temp working directory. Call once the output is written. */
55
+ removeTmp(): void;
56
+ }
57
+ //# sourceMappingURL=recorder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder.d.ts","sourceRoot":"","sources":["../../src/video/recorder.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,IAAI,EAAmB,MAAM,WAAW,CAAC;AAGvD,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,aAAa,CAAC;AAa3C;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,EACrB,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,OAAO,GACjB,MAAM,CAGR;AAED,qBAAa,QAAQ;IAiBjB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAjBtB,OAAO,CAAC,UAAU,CAAM;IACxB,OAAO,CAAC,WAAW,CAAiB;IACpC,OAAO,CAAC,cAAc,CAAM;IAC5B,OAAO,CAAC,GAAG,CAA2B;IACtC,OAAO,CAAC,UAAU,CAA6B;IAC/C,OAAO,CAAC,WAAW,CAA+C;IAClE,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,UAAU,CAAK;IAEvB,OAAO,CAAC,YAAY,CAAsB;IAG1C,OAAO,CAAC,WAAW,CAAK;gBAGL,MAAM,EAAE,MAAM,cAAc,EAC5B,GAAG,EAAE,MAAM;IAG9B,yDAAyD;IACzD,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,2EAA2E;IAC3E,IAAI,QAAQ,IAAI,OAAO,EAAE,CAExB;IAED,wDAAwD;IACxD,IAAI,MAAM,IAAI,MAAM,CAEnB;IAED,8EAA8E;IAC9E,iBAAiB,IAAI,MAAM;IAK3B,iFAAiF;IACjF,IAAI,IAAI,IAAI;IAIZ,iEAAiE;YACnD,UAAU;IAaxB,wEAAwE;IAClE,KAAK,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAsEtC;;iFAE6E;IACvE,GAAG,CAAC,MAAM,UAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IA2CxC;;;;;;OAMG;IACG,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAAC,IAAI,CAAC;IAuCtE,sEAAsE;IAChE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAO9B,0EAA0E;IAC1E,SAAS,IAAI,IAAI;CAGlB"}
@@ -0,0 +1,238 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { FFMPEG_PATH, getDuration, runFfmpeg } from "../ffmpeg.js";
6
+ import { RecordableError } from "../errors.js";
7
+ // ─── Video layer: capture ────────────────────────────────────────────────────
8
+ //
9
+ // Captures the page via CDP `Page.startScreencast`, pipes JPEG frames into ffmpeg
10
+ // at a steady fps to produce one MP4 per captured stretch, and tracks the
11
+ // recorded-time clock the audio layer positions clips against. Stitching the
12
+ // segments into the final video lives in `./stitch.ts`.
13
+ //
14
+ // Segments are started lazily and ended on pause, so off-camera gaps (page loads,
15
+ // logins, data setup) leave no trace. Config is read through `getCfg` on each call
16
+ // so runtime changes take effect.
17
+ /**
18
+ * Recorded-time position now: finalised segment time plus the in-flight segment's
19
+ * elapsed time (`frames / fps`). Off-camera (paused) stretches capture no frames,
20
+ * so they never advance this clock — audio lands in recorded time.
21
+ */
22
+ export function timelineMs(completedMs, segmentFrames, fps, capturing) {
23
+ const current = capturing && fps > 0 ? (segmentFrames / fps) * 1000 : 0;
24
+ return completedMs + current;
25
+ }
26
+ export class Recorder {
27
+ getCfg;
28
+ log;
29
+ tmpDirPath = "";
30
+ segmentList = [];
31
+ currentSegment = "";
32
+ cdp = null;
33
+ ffmpegProc = null;
34
+ frameTicker = null;
35
+ latestFrame = null;
36
+ segmentFrames = 0;
37
+ segmentFps = 0;
38
+ // A spawn/encode failure on the capture ffmpeg, surfaced at the next end().
39
+ captureError = null;
40
+ // Timeline clock (recorded time, ms) summed over finalised segments.
41
+ completedMs = 0;
42
+ constructor(getCfg, log) {
43
+ this.getCfg = getCfg;
44
+ this.log = log;
45
+ }
46
+ /** True while a segment is actively capturing frames. */
47
+ get capturing() {
48
+ return this.ffmpegProc !== null;
49
+ }
50
+ /** The captured + inserted segments, in timeline order (for stitching). */
51
+ get segments() {
52
+ return this.segmentList;
53
+ }
54
+ /** The temp working directory holding segment files. */
55
+ get tmpDir() {
56
+ return this.tmpDirPath;
57
+ }
58
+ /** Recorded-time position now: finalised segments + the in-flight segment. */
59
+ currentTimelineMs() {
60
+ const fps = this.segmentFps || this.getCfg().fps;
61
+ return timelineMs(this.completedMs, this.segmentFrames, fps, this.capturing);
62
+ }
63
+ /** Create the temp working directory for segment files. Call once before use. */
64
+ init() {
65
+ this.tmpDirPath = mkdtempSync(join(tmpdir(), "recordable-"));
66
+ }
67
+ /** Lazily create the CDP session used for screencast capture. */
68
+ async _ensureCdp(page) {
69
+ if (this.cdp)
70
+ return this.cdp;
71
+ const cdp = await page.createCDPSession();
72
+ cdp.on("Page.screencastFrame", (frame) => {
73
+ this.latestFrame = Buffer.from(frame.data, "base64");
74
+ cdp
75
+ .send("Page.screencastFrameAck", { sessionId: frame.sessionId })
76
+ .catch(() => { });
77
+ });
78
+ this.cdp = cdp;
79
+ return cdp;
80
+ }
81
+ /** Begin capturing into a fresh segment. No-op if already capturing. */
82
+ async begin(page) {
83
+ if (this.ffmpegProc)
84
+ return;
85
+ const cfg = this.getCfg();
86
+ const idx = this.segmentList.length;
87
+ const file = join(this.tmpDirPath, `seg-${String(idx).padStart(3, "0")}.mp4`);
88
+ const { width, height } = cfg.viewport;
89
+ const fps = cfg.fps;
90
+ // Encode a stream of JPEG frames piped on stdin into an MP4 segment.
91
+ const proc = spawn(FFMPEG_PATH, [
92
+ "-y",
93
+ "-f",
94
+ "image2pipe",
95
+ "-framerate",
96
+ String(fps),
97
+ "-i",
98
+ "pipe:0",
99
+ "-r",
100
+ String(fps),
101
+ "-c:v",
102
+ cfg.videoCodec,
103
+ "-preset",
104
+ cfg.videoPreset,
105
+ "-crf",
106
+ String(cfg.videoCrf),
107
+ "-pix_fmt",
108
+ "yuv420p",
109
+ "-vf",
110
+ "pad=ceil(iw/2)*2:ceil(ih/2)*2", // libx264 needs even dimensions
111
+ file,
112
+ ], { stdio: ["pipe", "ignore", "ignore"] });
113
+ proc.on("error", (e) => {
114
+ // Encoding is async; remember the failure and surface it at end() rather
115
+ // than silently dropping a frameless, invalid segment.
116
+ this.captureError = e;
117
+ this.log.error(`ffmpeg: ${String(e)}`);
118
+ });
119
+ this.ffmpegProc = proc;
120
+ this.currentSegment = file;
121
+ this.segmentFrames = 0;
122
+ this.segmentFps = fps;
123
+ this.latestFrame = null;
124
+ // Begin the screencast and push the most recent frame at a steady fps so the
125
+ // output is constant-frame-rate even when the page is idle.
126
+ const cdp = await this._ensureCdp(page);
127
+ await cdp.send("Page.startScreencast", {
128
+ format: "jpeg",
129
+ quality: 90,
130
+ maxWidth: width,
131
+ maxHeight: height,
132
+ everyNthFrame: 1,
133
+ });
134
+ this.frameTicker = setInterval(() => {
135
+ const p = this.ffmpegProc;
136
+ if (!p || !p.stdin?.writable || !this.latestFrame)
137
+ return;
138
+ p.stdin.write(this.latestFrame);
139
+ this.segmentFrames++;
140
+ }, Math.max(1, Math.round(1000 / fps)));
141
+ this.log("Record", idx === 0 ? "start" : `resume (segment ${idx + 1})`);
142
+ }
143
+ /** End the active segment, flushing ffmpeg and keeping it only if it has frames.
144
+ * Pass `silent` to skip the "pause" log (used by `insert`, which seals the
145
+ * current segment as an internal step rather than a user-visible pause). */
146
+ async end(silent = false) {
147
+ if (!this.ffmpegProc)
148
+ return;
149
+ if (this.frameTicker) {
150
+ clearInterval(this.frameTicker);
151
+ this.frameTicker = null;
152
+ }
153
+ await this.cdp?.send("Page.stopScreencast").catch(() => { });
154
+ const proc = this.ffmpegProc;
155
+ this.ffmpegProc = null;
156
+ const frames = this.segmentFrames;
157
+ // Flush stdin and wait for ffmpeg to finish writing the file.
158
+ await new Promise((resolve) => {
159
+ proc.once("close", () => resolve());
160
+ try {
161
+ proc.stdin?.end();
162
+ }
163
+ catch {
164
+ resolve();
165
+ }
166
+ });
167
+ if (this.captureError) {
168
+ const e = this.captureError;
169
+ this.captureError = null;
170
+ throw new RecordableError("FFMPEG_FAILED", `recording capture failed: ${e.message}`, { cause: e });
171
+ }
172
+ // Only keep segments that actually captured frames (avoids empty/invalid mp4).
173
+ if (this.currentSegment && frames > 0) {
174
+ this.segmentList.push({ path: this.currentSegment, fadeIn: 0, fadeOut: 0 });
175
+ this.completedMs +=
176
+ (frames / (this.segmentFps || this.getCfg().fps)) * 1000;
177
+ }
178
+ this.currentSegment = "";
179
+ this.latestFrame = null;
180
+ if (!silent)
181
+ this.log("Record", "pause");
182
+ }
183
+ /**
184
+ * Seal the active segment (silently), normalize the clip to the recording's
185
+ * resolution / fps / codec / pixel format, and append it as the next segment.
186
+ * Fades (ms) are recorded on the segment and applied at stitch time. Doesn't
187
+ * touch recording *intent* — if capture was active the run loop lazily begins a
188
+ * fresh segment before the next action, so recording resumes on its own.
189
+ */
190
+ async insert(path, options = {}) {
191
+ if (!existsSync(path))
192
+ throw new RecordableError("FILE_NOT_FOUND", `insert: file not found: ${path}`);
193
+ await this.end(true);
194
+ const cfg = this.getCfg();
195
+ const idx = this.segmentList.length;
196
+ const file = join(this.tmpDirPath, `seg-${String(idx).padStart(3, "0")}.mp4`);
197
+ const { width, height } = cfg.viewport;
198
+ // Letterbox-fit to the viewport and conform fps/codec/pixel format so the clip
199
+ // is concat-compatible with the captured segments.
200
+ await runFfmpeg([
201
+ "-y",
202
+ "-i",
203
+ path,
204
+ "-an",
205
+ "-vf",
206
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease,` +
207
+ `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${cfg.fps}`,
208
+ "-c:v",
209
+ cfg.videoCodec,
210
+ "-preset",
211
+ cfg.videoPreset,
212
+ "-crf",
213
+ String(cfg.videoCrf),
214
+ "-pix_fmt",
215
+ "yuv420p",
216
+ file,
217
+ ]);
218
+ this.segmentList.push({
219
+ path: file,
220
+ fadeIn: Math.max(0, options.fadeIn ?? 0) / 1000,
221
+ fadeOut: Math.max(0, options.fadeOut ?? 0) / 1000,
222
+ });
223
+ this.completedMs += (await getDuration(file)) * 1000;
224
+ }
225
+ /** Detach the CDP session. Call after the final segment is sealed. */
226
+ async dispose() {
227
+ if (this.cdp) {
228
+ await this.cdp.detach().catch(() => { });
229
+ this.cdp = null;
230
+ }
231
+ }
232
+ /** Remove the temp working directory. Call once the output is written. */
233
+ removeTmp() {
234
+ if (this.tmpDirPath)
235
+ rmSync(this.tmpDirPath, { recursive: true, force: true });
236
+ }
237
+ }
238
+ //# sourceMappingURL=recorder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recorder.js","sourceRoot":"","sources":["../../src/video/recorder.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import { type Logger } from "../logger.js";
2
+ import type { ResolvedConfig } from "../config.js";
3
+ /**
4
+ * One piece of the final timeline: a captured stretch or an inserted clip.
5
+ * `fadeIn`/`fadeOut` (seconds) are non-zero only for inserted clips and request
6
+ * a cross-fade with the neighbouring piece (or with black at the timeline ends).
7
+ */
8
+ export interface Segment {
9
+ path: string;
10
+ fadeIn: number;
11
+ fadeOut: number;
12
+ }
13
+ /** Stitch `segs` into `out`, choosing move / join / cross-fade automatically. */
14
+ export declare function stitch(segs: Segment[], cfg: ResolvedConfig, log: Logger, out: string, tmpDir: string): Promise<void>;
15
+ //# sourceMappingURL=stitch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stitch.d.ts","sourceRoot":"","sources":["../../src/video/stitch.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AASnD;;;;GAIG;AACH,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,iFAAiF;AACjF,wBAAsB,MAAM,CAC1B,IAAI,EAAE,OAAO,EAAE,EACf,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CASf"}