@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.
- package/README.md +67 -22
- package/package.json +18 -14
- package/src/commands/auth.js +37 -7
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-target.js +36 -4
- package/src/commands/drifts.js +13 -1
- package/src/commands/publish.js +137 -12
- package/src/commands/pull.js +9 -4
- package/src/commands/refresh.js +166 -0
- package/src/commands/setup-wizard.js +35 -2
- package/src/commands/status.js +22 -2
- package/src/commands/variation.js +194 -0
- package/src/index.js +187 -9
- package/src/lib/api-client.js +61 -35
- package/src/lib/auto-update/refresh.js +598 -0
- package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
- package/src/lib/auto-update/spec.js +89 -0
- package/src/lib/capture-engine.js +73 -0
- package/src/lib/capture-script-runner.js +280 -134
- package/src/lib/certification.js +23 -1
- package/src/lib/compose-context.js +156 -0
- package/src/lib/compose-pack.js +42 -0
- package/src/lib/compose-runtime.js +34 -0
- package/src/lib/compose-upload.js +142 -0
- package/src/lib/config.js +2 -2
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +0 -4
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +45 -0
- package/src/lib/ui-api-helpers.js +118 -0
- package/src/lib/ui-api.js +28 -820
- package/src/lib/ui-asset-cleanup.js +62 -0
- package/src/lib/ui-output-versions.js +165 -0
- package/src/lib/ui-recorder-routes.js +341 -0
- package/src/lib/ui-scenario-metadata.js +161 -0
- package/vendor/compose/dist/auto-update.cjs +5544 -0
- package/vendor/compose/dist/auto-update.mjs +5518 -0
- package/vendor/compose/dist/capture.cjs +1450 -0
- package/vendor/compose/dist/capture.mjs +1416 -0
- package/vendor/compose/dist/eligibility.cjs +5331 -0
- package/vendor/compose/dist/eligibility.mjs +5313 -0
- package/vendor/compose/dist/index.cjs +2046 -0
- package/vendor/compose/dist/index.mjs +1997 -0
- package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
- package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
- package/vendor/compose/dist/jsx-runtime.cjs +58 -0
- package/vendor/compose/dist/jsx-runtime.mjs +31 -0
- package/vendor/compose/dist/render.cjs +558 -0
- package/vendor/compose/dist/render.mjs +515 -0
- package/vendor/compose/dist/verify-cli.cjs +3806 -0
- package/vendor/compose/dist/verify-cli.mjs +3812 -0
- package/vendor/compose/dist/verify.cjs +3880 -0
- package/vendor/compose/dist/verify.mjs +3858 -0
- package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ingest.js +0 -458
- package/src/commands/setup.js +0 -165
- 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-]
|
|
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
|
|
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 };
|
package/src/lib/record-clip.js
CHANGED
|
@@ -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
|
-
|
|
420
|
+
createClipMetadataRecorder,
|
|
421
|
+
startClipRecording,
|
|
422
|
+
writeClipMetadata
|
|
342
423
|
};
|
|
343
|
-
|
package/src/lib/record-config.js
CHANGED
|
@@ -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
|
+
};
|