@reshotdev/screenshot 0.0.1-beta.12 → 0.0.1-beta.13

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 (60) hide show
  1. package/README.md +67 -22
  2. package/package.json +18 -14
  3. package/src/commands/auth.js +37 -7
  4. package/src/commands/capture-dom.js +50 -0
  5. package/src/commands/compose.js +220 -0
  6. package/src/commands/doctor-target.js +36 -4
  7. package/src/commands/drifts.js +13 -1
  8. package/src/commands/publish.js +137 -12
  9. package/src/commands/pull.js +9 -4
  10. package/src/commands/refresh.js +166 -0
  11. package/src/commands/setup-wizard.js +35 -2
  12. package/src/commands/status.js +22 -2
  13. package/src/commands/variation.js +194 -0
  14. package/src/index.js +187 -9
  15. package/src/lib/api-client.js +61 -35
  16. package/src/lib/auto-update/refresh.js +598 -0
  17. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  18. package/src/lib/auto-update/spec.js +89 -0
  19. package/src/lib/capture-engine.js +73 -0
  20. package/src/lib/capture-script-runner.js +280 -134
  21. package/src/lib/certification.js +23 -1
  22. package/src/lib/compose-context.js +156 -0
  23. package/src/lib/compose-pack.js +42 -0
  24. package/src/lib/compose-runtime.js +34 -0
  25. package/src/lib/compose-upload.js +142 -0
  26. package/src/lib/config.js +2 -2
  27. package/src/lib/dom-capture.js +64 -0
  28. package/src/lib/record-clip.js +83 -3
  29. package/src/lib/record-config.js +0 -4
  30. package/src/lib/resolve-targets.js +60 -0
  31. package/src/lib/run-manifest.js +45 -0
  32. package/src/lib/ui-api-helpers.js +118 -0
  33. package/src/lib/ui-api.js +28 -820
  34. package/src/lib/ui-asset-cleanup.js +62 -0
  35. package/src/lib/ui-output-versions.js +165 -0
  36. package/src/lib/ui-recorder-routes.js +341 -0
  37. package/src/lib/ui-scenario-metadata.js +161 -0
  38. package/vendor/compose/dist/auto-update.cjs +5544 -0
  39. package/vendor/compose/dist/auto-update.mjs +5518 -0
  40. package/vendor/compose/dist/capture.cjs +1450 -0
  41. package/vendor/compose/dist/capture.mjs +1416 -0
  42. package/vendor/compose/dist/eligibility.cjs +5331 -0
  43. package/vendor/compose/dist/eligibility.mjs +5313 -0
  44. package/vendor/compose/dist/index.cjs +2046 -0
  45. package/vendor/compose/dist/index.mjs +1997 -0
  46. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  47. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  48. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  49. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  50. package/vendor/compose/dist/render.cjs +558 -0
  51. package/vendor/compose/dist/render.mjs +515 -0
  52. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  53. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  54. package/vendor/compose/dist/verify.cjs +3880 -0
  55. package/vendor/compose/dist/verify.mjs +3858 -0
  56. package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
  57. package/web/manager/dist/index.html +1 -1
  58. package/src/commands/ingest.js +0 -458
  59. package/src/commands/setup.js +0 -165
  60. package/src/lib/playwright-runner.js +0 -252
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const fs = require("fs-extra");
5
+
6
+ const DEFAULT_FORMATS = ["mp4", "webm", "poster"];
7
+ const VALID_FORMATS = new Set(["mp4", "webm", "poster", "gif"]);
8
+
9
+ async function resolveComposeContext(file, options = {}) {
10
+ const compositionPath = path.resolve(process.cwd(), file);
11
+ if (!(await fs.pathExists(compositionPath))) {
12
+ throw new Error(
13
+ `Composition file not found: ${compositionPath}\n` +
14
+ "Pass the path to a .compose.tsx file.",
15
+ );
16
+ }
17
+
18
+ const slug = options.slug || deriveSlug(compositionPath);
19
+ const compositionDir = path.dirname(compositionPath);
20
+ const metadataPath = resolveMetadataPath(compositionDir, slug);
21
+ const metadata = await readMetadata(metadataPath, slug);
22
+ const capturePath = resolveCapturePath({
23
+ metadata,
24
+ compositionDir,
25
+ slug,
26
+ });
27
+ await assertCaptureExists(capturePath, slug);
28
+
29
+ return {
30
+ compositionPath,
31
+ slug,
32
+ compositionDir,
33
+ metadataPath,
34
+ metadata,
35
+ capturePath,
36
+ };
37
+ }
38
+
39
+ function deriveSlug(filePath) {
40
+ const filename = path.basename(filePath);
41
+ const withoutKnownSuffix = filename.replace(/\.(compose\.)?[cm]?[tj]sx?$/i, "");
42
+ return withoutKnownSuffix || path.basename(filePath, path.extname(filePath));
43
+ }
44
+
45
+ function resolveOutBase(compositionPath, out) {
46
+ return path.resolve(
47
+ process.cwd(),
48
+ out || path.join(path.dirname(compositionPath), `${deriveSlug(compositionPath)}.composed`),
49
+ );
50
+ }
51
+
52
+ function resolveMetadataPath(compositionDir, slug) {
53
+ return path.join(compositionDir, `${slug}.metadata.json`);
54
+ }
55
+
56
+ async function readMetadata(metadataPath, slug) {
57
+ if (!(await fs.pathExists(metadataPath))) {
58
+ throw new Error(
59
+ `Missing metadata file for slug "${slug}": ${metadataPath}\n` +
60
+ `Expected a sibling ${slug}.metadata.json file. Re-record the clip with metadata enabled or pass --slug matching an existing metadata file.`,
61
+ );
62
+ }
63
+
64
+ try {
65
+ const metadata = await fs.readJson(metadataPath);
66
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
67
+ throw new Error("metadata must be a JSON object");
68
+ }
69
+ return metadata;
70
+ } catch (error) {
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ throw new Error(`Could not read metadata file: ${metadataPath}\n${message}`);
73
+ }
74
+ }
75
+
76
+ function resolveCapturePath({ metadata, compositionDir, slug }) {
77
+ const declaredPath =
78
+ metadata.capturePath ||
79
+ metadata.workflowCapturePath ||
80
+ metadata.capture?.path ||
81
+ metadata.source?.capturePath ||
82
+ metadata.source?.path;
83
+
84
+ if (typeof declaredPath === "string" && declaredPath.trim()) {
85
+ return path.resolve(compositionDir, declaredPath);
86
+ }
87
+
88
+ return path.join(compositionDir, `workflow-capture-${slug}.mp4`);
89
+ }
90
+
91
+ async function assertCaptureExists(capturePath, slug) {
92
+ if (await fs.pathExists(capturePath)) {
93
+ return;
94
+ }
95
+
96
+ throw new Error(
97
+ `Missing workflow capture for slug "${slug}": ${capturePath}\n` +
98
+ `Expected workflow-capture-${slug}.mp4 next to the composition, or a capturePath in ${slug}.metadata.json.`,
99
+ );
100
+ }
101
+
102
+ function parseSize(value) {
103
+ const match = /^(\d+)x(\d+)$/i.exec(String(value || "").trim());
104
+ if (!match) {
105
+ throw new Error(`Invalid --size "${value}". Use WIDTHxHEIGHT, for example 1440x900.`);
106
+ }
107
+
108
+ const width = Number(match[1]);
109
+ const height = Number(match[2]);
110
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
111
+ throw new Error(`Invalid --size "${value}". Width and height must be positive integers.`);
112
+ }
113
+
114
+ return { width, height };
115
+ }
116
+
117
+ function parseFormats(value, includeGif = false) {
118
+ const rawFormats = value == null || value === "" ? DEFAULT_FORMATS : String(value).split(",");
119
+ const formats = [];
120
+
121
+ for (const rawFormat of rawFormats) {
122
+ const format = rawFormat.trim().toLowerCase();
123
+ if (!format) continue;
124
+ if (!VALID_FORMATS.has(format)) {
125
+ throw new Error(
126
+ `Unknown compose format "${rawFormat}". Supported formats: ${Array.from(VALID_FORMATS).join(", ")}.`,
127
+ );
128
+ }
129
+ if (!formats.includes(format)) {
130
+ formats.push(format);
131
+ }
132
+ }
133
+
134
+ if (includeGif && !formats.includes("gif")) {
135
+ formats.push("gif");
136
+ }
137
+
138
+ if (formats.length === 0) {
139
+ throw new Error(`No compose formats selected. Use --formats=${DEFAULT_FORMATS.join(",")}.`);
140
+ }
141
+
142
+ return formats;
143
+ }
144
+
145
+ module.exports = {
146
+ DEFAULT_FORMATS,
147
+ assertCaptureExists,
148
+ deriveSlug,
149
+ parseFormats,
150
+ parseSize,
151
+ readMetadata,
152
+ resolveCapturePath,
153
+ resolveComposeContext,
154
+ resolveMetadataPath,
155
+ resolveOutBase,
156
+ };
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs-extra");
4
+
5
+ async function packFromExistingOutputs(outBase) {
6
+ const pack = {
7
+ mp4: `${outBase}.mp4`,
8
+ webm: `${outBase}.webm`,
9
+ poster: `${outBase}.webp`,
10
+ };
11
+ const gifPath = `${outBase}.gif`;
12
+ if (await fs.pathExists(gifPath)) {
13
+ pack.gif = gifPath;
14
+ }
15
+ return pack;
16
+ }
17
+
18
+ async function assertUploadPackExists(pack, skipRender) {
19
+ const required = [
20
+ ["rendered_mp4", pack.mp4],
21
+ ["rendered_webm", pack.webm],
22
+ ["rendered_poster", pack.poster],
23
+ ];
24
+
25
+ for (const [field, filePath] of required) {
26
+ if (!filePath || !(await fs.pathExists(filePath))) {
27
+ const rerenderHint = skipRender
28
+ ? " Run `reshot compose <file>` first or remove --skip-render."
29
+ : "";
30
+ throw new Error(`Missing ${field} output: ${filePath || "(not produced)"}.${rerenderHint}`);
31
+ }
32
+ }
33
+
34
+ if (pack.gif && !(await fs.pathExists(pack.gif))) {
35
+ throw new Error(`Missing rendered_gif output: ${pack.gif}.`);
36
+ }
37
+ }
38
+
39
+ module.exports = {
40
+ assertUploadPackExists,
41
+ packFromExistingOutputs,
42
+ };
@@ -0,0 +1,34 @@
1
+ // compose-runtime.js — locate the @reshot/compose build the CLI ships with.
2
+ //
3
+ // The compose engine is vendored (self-contained: motion-core and the pure-JS
4
+ // image deps are inlined; only esbuild + playwright-core stay external and are
5
+ // declared as CLI dependencies). The published package ships vendor/compose/dist;
6
+ // when developing inside the monorepo we fall back to the live package build.
7
+
8
+ const path = require("path");
9
+ const fs = require("fs-extra");
10
+
11
+ let cached;
12
+
13
+ /** Absolute path to the compose dist directory (capture.cjs, render.mjs, …). */
14
+ function composeDistDir() {
15
+ if (cached) return cached;
16
+ const candidates = [
17
+ // Shipped with the published CLI (also present in the standalone repo).
18
+ path.resolve(__dirname, "../../vendor/compose/dist"),
19
+ // Monorepo development: packages/compose/dist.
20
+ path.resolve(__dirname, "../../../../packages/compose/dist"),
21
+ ];
22
+ for (const dir of candidates) {
23
+ if (fs.existsSync(dir)) {
24
+ cached = dir;
25
+ return dir;
26
+ }
27
+ }
28
+ throw new Error(
29
+ "@reshot/compose build not found. Expected vendor/compose/dist " +
30
+ "(shipped with the CLI) or packages/compose/dist (monorepo dev).",
31
+ );
32
+ }
33
+
34
+ module.exports = { composeDistDir };
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs-extra");
4
+ const axios = require("axios");
5
+ const FormData = require("form-data");
6
+ const config = require("./config");
7
+ const apiClient = require("./api-client");
8
+
9
+ function resolveComposeProjectContext({ projectOption, settings }) {
10
+ const resolvedSettings =
11
+ settings !== undefined ? settings : readSettingsSafe();
12
+ const apiKey = process.env.RESHOT_API_KEY || resolvedSettings?.apiKey;
13
+ const projectId =
14
+ projectOption ||
15
+ process.env.RESHOT_PROJECT_ID ||
16
+ resolvedSettings?.projectId;
17
+
18
+ if (!apiKey) {
19
+ throw new Error(
20
+ "No API key found. Set RESHOT_API_KEY or run `reshot auth` to create .reshot/settings.json.",
21
+ );
22
+ }
23
+
24
+ if (!projectId) {
25
+ throw new Error(
26
+ "No project ID found. Pass --project, set RESHOT_PROJECT_ID, or authenticate with `reshot auth`.",
27
+ );
28
+ }
29
+
30
+ return { apiKey, projectId };
31
+ }
32
+
33
+ function readSettingsSafe() {
34
+ try {
35
+ return config.readSettings();
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ async function uploadComposition({
42
+ apiBaseUrl,
43
+ apiKey,
44
+ projectId,
45
+ name,
46
+ slug,
47
+ sourceTsx,
48
+ metadataJson,
49
+ pack,
50
+ autoApprove = false,
51
+ httpClient = axios,
52
+ }) {
53
+ const formData = new FormData();
54
+ formData.append("name", name);
55
+ formData.append("slug", slug);
56
+ formData.append("source_tsx", sourceTsx);
57
+ formData.append("metadata_json", metadataJson);
58
+ if (autoApprove) {
59
+ formData.append("auto_approve", "true");
60
+ }
61
+ formData.append("rendered_mp4", fs.createReadStream(pack.mp4));
62
+ formData.append("rendered_webm", fs.createReadStream(pack.webm));
63
+ formData.append("rendered_poster", fs.createReadStream(pack.poster));
64
+ if (pack.gif) {
65
+ formData.append("rendered_gif", fs.createReadStream(pack.gif));
66
+ }
67
+
68
+ const endpoint = `${apiBaseUrl.replace(/\/+$/, "")}/projects/${encodeURIComponent(projectId)}/compositions`;
69
+ const response = await httpClient.post(endpoint, formData, {
70
+ headers: {
71
+ ...formData.getHeaders(),
72
+ Authorization: `Bearer ${apiKey}`,
73
+ },
74
+ timeout: 180000,
75
+ maxBodyLength: Infinity,
76
+ maxContentLength: Infinity,
77
+ });
78
+
79
+ const data = unwrapApiResponse(response.data);
80
+ const attributionWarning = readResponseHeader(
81
+ response.headers,
82
+ "x-reshot-attribution-warning",
83
+ );
84
+
85
+ if (attributionWarning) {
86
+ return { ...data, attributionWarning };
87
+ }
88
+
89
+ return data;
90
+ }
91
+
92
+ function unwrapApiResponse(body) {
93
+ if (body && typeof body === "object" && body.success && body.data !== undefined) {
94
+ return body.data;
95
+ }
96
+ return body;
97
+ }
98
+
99
+ function readResponseHeader(headers, name) {
100
+ if (!headers) return null;
101
+ if (typeof headers.get === "function") {
102
+ return headers.get(name);
103
+ }
104
+ return headers[name] || headers[name.toLowerCase()] || null;
105
+ }
106
+
107
+ function buildDashboardUrl(response, apiBaseUrl, projectId) {
108
+ const platformUrl = apiBaseUrl.replace(/\/api\/?$/, "");
109
+ const dashboardUrl =
110
+ response?.dashboardUrl ||
111
+ response?.composition?.dashboardUrl ||
112
+ response?.render?.dashboardUrl;
113
+ if (dashboardUrl) {
114
+ return dashboardUrl;
115
+ }
116
+
117
+ const compositionId = response?.composition?.id || response?.id;
118
+ const base = `${platformUrl}/app/projects/${encodeURIComponent(projectId)}/compositions`;
119
+ return compositionId ? `${base}/${encodeURIComponent(compositionId)}` : base;
120
+ }
121
+
122
+ function humanizeName(slug) {
123
+ return String(slug)
124
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
125
+ .replace(/[-_]+/g, " ")
126
+ .trim() || slug;
127
+ }
128
+
129
+ function getComposeApiBaseUrl(explicitApiBaseUrl) {
130
+ return explicitApiBaseUrl || apiClient.getApiBaseUrl();
131
+ }
132
+
133
+ module.exports = {
134
+ buildDashboardUrl,
135
+ getComposeApiBaseUrl,
136
+ humanizeName,
137
+ readResponseHeader,
138
+ readSettingsSafe,
139
+ resolveComposeProjectContext,
140
+ unwrapApiResponse,
141
+ uploadComposition,
142
+ };
package/src/lib/config.js CHANGED
@@ -330,9 +330,9 @@ function readConfig() {
330
330
  if (!scenario.key) {
331
331
  throw new Error(`Scenario "${scenario.name}" must have a "key" field`);
332
332
  }
333
- if (!/^[a-z0-9-]+$/i.test(scenario.key)) {
333
+ if (!/^[a-z0-9]([a-z0-9\-_/]*[a-z0-9])?$/i.test(scenario.key)) {
334
334
  throw new Error(
335
- `Scenario "${scenario.name}" has invalid key "${scenario.key}". Keys must be alphanumeric with hyphens only.`
335
+ `Scenario "${scenario.name}" has invalid key "${scenario.key}". Keys must start and end alphanumeric; hyphens, underscores, and slashes are allowed in between.`
336
336
  );
337
337
  }
338
338
  if (!scenario.url) {
@@ -0,0 +1,64 @@
1
+ // dom-capture.js — CLI integration layer for the Tier-3 DOM-capture engine.
2
+ //
3
+ // The capture techniques (m7 hybrid primary, m4 CDP DOMSnapshot fallback) live in
4
+ // @reshot/compose/capture so they can reuse the deterministic renderer + the
5
+ // calibrated verify evaluator and be gated under `pnpm --dir packages/compose
6
+ // test`. This module is the thin CLI-side wrapper: it loads that engine (with a
7
+ // monorepo dist fallback, mirroring commands/compose.js) and exposes the two
8
+ // entry points the CLI needs.
9
+
10
+ const path = require("path");
11
+ const fs = require("fs-extra");
12
+ const { composeDistDir } = require("./compose-runtime");
13
+
14
+ function loadCaptureEngine() {
15
+ return require(path.join(composeDistDir(), "capture.cjs"));
16
+ }
17
+
18
+ /**
19
+ * Capture a DOM artifact from an ALREADY-NAVIGATED live page and write it next to
20
+ * the other capture outputs. Returns the metadata.domArtifact block (or null if
21
+ * capture failed — capture is additive and must never break the video path).
22
+ *
23
+ * @param {object} args
24
+ * @param {import('playwright').Page} args.page live page (capture as-is)
25
+ * @param {string} args.outputDir
26
+ * @param {string} args.slug
27
+ * @param {string|null} [args.csp]
28
+ */
29
+ async function captureDomArtifact({ page, outputDir, slug, csp = null }) {
30
+ try {
31
+ const { captureDom, writeArtifact } = loadCaptureEngine();
32
+ const snapshot = await captureDom(page, { csp });
33
+ const base = path.join(outputDir, slug);
34
+ const paths = await writeArtifact(snapshot, base);
35
+ return { path: paths.html, method: snapshot.method, sidecars: snapshot.surfaces };
36
+ } catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ console.warn(` ⚠ DOM capture skipped: ${message}`);
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Standalone capture of a URL: navigates, captures, remounts, and writes the
45
+ * artifact + remounted.png + live.png into outDir. Returns a result summary.
46
+ */
47
+ async function captureDomFromUrl({ url, outDir, slug = "capture", settings }) {
48
+ const { captureUrl, writeArtifact } = loadCaptureEngine();
49
+ await fs.ensureDir(outDir);
50
+ const result = await captureUrl(url, settings ? { settings } : {});
51
+ const paths = await writeArtifact(result.snapshot, path.join(outDir, slug));
52
+ await fs.writeFile(path.join(outDir, "remounted.png"), result.remountPng);
53
+ await fs.writeFile(path.join(outDir, "live.png"), result.livePng);
54
+ return {
55
+ method: result.method,
56
+ artifact: paths.html,
57
+ meta: paths.meta,
58
+ remounted: path.join(outDir, "remounted.png"),
59
+ live: path.join(outDir, "live.png"),
60
+ sidecars: result.snapshot.surfaces,
61
+ };
62
+ }
63
+
64
+ module.exports = { loadCaptureEngine, captureDomArtifact, captureDomFromUrl };
@@ -8,13 +8,27 @@ const { chromium } = require('playwright');
8
8
  const { updateBrowserMode } = require('./record-browser-injection');
9
9
  const { runPolishedClip } = require('./polished-clip');
10
10
  const { saveScenarioProgress } = require('./record-config');
11
+ const { resolveTargets } = require('./resolve-targets');
12
+ const { captureDomArtifact } = require('./dom-capture');
11
13
 
12
14
  /**
13
15
  * Start clip recording flow
14
16
  * @param {Object} sessionState - Recording session state
15
17
  * @param {Page} page - Playwright page object
16
18
  */
17
- async function startClipRecording(sessionState, page) {
19
+ async function startClipRecording(sessionState, page, config = {}) {
20
+ const metadataRecorder = config.emitMetadata
21
+ ? createClipMetadataRecorder({
22
+ slug: config.slug || sessionState.visualKey,
23
+ captureSize: config.captureSize || { width: 1280, height: 720 },
24
+ })
25
+ : null;
26
+
27
+ if (metadataRecorder) {
28
+ sessionState.logEvent = metadataRecorder.logEvent;
29
+ metadataRecorder.logEvent('workflow_start');
30
+ }
31
+
18
32
  // Ask about container element
19
33
  const { useContainer } = await inquirer.prompt([
20
34
  {
@@ -127,6 +141,10 @@ async function startClipRecording(sessionState, page) {
127
141
 
128
142
  // Set up action replay handler to sync video with real user actions
129
143
  sessionState.replayActionToRecording = async (action, selector, text) => {
144
+ if (metadataRecorder) {
145
+ metadataRecorder.logEvent(action, { selector, text });
146
+ }
147
+
130
148
  try {
131
149
  if (action === 'click') {
132
150
  await recordingPage.click(selector);
@@ -233,6 +251,17 @@ async function startClipRecording(sessionState, page) {
233
251
 
234
252
  sessionState.capturedSteps.push(clipStep);
235
253
  await saveScenarioProgress(sessionState, page, { finalize: false });
254
+
255
+ if (metadataRecorder) {
256
+ await writeClipMetadata({
257
+ page,
258
+ outputDir: config.outputDir || sessionState.outputDir || process.cwd(),
259
+ slug: metadataRecorder.slug,
260
+ captureSize: metadataRecorder.captureSize,
261
+ timeline: metadataRecorder.timeline,
262
+ targets: config.targets,
263
+ });
264
+ }
236
265
 
237
266
  // Clean up temp video
238
267
  fs.removeSync(rawVideoPath);
@@ -242,6 +271,9 @@ async function startClipRecording(sessionState, page) {
242
271
  sessionState.clipEvents = null;
243
272
  sessionState.recordingStart = null;
244
273
  sessionState.stopClipRecording = false;
274
+ if (metadataRecorder) {
275
+ delete sessionState.logEvent;
276
+ }
245
277
 
246
278
  console.log(
247
279
  chalk.green(
@@ -250,6 +282,53 @@ async function startClipRecording(sessionState, page) {
250
282
  );
251
283
  }
252
284
 
285
+ function createClipMetadataRecorder({ slug, captureSize }) {
286
+ const startedAt = Date.now();
287
+ const timeline = [];
288
+
289
+ return {
290
+ slug,
291
+ captureSize,
292
+ timeline,
293
+ logEvent(type, payload = {}) {
294
+ timeline.push({
295
+ tMs: Date.now() - startedAt,
296
+ type,
297
+ payload,
298
+ });
299
+ },
300
+ };
301
+ }
302
+
303
+ async function writeClipMetadata({
304
+ page,
305
+ outputDir,
306
+ slug,
307
+ captureSize,
308
+ timeline,
309
+ targets,
310
+ }) {
311
+ const resolvedTargets = targets ? await resolveTargets(page, targets) : {};
312
+
313
+ // Tier-3: alongside the video, emit a self-contained DOM reconstruction
314
+ // artifact (<slug>.dom.html + sidecars) for eligible screens. Additive and
315
+ // best-effort — it must never break the video path, so failures are swallowed.
316
+ await fs.ensureDir(outputDir);
317
+ const domArtifact = await captureDomArtifact({ page, outputDir, slug });
318
+
319
+ const metadata = {
320
+ slug,
321
+ version: 1,
322
+ captureSize,
323
+ timeline,
324
+ targets: resolvedTargets,
325
+ ...(domArtifact ? { domArtifact } : {}),
326
+ };
327
+ const metadataPath = path.join(outputDir, `${slug}.metadata.json`);
328
+ await fs.writeJson(metadataPath, metadata, { spaces: 2 });
329
+ return metadataPath;
330
+ }
331
+
253
332
  /**
254
333
  * Run subtitle editor mini-server
255
334
  * @param {Array} events - Clip events with timestamps
@@ -338,6 +417,7 @@ function buildClipKey(visualKey, filename) {
338
417
  }
339
418
 
340
419
  module.exports = {
341
- startClipRecording
420
+ createClipMetadataRecorder,
421
+ startClipRecording,
422
+ writeClipMetadata
342
423
  };
343
-
@@ -492,10 +492,6 @@ async function persistFinalScenario(sessionState, page, options = {}) {
492
492
  // Default output configuration for automatic step-by-step image generation
493
493
  const defaultOutput = {
494
494
  format: "step-by-step-images",
495
- highlight: {
496
- color: "rgba(255, 255, 0, 0.5)",
497
- style: "box",
498
- },
499
495
  };
500
496
 
501
497
  // Parse groupPath from session state or existing scenario
@@ -0,0 +1,60 @@
1
+ // resolve-targets.js - Resolve compose target coordinates from a Playwright page
2
+
3
+ async function resolveTargets(page, targets = {}) {
4
+ const resolved = {};
5
+
6
+ for (const [name, config] of Object.entries(targets || {})) {
7
+ const target = normalizeTarget(config);
8
+
9
+ for (const step of target.navigate) {
10
+ if (step.clickText) {
11
+ await page.getByText(step.clickText, { exact: true }).first().click();
12
+ } else if (step.selector) {
13
+ await page.locator(step.selector).first().click();
14
+ }
15
+ if (step.waitMs) {
16
+ await page.waitForTimeout(step.waitMs);
17
+ }
18
+ }
19
+
20
+ const locator = page.locator(target.selector).first();
21
+ await locator.waitFor({ state: 'visible', timeout: target.timeoutMs });
22
+ const box = await locator.boundingBox();
23
+ if (!box) {
24
+ throw new Error(`Could not resolve target "${name}"`);
25
+ }
26
+
27
+ resolved[name] = {
28
+ x: Math.round(box.x),
29
+ y: Math.round(box.y),
30
+ w: Math.round(box.width),
31
+ h: Math.round(box.height),
32
+ };
33
+ }
34
+
35
+ return resolved;
36
+ }
37
+
38
+ function normalizeTarget(config) {
39
+ if (typeof config === 'string') {
40
+ return {
41
+ selector: config,
42
+ navigate: [],
43
+ timeoutMs: 10000,
44
+ };
45
+ }
46
+
47
+ if (!config || typeof config !== 'object' || !config.selector) {
48
+ throw new Error('Target config must be a selector string or an object with selector');
49
+ }
50
+
51
+ return {
52
+ selector: config.selector,
53
+ navigate: Array.isArray(config.navigate) ? config.navigate : [],
54
+ timeoutMs: config.timeoutMs || 10000,
55
+ };
56
+ }
57
+
58
+ module.exports = {
59
+ resolveTargets,
60
+ };