talktrack 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/index.cjs +678 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +200 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.js +658 -0
- package/dist/index.js.map +1 -0
- package/package.json +101 -2
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_child_process = require("node:child_process");
|
|
3
|
+
let node_fs_promises = require("node:fs/promises");
|
|
4
|
+
let node_os = require("node:os");
|
|
5
|
+
let node_path = require("node:path");
|
|
6
|
+
let _playwright_test = require("@playwright/test");
|
|
7
|
+
/**
|
|
8
|
+
* Uniform noise in -spread..spread, used for micro-jitter along the path.
|
|
9
|
+
*/
|
|
10
|
+
function jitter(spread) {
|
|
11
|
+
return (Math.random() - .5) * 2 * spread;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Builds the human-feeling path between two points as a list of intermediate points; the caller paces them out over real wall-clock time so a screen recording actually shows the glide. The last point is always exactly `to`, regardless of jitter or overshoot.
|
|
15
|
+
*/
|
|
16
|
+
function buildHumanPath(from, to) {
|
|
17
|
+
const dx = to.x - from.x;
|
|
18
|
+
const dy = to.y - from.y;
|
|
19
|
+
const distance = Math.hypot(dx, dy);
|
|
20
|
+
if (distance < 2) return [to];
|
|
21
|
+
const steps = Math.max(8, Math.min(34, Math.round(distance / 14)));
|
|
22
|
+
const perpX = -dy / distance;
|
|
23
|
+
const perpY = dx / distance;
|
|
24
|
+
const bowMagnitude = Math.min(40, distance * .12) * (Math.random() < .5 ? 1 : -1);
|
|
25
|
+
const overshoot = distance > 180 && Math.random() < .6;
|
|
26
|
+
const overshootPx = overshoot ? 6 + Math.random() * 10 : 0;
|
|
27
|
+
const OVERSHOOT_WINDOW_START_T = .8;
|
|
28
|
+
const OVERSHOOT_WINDOW_END_T = .97;
|
|
29
|
+
const OVERSHOOT_WINDOW_WIDTH_T = .17;
|
|
30
|
+
const PATH_POINT_JITTER_PX = .6;
|
|
31
|
+
const path = [];
|
|
32
|
+
for (let i = 1; i <= steps; i++) {
|
|
33
|
+
const t = i / steps;
|
|
34
|
+
const eased = easeInOutCubic(t);
|
|
35
|
+
const bow = Math.sin(t * Math.PI) * bowMagnitude;
|
|
36
|
+
let px = from.x + dx * eased + perpX * bow;
|
|
37
|
+
let py = from.y + dy * eased + perpY * bow;
|
|
38
|
+
if (overshoot && t > OVERSHOOT_WINDOW_START_T && t < OVERSHOOT_WINDOW_END_T) {
|
|
39
|
+
const overshootT = (t - OVERSHOOT_WINDOW_START_T) / OVERSHOOT_WINDOW_WIDTH_T;
|
|
40
|
+
const settle = Math.sin(overshootT * Math.PI);
|
|
41
|
+
px += dx / distance * overshootPx * settle;
|
|
42
|
+
py += dy / distance * overshootPx * settle;
|
|
43
|
+
}
|
|
44
|
+
path.push({
|
|
45
|
+
x: px + jitter(PATH_POINT_JITTER_PX),
|
|
46
|
+
y: py + jitter(PATH_POINT_JITTER_PX)
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
path[path.length - 1] = to;
|
|
50
|
+
return path;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Ease-in-out cubic, shared with naturalScrollIntoView's own identical curve: a real scroll (wheel or trackpad) accelerates away from rest and decelerates into rest, it doesn't move at constant speed.
|
|
54
|
+
*/
|
|
55
|
+
function easeInOutCubic(t) {
|
|
56
|
+
return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/timeline.ts
|
|
60
|
+
const recordingStartedAt = /* @__PURE__ */ new WeakMap();
|
|
61
|
+
const captionTimeline = /* @__PURE__ */ new WeakMap();
|
|
62
|
+
/**
|
|
63
|
+
* Called once, as early as possible (installCursor's own first line). First call wins, so a demo that navigates before installing the overlay doesn't shift t0 later than the recording's own real start.
|
|
64
|
+
*/
|
|
65
|
+
function markRecordingStart(page) {
|
|
66
|
+
if (!recordingStartedAt.has(page)) recordingStartedAt.set(page, Date.now());
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Appends one narration event for the page: a string shows that caption from now, null hides whatever is showing. Called by showCaption/hideCaption; direct use is only for advanced driving.
|
|
70
|
+
*/
|
|
71
|
+
function recordCaptionEvent(page, text) {
|
|
72
|
+
const startedAt = recordingStartedAt.get(page) ?? Date.now();
|
|
73
|
+
const events = captionTimeline.get(page) ?? [];
|
|
74
|
+
events.push({
|
|
75
|
+
atMs: Date.now() - startedAt,
|
|
76
|
+
text
|
|
77
|
+
});
|
|
78
|
+
captionTimeline.set(page, events);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Snapshot for the framing pass. Read after the context has closed is fine: the map is keyed by the Page object itself, which outlives its browser context.
|
|
82
|
+
*/
|
|
83
|
+
function getRecordingTimeline(page) {
|
|
84
|
+
return {
|
|
85
|
+
startedAt: recordingStartedAt.get(page) ?? Date.now(),
|
|
86
|
+
events: captionTimeline.get(page) ?? []
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/cursor.ts
|
|
91
|
+
const TALKTRACK_OVERLAY_INIT = () => {
|
|
92
|
+
if (document.getElementById("__talktrack_cursor__")) return;
|
|
93
|
+
const style = document.createElement("style");
|
|
94
|
+
style.textContent = `
|
|
95
|
+
#__talktrack_cursor__ {
|
|
96
|
+
position: fixed; top: 0; left: 0; width: 22px; height: 22px;
|
|
97
|
+
border-radius: 50%; background: rgba(220,38,38,0.88);
|
|
98
|
+
border: 2px solid white; box-shadow: 0 1px 5px rgba(0,0,0,0.6);
|
|
99
|
+
pointer-events: none; z-index: 2147483647; transform: translate(-50%, -50%) scale(1);
|
|
100
|
+
transition: transform 0.12s ease-out, background-color 0.12s ease-out;
|
|
101
|
+
}
|
|
102
|
+
#__talktrack_cursor__.__talktrack_pressed__ {
|
|
103
|
+
transform: translate(-50%, -50%) scale(0.55);
|
|
104
|
+
background: rgba(153,27,27,0.95);
|
|
105
|
+
}
|
|
106
|
+
.__talktrack_ripple__ {
|
|
107
|
+
position: fixed; width: 14px; height: 14px; border-radius: 50%;
|
|
108
|
+
border: 3px solid rgba(220,38,38,0.9); pointer-events: none; z-index: 2147483647;
|
|
109
|
+
transform: translate(-50%, -50%); animation: __talktrack_ripple_anim__ 0.55s ease-out forwards;
|
|
110
|
+
}
|
|
111
|
+
@keyframes __talktrack_ripple_anim__ {
|
|
112
|
+
from { width: 14px; height: 14px; opacity: 1; }
|
|
113
|
+
to { width: 58px; height: 58px; opacity: 0; }
|
|
114
|
+
}
|
|
115
|
+
`;
|
|
116
|
+
document.head.appendChild(style);
|
|
117
|
+
const cursor = document.createElement("div");
|
|
118
|
+
cursor.id = "__talktrack_cursor__";
|
|
119
|
+
document.body.appendChild(cursor);
|
|
120
|
+
window.addEventListener("mousemove", (e) => {
|
|
121
|
+
cursor.style.left = `${String(e.clientX)}px`;
|
|
122
|
+
cursor.style.top = `${String(e.clientY)}px`;
|
|
123
|
+
}, true);
|
|
124
|
+
window.__talktrackPress = (x, y) => {
|
|
125
|
+
cursor.classList.add("__talktrack_pressed__");
|
|
126
|
+
const ripple = document.createElement("div");
|
|
127
|
+
ripple.className = "__talktrack_ripple__";
|
|
128
|
+
ripple.style.left = `${String(x)}px`;
|
|
129
|
+
ripple.style.top = `${String(y)}px`;
|
|
130
|
+
document.body.appendChild(ripple);
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
ripple.remove();
|
|
133
|
+
}, 600);
|
|
134
|
+
};
|
|
135
|
+
window.__talktrackRelease = () => {
|
|
136
|
+
cursor.classList.remove("__talktrack_pressed__");
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
const currentPosition = /* @__PURE__ */ new WeakMap();
|
|
140
|
+
async function glideTo(page, to) {
|
|
141
|
+
const path = buildHumanPath(currentPosition.get(page) ?? {
|
|
142
|
+
x: to.x,
|
|
143
|
+
y: to.y
|
|
144
|
+
}, to);
|
|
145
|
+
const MOVE_STEP_MIN_DELAY_MS = 12;
|
|
146
|
+
const MOVE_STEP_DELAY_RANGE_MS = 10;
|
|
147
|
+
for (const point of path) {
|
|
148
|
+
await page.mouse.move(point.x, point.y);
|
|
149
|
+
await page.waitForTimeout(MOVE_STEP_MIN_DELAY_MS + Math.random() * MOVE_STEP_DELAY_RANGE_MS);
|
|
150
|
+
}
|
|
151
|
+
currentPosition.set(page, to);
|
|
152
|
+
}
|
|
153
|
+
async function tolerateNavigationRace(promise) {
|
|
154
|
+
return promise.catch(() => void 0);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Playwright's own locator.scrollIntoViewIfNeeded() is a synchronous DOM scrollIntoView: an instant jump-cut in a recording, not something a person watching would recognise as scrolling at all. This drives a real sequence of wheel events instead, eased and paced like glideTo's own mouse path, so a recording shows the page actually gliding to the target rather than teleporting to it. Exported (not just used internally by moveAndClick below) so a demo can bring a target into view on its own, with no click attached (for example before narrating over a tall diagram whose own heading satisfies a "content rendered" assertion long before the diagram's body, the actual thing being narrated, has scrolled into the visible viewport).
|
|
158
|
+
*/
|
|
159
|
+
async function naturalScrollIntoView(page, locator) {
|
|
160
|
+
const viewport = page.viewportSize();
|
|
161
|
+
if (!viewport) return;
|
|
162
|
+
const hoverPoint = {
|
|
163
|
+
x: viewport.width / 2,
|
|
164
|
+
y: viewport.height / 2
|
|
165
|
+
};
|
|
166
|
+
await tolerateNavigationRace(page.mouse.move(hoverPoint.x, hoverPoint.y));
|
|
167
|
+
const box = await tolerateNavigationRace(locator.boundingBox());
|
|
168
|
+
if (!box) return;
|
|
169
|
+
const margin = 96;
|
|
170
|
+
if (box.y >= margin && box.y + box.height <= viewport.height - margin) return;
|
|
171
|
+
const totalDelta = box.y + box.height / 2 - viewport.height / 2;
|
|
172
|
+
if (Math.abs(totalDelta) < 12) return;
|
|
173
|
+
const steps = Math.max(7, Math.min(26, Math.round(Math.abs(totalDelta) / 55)));
|
|
174
|
+
let deliveredSoFar = 0;
|
|
175
|
+
const SCROLL_STEP_MIN_DELAY_MS = 14;
|
|
176
|
+
const SCROLL_STEP_DELAY_RANGE_MS = 12;
|
|
177
|
+
for (let i = 1; i <= steps; i++) {
|
|
178
|
+
const target = totalDelta * easeInOutCubic(i / steps);
|
|
179
|
+
const stepDelta = target - deliveredSoFar;
|
|
180
|
+
deliveredSoFar = target;
|
|
181
|
+
await page.mouse.wheel(0, stepDelta);
|
|
182
|
+
await page.waitForTimeout(SCROLL_STEP_MIN_DELAY_MS + Math.random() * SCROLL_STEP_DELAY_RANGE_MS);
|
|
183
|
+
}
|
|
184
|
+
await page.waitForTimeout(140 + Math.random() * 120);
|
|
185
|
+
}
|
|
186
|
+
async function applyCursor(page) {
|
|
187
|
+
await tolerateNavigationRace(page.evaluate(TALKTRACK_OVERLAY_INIT));
|
|
188
|
+
const pos = currentPosition.get(page);
|
|
189
|
+
if (!pos) return;
|
|
190
|
+
await tolerateNavigationRace(page.evaluate(([x, y]) => {
|
|
191
|
+
const cursor = document.getElementById("__talktrack_cursor__");
|
|
192
|
+
if (cursor) {
|
|
193
|
+
cursor.style.left = `${String(x)}px`;
|
|
194
|
+
cursor.style.top = `${String(y)}px`;
|
|
195
|
+
}
|
|
196
|
+
}, [pos.x, pos.y]));
|
|
197
|
+
await tolerateNavigationRace(page.mouse.move(pos.x, pos.y, { steps: 1 }));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Re-injects on every navigation (addInitScript) and once immediately for the current document, since addInitScript only applies to documents created after it's registered. Also marks the recording's own t0 (see ./timeline): every demo calls this as its very first action, which is the closest available proxy for "when the video recording actually began" without needing frame-exact precision a demo narration track doesn't need anyway.
|
|
201
|
+
*/
|
|
202
|
+
async function installCursor(page) {
|
|
203
|
+
markRecordingStart(page);
|
|
204
|
+
await page.addInitScript(TALKTRACK_OVERLAY_INIT);
|
|
205
|
+
await applyCursor(page);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Use in place of page.goto() for any navigation after installCursor(): restores the cursor to its last known state on the new document instead of letting it reset, so navigating doesn't read as a visual glitch in the recording.
|
|
209
|
+
*/
|
|
210
|
+
async function gotoWithCursor(page, url) {
|
|
211
|
+
await page.goto(url);
|
|
212
|
+
await applyCursor(page);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Use in place of page.reload() for any navigation after installCursor(): restores the cursor to its last known state on the new document the same way gotoWithCursor does for page.goto(). A bare page.reload() in a demo that has already called installCursor() is the one navigation path the overlay can't cover on its own: addInitScript recreates the cursor DOM element on the fresh document (so it doesn't vanish outright), but nothing repositions it, so a reload leaves the cursor visibly pinned to the corner until the next moveAndClick's glide happens to move it (confirmed live as the cause of a cursor going missing mid-recording during a demo's own periodic safety-net reloads).
|
|
216
|
+
*/
|
|
217
|
+
async function reloadWithCursor(page) {
|
|
218
|
+
await page.reload();
|
|
219
|
+
await applyCursor(page);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Sets (or updates) the narration shown in the footer band below the recorded app window; see ./timeline for how this timestamped event actually becomes an on-screen banner during post-processing. Pass null (or call hideCaption) to clear it between unrelated steps rather than leaving a stale caption shown throughout the gap.
|
|
223
|
+
*/
|
|
224
|
+
function showCaption(page, text) {
|
|
225
|
+
recordCaptionEvent(page, text);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Clears the narration band (closes the window the last showCaption opened). Hide between unrelated steps rather than leaving a stale caption shown throughout the gap.
|
|
229
|
+
*/
|
|
230
|
+
function hideCaption(page) {
|
|
231
|
+
recordCaptionEvent(page, null);
|
|
232
|
+
}
|
|
233
|
+
function humanPause(baseMs) {
|
|
234
|
+
return Math.round(baseMs * (.75 + Math.random() * .5));
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Moves the cursor humanly to the locator (scrolling it into view first), clicks it, and holds a jittered pause afterwards. Use in place of locator.click() in any recording.
|
|
238
|
+
*/
|
|
239
|
+
async function moveAndClick(page, locator, pauseMs = 250) {
|
|
240
|
+
await naturalScrollIntoView(page, locator);
|
|
241
|
+
const box = await locator.boundingBox();
|
|
242
|
+
if (!box) throw new Error("Could not resolve a bounding box for the click target");
|
|
243
|
+
const CLICK_TARGET_MIN_OFFSET_RATIO = .4;
|
|
244
|
+
const CLICK_TARGET_OFFSET_RANGE_RATIO = .2;
|
|
245
|
+
const target = {
|
|
246
|
+
x: box.x + box.width * (CLICK_TARGET_MIN_OFFSET_RATIO + Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO),
|
|
247
|
+
y: box.y + box.height * (CLICK_TARGET_MIN_OFFSET_RATIO + Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO)
|
|
248
|
+
};
|
|
249
|
+
await glideTo(page, target);
|
|
250
|
+
await page.waitForTimeout(humanPause(180));
|
|
251
|
+
await tolerateNavigationRace(page.evaluate(([px, py]) => window.__talktrackPress?.(px, py), [target.x, target.y]));
|
|
252
|
+
await locator.click();
|
|
253
|
+
await page.waitForTimeout(humanPause(100));
|
|
254
|
+
await tolerateNavigationRace(page.evaluate(() => window.__talktrackRelease?.()));
|
|
255
|
+
const CURSOR_REAPPLY_ATTEMPTS = 4;
|
|
256
|
+
const CURSOR_REAPPLY_INTERVAL_MS = 90;
|
|
257
|
+
for (let i = 0; i < CURSOR_REAPPLY_ATTEMPTS; i++) {
|
|
258
|
+
await applyCursor(page);
|
|
259
|
+
await page.waitForTimeout(CURSOR_REAPPLY_INTERVAL_MS);
|
|
260
|
+
}
|
|
261
|
+
await page.waitForTimeout(humanPause(pauseMs));
|
|
262
|
+
}
|
|
263
|
+
async function typeCharByChar(page, text) {
|
|
264
|
+
const CHAR_TYPE_MIN_DELAY_MS = 28;
|
|
265
|
+
const CHAR_TYPE_DELAY_RANGE_MS = 55;
|
|
266
|
+
const SPACE_PAUSE_MIN_DELAY_MS = 40;
|
|
267
|
+
const SPACE_PAUSE_DELAY_RANGE_MS = 60;
|
|
268
|
+
const THINKING_PAUSE_PROBABILITY = .04;
|
|
269
|
+
const THINKING_PAUSE_MIN_DELAY_MS = 200;
|
|
270
|
+
const THINKING_PAUSE_DELAY_RANGE_MS = 400;
|
|
271
|
+
for (const ch of text) {
|
|
272
|
+
await page.keyboard.type(ch);
|
|
273
|
+
let delay = CHAR_TYPE_MIN_DELAY_MS + Math.random() * CHAR_TYPE_DELAY_RANGE_MS;
|
|
274
|
+
if (ch === " ") delay += SPACE_PAUSE_MIN_DELAY_MS + Math.random() * SPACE_PAUSE_DELAY_RANGE_MS;
|
|
275
|
+
if (Math.random() < THINKING_PAUSE_PROBABILITY) delay += THINKING_PAUSE_MIN_DELAY_MS + Math.random() * THINKING_PAUSE_DELAY_RANGE_MS;
|
|
276
|
+
await page.waitForTimeout(delay);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Clicks into the locator and types the text character by character at human speed, so a recording shows the value appearing as it's typed rather than arriving at once.
|
|
281
|
+
*/
|
|
282
|
+
async function fillVisibly(page, locator, text, pauseMs = 300) {
|
|
283
|
+
await moveAndClick(page, locator, humanPause(150));
|
|
284
|
+
await locator.fill("");
|
|
285
|
+
await typeCharByChar(page, text);
|
|
286
|
+
await page.waitForTimeout(humanPause(pauseMs));
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Exported so demos can pace their own non-interaction "reading" pauses (for example holding on a state that just changed) with the same jittered, human-feeling timing.
|
|
290
|
+
*/
|
|
291
|
+
function humanBeatMs(baseMs) {
|
|
292
|
+
return humanPause(baseMs);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Waits a jittered pause of roughly `ms` milliseconds on the page, for "reading" beats between interactions.
|
|
296
|
+
*/
|
|
297
|
+
async function beat(page, ms = 600) {
|
|
298
|
+
await page.waitForTimeout(humanBeatMs(ms));
|
|
299
|
+
}
|
|
300
|
+
const NARRATE_SCENE_DEFAULT_LEAD_IN_MS = 800;
|
|
301
|
+
const NARRATE_SCENE_DEFAULT_DWELL_MS = 1200;
|
|
302
|
+
/**
|
|
303
|
+
* Shows a caption, waits for a real page-state condition to settle while the caption stays up, holds a digest dwell, then hides it. The caption's on-screen duration is tied to genuine readiness (the `settle` promise) rather than a fixed sleep: `leadInMs` is a minimum floor running concurrently with `settle`, and `dwellMs` is the "look at this" hold after it resolves.
|
|
304
|
+
*/
|
|
305
|
+
async function narrateScene(page, text, settle, opts) {
|
|
306
|
+
const leadInMs = opts?.leadInMs ?? NARRATE_SCENE_DEFAULT_LEAD_IN_MS;
|
|
307
|
+
const dwellMs = opts?.dwellMs ?? NARRATE_SCENE_DEFAULT_DWELL_MS;
|
|
308
|
+
showCaption(page, text);
|
|
309
|
+
const [result] = await Promise.all([settle(), beat(page, leadInMs)]);
|
|
310
|
+
await beat(page, dwellMs);
|
|
311
|
+
hideCaption(page);
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/bands.ts
|
|
316
|
+
/** The band look talktrack frames recordings with when no `bands` override is given. */
|
|
317
|
+
const defaultBandStyle = {
|
|
318
|
+
headerHeightPx: 48,
|
|
319
|
+
footerHeightPx: 96,
|
|
320
|
+
background: "#111827",
|
|
321
|
+
textColor: "#f8fafc",
|
|
322
|
+
fontStack: "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
|
|
323
|
+
headerFontSizePx: 18,
|
|
324
|
+
footerFontSizePx: 20
|
|
325
|
+
};
|
|
326
|
+
/**
|
|
327
|
+
* Merges a caller's partial band overrides over {@link defaultBandStyle}; an absent overrides object yields the defaults unchanged.
|
|
328
|
+
*/
|
|
329
|
+
function resolveBandStyle(overrides) {
|
|
330
|
+
return {
|
|
331
|
+
...defaultBandStyle,
|
|
332
|
+
...overrides
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Escapes the five characters HTML treats as markup, so arbitrary narration text can be embedded in the band page's template safely.
|
|
337
|
+
*/
|
|
338
|
+
function escapeHtml(text) {
|
|
339
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Renders one band as a PNG. Takes a dedicated throwaway BrowserContext, not the demo's own page/context: a context recording video (recordVideo or Playwright Test's video option) records every page opened within it, so rendering the bands through the demo's own context would leave junk video files alongside the real recording. A fresh context with no video option set (off by default) avoids that entirely.
|
|
343
|
+
*
|
|
344
|
+
* Text is rendered as an ordinary PNG screenshot of that throwaway page rather than via ffmpeg's drawtext filter, since drawtext needs libfreetype/libfontconfig support that common ffmpeg builds (a plain Homebrew install, for instance) don't ship. Rendering text via the same browser already driving the recording sidesteps that dependency and gives exact font parity with the recorded application for free.
|
|
345
|
+
*/
|
|
346
|
+
async function renderTextBand(bandContext, style, band) {
|
|
347
|
+
const bandPage = await bandContext.newPage();
|
|
348
|
+
try {
|
|
349
|
+
await bandPage.setViewportSize({
|
|
350
|
+
width: band.width,
|
|
351
|
+
height: band.height
|
|
352
|
+
});
|
|
353
|
+
await bandPage.setContent(`<!doctype html>
|
|
354
|
+
<html><head><style>
|
|
355
|
+
html, body { margin: 0; width: ${String(band.width)}px; height: ${String(band.height)}px; background: ${style.background}; display: flex; align-items: center; justify-content: center; }
|
|
356
|
+
p { margin: 0; padding: 0 28px; max-width: 100%; box-sizing: border-box; color: ${style.textColor}; font: 500 ${String(band.fontSizePx)}px/1.35 ${style.fontStack}; text-align: center; overflow-wrap: break-word; }
|
|
357
|
+
</style></head>
|
|
358
|
+
<body><p>${escapeHtml(band.text)}</p></body></html>`);
|
|
359
|
+
return await bandPage.screenshot({ type: "png" });
|
|
360
|
+
} finally {
|
|
361
|
+
await bandPage.close();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/filter-graph.ts
|
|
366
|
+
/**
|
|
367
|
+
* A window opens at each show event and closes at whatever event follows it (an explicit hide, or a subsequent show replacing the line), or at the video's own end if nothing follows. A hide event only ever closes the preceding show's window: it opens no window of its own.
|
|
368
|
+
*/
|
|
369
|
+
function captionWindows(events, videoDurationMs) {
|
|
370
|
+
const windows = [];
|
|
371
|
+
for (const [i, event] of events.entries()) {
|
|
372
|
+
if (event.text === null) continue;
|
|
373
|
+
const nextEvent = events[i + 1];
|
|
374
|
+
windows.push({
|
|
375
|
+
text: event.text,
|
|
376
|
+
startMs: event.atMs,
|
|
377
|
+
endMs: nextEvent === void 0 ? videoDurationMs : nextEvent.atMs
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return windows;
|
|
381
|
+
}
|
|
382
|
+
const MS_PER_SECOND = 1e3;
|
|
383
|
+
const FFMPEG_TIME_DECIMAL_PLACES = 3;
|
|
384
|
+
/**
|
|
385
|
+
* Input 0 is the raw recording, input 1 the header band PNG, inputs 2.. the caption band PNGs in window order. The graph pads input 0 onto a taller canvas of band colour (header band above, footer band below), overlays the header at the top, then chains one time-gated overlay per caption window onto the footer region.
|
|
386
|
+
*/
|
|
387
|
+
function buildFramePlan(viewport, style, windows) {
|
|
388
|
+
const padHeight = viewport.height + style.headerHeightPx + style.footerHeightPx;
|
|
389
|
+
let filterGraph = `[0:v]pad=${String(viewport.width)}:${String(padHeight)}:0:${String(style.headerHeightPx)}:color=${style.background}[bg];[bg][1:v]overlay=0:0[v0]`;
|
|
390
|
+
let lastLabel = "v0";
|
|
391
|
+
let inputIndex = 2;
|
|
392
|
+
for (const [i, window] of windows.entries()) {
|
|
393
|
+
const startS = (window.startMs / MS_PER_SECOND).toFixed(FFMPEG_TIME_DECIMAL_PLACES);
|
|
394
|
+
const endS = (window.endMs / MS_PER_SECOND).toFixed(FFMPEG_TIME_DECIMAL_PLACES);
|
|
395
|
+
const nextLabel = `v${String(i + 1)}`;
|
|
396
|
+
filterGraph += `;[${lastLabel}][${String(inputIndex)}:v]overlay=0:${String(style.headerHeightPx + viewport.height)}:enable='between(t,${startS},${endS})'[${nextLabel}]`;
|
|
397
|
+
lastLabel = nextLabel;
|
|
398
|
+
inputIndex++;
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
inputCount: inputIndex,
|
|
402
|
+
filterGraph,
|
|
403
|
+
finalLabel: lastLabel
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/ffmpeg.ts
|
|
408
|
+
async function runCommand(command, args, label) {
|
|
409
|
+
return await new Promise((resolve, reject) => {
|
|
410
|
+
(0, node_child_process.execFile)(command, args, (error, stdout) => {
|
|
411
|
+
if (error) {
|
|
412
|
+
const reason = error.code === "ENOENT" ? `${label} not found on PATH. talktrack drives the external ${label} binary for video post-processing; install it (for example "brew install ffmpeg" or "apt-get install ffmpeg") and retry.` : `${label} failed: ${error.message}`;
|
|
413
|
+
reject(new Error(reason, { cause: error }));
|
|
414
|
+
} else resolve(stdout);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Runs the external ffmpeg binary with the given arguments. Rejects with an install hint rather than a bare spawn error when ffmpeg is not on PATH.
|
|
420
|
+
*/
|
|
421
|
+
async function runFfmpeg(args) {
|
|
422
|
+
return await runCommand("ffmpeg", args, "ffmpeg");
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Throws unless the recorded video's pixel size equals the viewport the bands are laid out against. Playwright records a smaller video than the viewport unless `video.size` is set to match (its default scales the recording to fit inside 800x800), and the framing pass would then pad that smaller picture onto a canvas sized for the full viewport: a silently mis-laid-out result that still looks like a successful run.
|
|
426
|
+
*/
|
|
427
|
+
function assertVideoMatchesViewport(facts, viewport, videoPath) {
|
|
428
|
+
if (facts.width === viewport.width && facts.height === viewport.height) return;
|
|
429
|
+
throw new Error(`talktrack cannot frame ${videoPath}: the recording is ${String(facts.width)}x${String(facts.height)} but the viewport is ${String(viewport.width)}x${String(viewport.height)}. Set the recording size to the viewport size (Playwright Test: video: { mode: "on", size }; recordDemo does this itself) so the bands line up with the picture.`);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Narrows a parsed JSON value to a plain object, per the house unknown-handling rule: never an assertion, always a guard.
|
|
433
|
+
*/
|
|
434
|
+
function isRecord(value) {
|
|
435
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Probes a video file with ffprobe for its pixel dimensions and duration in milliseconds. Any output shape other than the documented stream+format JSON object rejects with the path and what was wrong.
|
|
439
|
+
*/
|
|
440
|
+
async function probeVideo(videoPath) {
|
|
441
|
+
const stdout = await runCommand("ffprobe", [
|
|
442
|
+
"-v",
|
|
443
|
+
"error",
|
|
444
|
+
"-select_streams",
|
|
445
|
+
"v:0",
|
|
446
|
+
"-show_entries",
|
|
447
|
+
"stream=width,height:format=duration",
|
|
448
|
+
"-of",
|
|
449
|
+
"json",
|
|
450
|
+
videoPath
|
|
451
|
+
], "ffprobe");
|
|
452
|
+
const parsed = JSON.parse(stdout);
|
|
453
|
+
if (!isRecord(parsed)) throw new Error(`Unexpected ffprobe output for ${videoPath}: not a JSON object`);
|
|
454
|
+
const streams = parsed.streams;
|
|
455
|
+
const format = parsed.format;
|
|
456
|
+
if (!Array.isArray(streams) || !isRecord(format)) throw new Error(`Unexpected ffprobe output for ${videoPath}: streams is not an array or format is not an object`);
|
|
457
|
+
const firstStream = streams[0];
|
|
458
|
+
if (!isRecord(firstStream)) throw new Error(`Unexpected ffprobe output for ${videoPath}: first stream is not an object`);
|
|
459
|
+
const width = firstStream.width;
|
|
460
|
+
const height = firstStream.height;
|
|
461
|
+
const durationRaw = format.duration;
|
|
462
|
+
if (typeof width !== "number" || typeof height !== "number" || typeof durationRaw !== "string") throw new Error(`Unexpected ffprobe output for ${videoPath}: stream width/height or format.duration has the wrong type`);
|
|
463
|
+
return {
|
|
464
|
+
durationMs: Math.round(Number.parseFloat(durationRaw) * 1e3),
|
|
465
|
+
width,
|
|
466
|
+
height
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/frame.ts
|
|
471
|
+
/**
|
|
472
|
+
* The default header band: the word "commit", the short sha, a middot, and the ISO recording-start timestamp, or just the timestamp when not inside a git working tree (demos run from anywhere, not only repos).
|
|
473
|
+
*/
|
|
474
|
+
async function defaultHeaderText(startedAtMs) {
|
|
475
|
+
const createdAtIso = new Date(startedAtMs).toISOString();
|
|
476
|
+
try {
|
|
477
|
+
return `commit ${await new Promise((resolve, reject) => {
|
|
478
|
+
(0, node_child_process.execFile)("git", [
|
|
479
|
+
"rev-parse",
|
|
480
|
+
"--short",
|
|
481
|
+
"HEAD"
|
|
482
|
+
], (error, stdout) => {
|
|
483
|
+
if (error) reject(new Error(error.message, { cause: error }));
|
|
484
|
+
else resolve(stdout.trim());
|
|
485
|
+
});
|
|
486
|
+
})} · ${createdAtIso}`;
|
|
487
|
+
} catch {
|
|
488
|
+
return createdAtIso;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Frames one raw recording: renders the header and caption band PNGs, probes the raw video's duration, and composites everything into {@link FrameRecordingInput.framedPath} with ffmpeg. Returns that path.
|
|
493
|
+
*/
|
|
494
|
+
async function frameRecording(input) {
|
|
495
|
+
const style = resolveBandStyle(input.bands);
|
|
496
|
+
const timeline = getRecordingTimeline(input.page);
|
|
497
|
+
const facts = await probeVideo(input.videoPath);
|
|
498
|
+
assertVideoMatchesViewport(facts, input.viewport, input.videoPath);
|
|
499
|
+
const windows = captionWindows(timeline.events, facts.durationMs);
|
|
500
|
+
const plan = buildFramePlan(input.viewport, style, windows);
|
|
501
|
+
const bandContext = await input.browser.newContext();
|
|
502
|
+
const workDir = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "talktrack-frame-"));
|
|
503
|
+
try {
|
|
504
|
+
const inputArgs = ["-i", input.videoPath];
|
|
505
|
+
const headerPngPath = (0, node_path.join)(workDir, "header.png");
|
|
506
|
+
await (0, node_fs_promises.writeFile)(headerPngPath, await renderTextBand(bandContext, style, {
|
|
507
|
+
width: input.viewport.width,
|
|
508
|
+
height: style.headerHeightPx,
|
|
509
|
+
fontSizePx: style.headerFontSizePx,
|
|
510
|
+
text: await input.headerText
|
|
511
|
+
}));
|
|
512
|
+
inputArgs.push("-i", headerPngPath);
|
|
513
|
+
for (const [i, window] of windows.entries()) {
|
|
514
|
+
const captionPngPath = (0, node_path.join)(workDir, `caption-${String(i)}.png`);
|
|
515
|
+
await (0, node_fs_promises.writeFile)(captionPngPath, await renderTextBand(bandContext, style, {
|
|
516
|
+
width: input.viewport.width,
|
|
517
|
+
height: style.footerHeightPx,
|
|
518
|
+
fontSizePx: style.footerFontSizePx,
|
|
519
|
+
text: window.text
|
|
520
|
+
}));
|
|
521
|
+
inputArgs.push("-i", captionPngPath);
|
|
522
|
+
}
|
|
523
|
+
await runFfmpeg([
|
|
524
|
+
"-y",
|
|
525
|
+
...inputArgs,
|
|
526
|
+
"-filter_complex",
|
|
527
|
+
plan.filterGraph,
|
|
528
|
+
"-map",
|
|
529
|
+
`[${plan.finalLabel}]`,
|
|
530
|
+
"-c:v",
|
|
531
|
+
"libvpx-vp9",
|
|
532
|
+
"-crf",
|
|
533
|
+
"32",
|
|
534
|
+
"-b:v",
|
|
535
|
+
"0",
|
|
536
|
+
"-deadline",
|
|
537
|
+
"good",
|
|
538
|
+
"-cpu-used",
|
|
539
|
+
"3",
|
|
540
|
+
input.framedPath
|
|
541
|
+
]);
|
|
542
|
+
return input.framedPath;
|
|
543
|
+
} finally {
|
|
544
|
+
await bandContext.close();
|
|
545
|
+
await (0, node_fs_promises.rm)(workDir, {
|
|
546
|
+
recursive: true,
|
|
547
|
+
force: true
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
552
|
+
//#region src/test-adapter.ts
|
|
553
|
+
/**
|
|
554
|
+
* Produces a taller, framed copy of the just-recorded spec video: the app window exactly as recorded, a static header above it, and a footer below it showing each narration line only while it was actually shown during the run. A no-op returning undefined (not a failure) when there's no video to frame at all (a spec run without video enabled) or no viewport to size bands against.
|
|
555
|
+
*
|
|
556
|
+
* Closes the page. Playwright only finalises a page's video once the page is closed, and an afterEach hook runs while the test's page is still open, so the recording on disk is incomplete until then: the adapter closes the page itself and waits for the finalised video (video.saveAs waits for exactly that) before reading it. The page is unusable afterwards, which is why this belongs in afterEach and not mid-test.
|
|
557
|
+
*/
|
|
558
|
+
async function finalizeFramedRecording(page, testInfo, opts) {
|
|
559
|
+
const video = page.video();
|
|
560
|
+
if (!video) return void 0;
|
|
561
|
+
const viewport = page.viewportSize();
|
|
562
|
+
if (!viewport) return void 0;
|
|
563
|
+
const browser = page.context().browser();
|
|
564
|
+
if (!browser) throw new Error("talktrack cannot frame this recording: a persistent browser context has no separate Browser handle to render caption bands through");
|
|
565
|
+
const rawPath = await video.path();
|
|
566
|
+
const framedPath = opts?.framedPath ?? rawPath.replace(/\.webm$/, "-framed.webm");
|
|
567
|
+
const headerText = opts?.headerText ?? defaultHeaderText(getRecordingTimeline(page).startedAt);
|
|
568
|
+
const scratchDir = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "talktrack-raw-"));
|
|
569
|
+
try {
|
|
570
|
+
await page.close();
|
|
571
|
+
const finalisedRawPath = (0, node_path.join)(scratchDir, "raw.webm");
|
|
572
|
+
await video.saveAs(finalisedRawPath);
|
|
573
|
+
await frameRecording({
|
|
574
|
+
page,
|
|
575
|
+
videoPath: finalisedRawPath,
|
|
576
|
+
viewport,
|
|
577
|
+
browser,
|
|
578
|
+
headerText,
|
|
579
|
+
framedPath,
|
|
580
|
+
...opts?.bands ? { bands: opts.bands } : {}
|
|
581
|
+
});
|
|
582
|
+
} finally {
|
|
583
|
+
await (0, node_fs_promises.rm)(scratchDir, {
|
|
584
|
+
recursive: true,
|
|
585
|
+
force: true
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
await testInfo.attach("framed-recording", {
|
|
589
|
+
path: framedPath,
|
|
590
|
+
contentType: "video/webm"
|
|
591
|
+
});
|
|
592
|
+
return framedPath;
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/standalone.ts
|
|
596
|
+
/**
|
|
597
|
+
* Starts one standalone demo recording: launches (or borrows) a browser, opens a video-recording context at the given viewport, installs the cursor, optionally navigates to `url`, and returns the session. Drive the page through the session's helpers, then `await session.finish()` once; it returns the framed recording's path.
|
|
598
|
+
*/
|
|
599
|
+
async function recordDemo(name, opts) {
|
|
600
|
+
const outputDir = opts.outputDir ?? (0, node_path.join)(process.cwd(), "talktrack-output");
|
|
601
|
+
await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
|
|
602
|
+
const ownsBrowser = opts.browser === void 0;
|
|
603
|
+
const browser = opts.browser ?? await (opts.browserType ?? _playwright_test.chromium).launch(opts.launchOptions ?? {});
|
|
604
|
+
const context = await browser.newContext({
|
|
605
|
+
...opts.contextOptions,
|
|
606
|
+
viewport: opts.viewport,
|
|
607
|
+
recordVideo: {
|
|
608
|
+
dir: outputDir,
|
|
609
|
+
size: opts.viewport
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
const page = await context.newPage();
|
|
613
|
+
await installCursor(page);
|
|
614
|
+
if (opts.url !== void 0) await gotoWithCursor(page, opts.url);
|
|
615
|
+
const finish = async () => {
|
|
616
|
+
const viewport = page.viewportSize();
|
|
617
|
+
if (!viewport) throw new Error("talktrack could not read the viewport before closing the demo's context");
|
|
618
|
+
const video = page.video();
|
|
619
|
+
if (!video) throw new Error("talktrack's recording context produced no video handle");
|
|
620
|
+
const videoPathPromise = video.path();
|
|
621
|
+
await context.close();
|
|
622
|
+
const videoPath = await videoPathPromise;
|
|
623
|
+
const framedPath = (0, node_path.join)(outputDir, `${name}-framed.webm`);
|
|
624
|
+
const headerText = opts.headerText ?? defaultHeaderText(getRecordingTimeline(page).startedAt);
|
|
625
|
+
const framed = await frameRecording({
|
|
626
|
+
page,
|
|
627
|
+
videoPath,
|
|
628
|
+
viewport,
|
|
629
|
+
browser,
|
|
630
|
+
headerText,
|
|
631
|
+
framedPath,
|
|
632
|
+
...opts.bands ? { bands: opts.bands } : {}
|
|
633
|
+
});
|
|
634
|
+
if (ownsBrowser) await browser.close();
|
|
635
|
+
return framed;
|
|
636
|
+
};
|
|
637
|
+
return {
|
|
638
|
+
page,
|
|
639
|
+
context,
|
|
640
|
+
moveAndClick: async (locator, pauseMs) => moveAndClick(page, locator, pauseMs),
|
|
641
|
+
fillVisibly: async (locator, text, pauseMs) => fillVisibly(page, locator, text, pauseMs),
|
|
642
|
+
naturalScrollIntoView: async (locator) => naturalScrollIntoView(page, locator),
|
|
643
|
+
goto: async (url) => gotoWithCursor(page, url),
|
|
644
|
+
reload: async () => reloadWithCursor(page),
|
|
645
|
+
caption: (text) => {
|
|
646
|
+
showCaption(page, text);
|
|
647
|
+
},
|
|
648
|
+
hideCaption: () => {
|
|
649
|
+
hideCaption(page);
|
|
650
|
+
},
|
|
651
|
+
narrateScene: async (text, settle, narrateOpts) => narrateScene(page, text, settle, narrateOpts),
|
|
652
|
+
beat: async (ms) => beat(page, ms),
|
|
653
|
+
finish
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
//#endregion
|
|
657
|
+
exports.beat = beat;
|
|
658
|
+
exports.buildFramePlan = buildFramePlan;
|
|
659
|
+
exports.captionWindows = captionWindows;
|
|
660
|
+
exports.defaultBandStyle = defaultBandStyle;
|
|
661
|
+
exports.defaultHeaderText = defaultHeaderText;
|
|
662
|
+
exports.fillVisibly = fillVisibly;
|
|
663
|
+
exports.finalizeFramedRecording = finalizeFramedRecording;
|
|
664
|
+
exports.frameRecording = frameRecording;
|
|
665
|
+
exports.gotoWithCursor = gotoWithCursor;
|
|
666
|
+
exports.hideCaption = hideCaption;
|
|
667
|
+
exports.humanBeatMs = humanBeatMs;
|
|
668
|
+
exports.installCursor = installCursor;
|
|
669
|
+
exports.markRecordingStart = markRecordingStart;
|
|
670
|
+
exports.moveAndClick = moveAndClick;
|
|
671
|
+
exports.narrateScene = narrateScene;
|
|
672
|
+
exports.naturalScrollIntoView = naturalScrollIntoView;
|
|
673
|
+
exports.recordCaptionEvent = recordCaptionEvent;
|
|
674
|
+
exports.recordDemo = recordDemo;
|
|
675
|
+
exports.reloadWithCursor = reloadWithCursor;
|
|
676
|
+
exports.showCaption = showCaption;
|
|
677
|
+
|
|
678
|
+
//# sourceMappingURL=index.cjs.map
|