@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,598 @@
|
|
|
1
|
+
// Phase 5 auto-update — the refresh orchestrator (the differentiator).
|
|
2
|
+
//
|
|
3
|
+
// For each composition with a stored spec: recapture its source screen, run the
|
|
4
|
+
// calibrated capture-time gate, and decide vs the prior accepted render's
|
|
5
|
+
// structure signature:
|
|
6
|
+
// - skip : recapture is reference-identical (idempotent no-op).
|
|
7
|
+
// - publish: data changed, still eligible + structure-stable + reconstruction
|
|
8
|
+
// within the calibrated bar -> re-render + upload AUTO-APPROVED;
|
|
9
|
+
// compositions.live_render_id advances; reference frame advances.
|
|
10
|
+
// - flag : redesign / lost eligibility / unfaithful reconstruction -> upload
|
|
11
|
+
// as PENDING (the existing route enqueues a COMPOSITION review item);
|
|
12
|
+
// live_render_id and the accepted reference are left UNCHANGED.
|
|
13
|
+
//
|
|
14
|
+
// The publish/flag mechanics reuse the existing compositions upload route
|
|
15
|
+
// (auto_approve toggles APPROVED+live-swap vs PENDING+review-item) and the
|
|
16
|
+
// existing composition-review queue — Phase 5 feeds them, it does not replace
|
|
17
|
+
// them. All injected deps are overridable for tests/capstones.
|
|
18
|
+
|
|
19
|
+
const os = require("os");
|
|
20
|
+
const path = require("path");
|
|
21
|
+
const { pathToFileURL } = require("url");
|
|
22
|
+
const fs = require("fs-extra");
|
|
23
|
+
const compose = require("../../commands/compose");
|
|
24
|
+
const apiClient = require("../api-client");
|
|
25
|
+
const specStore = require("./spec");
|
|
26
|
+
const { composeDistDir } = require("../compose-runtime");
|
|
27
|
+
|
|
28
|
+
// The Phase 4 <Scene> render template — one template serves every eligible
|
|
29
|
+
// composition; the authored camera + captured artifact arrive via env/sidecar.
|
|
30
|
+
const SCENE_TEMPLATE = path.resolve(__dirname, "scene-runtime.compose.tsx");
|
|
31
|
+
|
|
32
|
+
// Load the ESM build via dynamic import: pixelmatch v7 (used by the verify diff)
|
|
33
|
+
// is ESM-only and its default export does not survive the bundled CJS interop, so
|
|
34
|
+
// the .mjs is the reliable entrypoint even from this CommonJS module.
|
|
35
|
+
async function loadAutoUpdate() {
|
|
36
|
+
return import(pathToFileURL(path.join(composeDistDir(), "auto-update.mjs")).href);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function captureSettings(spec) {
|
|
40
|
+
const v = spec.source && spec.source.viewport;
|
|
41
|
+
if (!v) return undefined;
|
|
42
|
+
return {
|
|
43
|
+
width: v.width,
|
|
44
|
+
height: v.height,
|
|
45
|
+
deviceScaleFactor: v.deviceScaleFactor || 2,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Load compose's published render + capture entrypoints as ESM (same reason as
|
|
50
|
+
// loadAutoUpdate: the .mjs build is the reliable cross-interop entrypoint).
|
|
51
|
+
async function loadComposeScene() {
|
|
52
|
+
const composeDist = composeDistDir();
|
|
53
|
+
const render = await import(pathToFileURL(path.join(composeDist, "render.mjs")).href);
|
|
54
|
+
const capture = await import(pathToFileURL(path.join(composeDist, "capture.mjs")).href);
|
|
55
|
+
return { render: render.render, writeArtifact: capture.writeArtifact };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A composition renders as a crisp <Scene> only when (a) it explicitly opted in
|
|
59
|
+
// with an authored camera (`spec.scene.camera`) and/or in-scene motion
|
|
60
|
+
// (`spec.scene.motion`), AND (b) the live evaluation routed this recapture to
|
|
61
|
+
// "reconstruction" (eligible + within the calibrated bar), AND (c) we actually
|
|
62
|
+
// captured the DOM artifact this run, AND (d) — for MOTION, which binds to anchors —
|
|
63
|
+
// the recapture is still `animatable` (anchors stable). Any miss → video. This is
|
|
64
|
+
// the fail-safe seam: a drifted/ineligible screen never animates the wrong elements.
|
|
65
|
+
function sceneEligible(spec, evaluation) {
|
|
66
|
+
if (!spec || !spec.scene || !evaluation || !evaluation.snapshot) return false;
|
|
67
|
+
if (!evaluation.decision || evaluation.decision.route !== "reconstruction") return false;
|
|
68
|
+
const hasCamera = Array.isArray(spec.scene.camera) && spec.scene.camera.length > 0;
|
|
69
|
+
const hasMotion = Array.isArray(spec.scene.motion) && spec.scene.motion.length > 0;
|
|
70
|
+
if (!hasCamera && !hasMotion) return false;
|
|
71
|
+
// Anchor-stability fail-safe: authored motion binds to the screen's anchors, so a
|
|
72
|
+
// recapture whose anchors drifted (classification.animatable === false) must NOT
|
|
73
|
+
// be animated — route to video. Camera-only comps don't bind to anchors → exempt.
|
|
74
|
+
if (hasMotion && evaluation.classification && evaluation.classification.animatable === false) return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function restoreEnv(key, prev) {
|
|
79
|
+
if (prev === undefined) delete process.env[key];
|
|
80
|
+
else process.env[key] = prev;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Render the recaptured DOM artifact as a vector-crisp <Scene> driven by the
|
|
84
|
+
// composition's authored camera move, through the published Phase 1 pipeline.
|
|
85
|
+
async function renderScene(spec, evaluation, deps = {}) {
|
|
86
|
+
const { render, writeArtifact } = deps.composeScene || (await loadComposeScene());
|
|
87
|
+
const v = (spec.source && spec.source.viewport) || (spec.scene && spec.scene.viewport) || {};
|
|
88
|
+
const dpr = v.deviceScaleFactor || 2;
|
|
89
|
+
const viewport = {
|
|
90
|
+
width: v.width || evaluation.snapshot.viewport?.width,
|
|
91
|
+
height: v.height || evaluation.snapshot.viewport?.height,
|
|
92
|
+
};
|
|
93
|
+
const durationMs = spec.scene.durationMs || 2200;
|
|
94
|
+
|
|
95
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), `reshot-scene-${spec.compositionId}-`));
|
|
96
|
+
const { html: artifactPath } = await writeArtifact(evaluation.snapshot, path.join(tmp, "scene"));
|
|
97
|
+
const demoPath = path.join(tmp, "scene.demo.json");
|
|
98
|
+
await fs.writeFile(
|
|
99
|
+
demoPath,
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
viewport,
|
|
102
|
+
durationMs,
|
|
103
|
+
scrolls: evaluation.snapshot.scrolls || [],
|
|
104
|
+
timeline: spec.scene.timeline,
|
|
105
|
+
targets: spec.scene.targets,
|
|
106
|
+
camera: spec.scene.camera,
|
|
107
|
+
cameraOptions: spec.scene.cameraOptions,
|
|
108
|
+
motion: spec.scene.motion,
|
|
109
|
+
motionOptions: spec.scene.motionOptions,
|
|
110
|
+
}),
|
|
111
|
+
"utf8",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const prevArtifact = process.env.RESHOT_SCENE_ARTIFACT;
|
|
115
|
+
const prevDemo = process.env.RESHOT_SCENE_DEMO;
|
|
116
|
+
process.env.RESHOT_SCENE_ARTIFACT = artifactPath;
|
|
117
|
+
process.env.RESHOT_SCENE_DEMO = demoPath;
|
|
118
|
+
try {
|
|
119
|
+
const result = await render(SCENE_TEMPLATE, {
|
|
120
|
+
out: spec.outBase,
|
|
121
|
+
slug: spec.slug,
|
|
122
|
+
size: { width: viewport.width, height: viewport.height },
|
|
123
|
+
durationMs,
|
|
124
|
+
deviceScaleFactor: dpr,
|
|
125
|
+
formats: ["mp4", "webm", "poster"],
|
|
126
|
+
});
|
|
127
|
+
return result.pack || {};
|
|
128
|
+
} finally {
|
|
129
|
+
restoreEnv("RESHOT_SCENE_ARTIFACT", prevArtifact);
|
|
130
|
+
restoreEnv("RESHOT_SCENE_DEMO", prevDemo);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The existing video render path (a captured-video <Frame> composition).
|
|
135
|
+
async function renderVideo(spec) {
|
|
136
|
+
if (!spec.composePath) {
|
|
137
|
+
throw new Error(`Composition ${spec.compositionId} spec has no composePath to re-render`);
|
|
138
|
+
}
|
|
139
|
+
const v = spec.source && spec.source.viewport;
|
|
140
|
+
const size = v ? { width: v.width, height: v.height } : undefined;
|
|
141
|
+
const result = await compose.runCompose(spec.composePath, {
|
|
142
|
+
out: spec.outBase,
|
|
143
|
+
size: size ? `${size.width}x${size.height}` : undefined,
|
|
144
|
+
});
|
|
145
|
+
return result.pack || {};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Phase 4 flip: render eligible compositions as a crisp <Scene>, with the video
|
|
149
|
+
// render as the guaranteed fallback. A scene render that throws OR produces no clip
|
|
150
|
+
// silently degrades to video — "on any doubt, route to video; never ship a broken
|
|
151
|
+
// or blurry clip as crisp."
|
|
152
|
+
async function defaultRender(spec, deps = {}, evaluation = null) {
|
|
153
|
+
const renderSceneFn = deps.renderScene || renderScene;
|
|
154
|
+
const renderVideoFn = deps.renderVideo || renderVideo;
|
|
155
|
+
if (sceneEligible(spec, evaluation)) {
|
|
156
|
+
try {
|
|
157
|
+
const pack = await renderSceneFn(spec, evaluation, deps);
|
|
158
|
+
if (pack && (pack.mp4 || pack.webm)) return pack;
|
|
159
|
+
console.warn(
|
|
160
|
+
`[auto-update] scene render produced no clip for ${spec.compositionId}; routing to video fallback`,
|
|
161
|
+
);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
+
console.warn(
|
|
165
|
+
`[auto-update] scene render failed for ${spec.compositionId}; routing to video fallback: ${message}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return renderVideoFn(spec);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Reconciliation (handoff Task C, Option A): the platform's live_render_id is the
|
|
173
|
+
// source of truth for what is published. When a human approves a previously
|
|
174
|
+
// FLAGGED candidate in the dashboard, the platform advances live_render_id but our
|
|
175
|
+
// local spec still carries the stale `pendingFlag` — so without this the loop would
|
|
176
|
+
// keep returning "awaiting human review" forever (refreshComposition's flag-dedup),
|
|
177
|
+
// and the composition's baseline would never advance. `fetchLiveState` reads the
|
|
178
|
+
// composition's current live_render_id + render statuses so each run can adopt an
|
|
179
|
+
// out-of-band human decision before deciding.
|
|
180
|
+
async function defaultFetchLiveState(spec, deps = {}) {
|
|
181
|
+
const apiBaseUrl = deps.apiBaseUrl || apiClient.getApiBaseUrl();
|
|
182
|
+
const apiKey =
|
|
183
|
+
process.env.RESHOT_API_KEY ||
|
|
184
|
+
compose.resolveComposeProjectContext({ projectOption: spec.projectId }).apiKey;
|
|
185
|
+
const headers = { authorization: `Bearer ${apiKey}` };
|
|
186
|
+
const compsRes = await fetch(`${apiBaseUrl}/projects/${spec.projectId}/compositions`, { headers });
|
|
187
|
+
const comps = (await compsRes.json()).compositions || [];
|
|
188
|
+
const liveRenderId = comps.find((c) => c.id === spec.compositionId)?.live_render_id || null;
|
|
189
|
+
const rendersRes = await fetch(
|
|
190
|
+
`${apiBaseUrl}/projects/${spec.projectId}/compositions/${spec.compositionId}/renders`,
|
|
191
|
+
{ headers },
|
|
192
|
+
);
|
|
193
|
+
const renders = (await rendersRes.json()).renders || [];
|
|
194
|
+
return { liveRenderId, renders: renders.map((r) => ({ id: r.id, status: r.status })) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Adopt an out-of-band human decision recorded on the platform. Returns a mode tag
|
|
198
|
+
// when it adopted (so the summary can report it), or null when there was nothing to
|
|
199
|
+
// reconcile. Adopting means: make the now-live structure the accepted baseline
|
|
200
|
+
// (advance reference.signature + reference frame to the current recapture) and clear
|
|
201
|
+
// the outstanding flag, so the very next decision is a clean skip rather than a
|
|
202
|
+
// perpetual "awaiting human review".
|
|
203
|
+
async function reconcilePendingFlag(spec, evaluation, deps, now) {
|
|
204
|
+
if (!spec.pendingFlag) return null;
|
|
205
|
+
const fetchLiveState = deps.fetchLiveState || defaultFetchLiveState;
|
|
206
|
+
let state;
|
|
207
|
+
try {
|
|
208
|
+
state = await fetchLiveState(spec, deps);
|
|
209
|
+
} catch {
|
|
210
|
+
// Reconciliation is best-effort: if the platform is unreachable we fall back
|
|
211
|
+
// to the existing fail-safe flag-dedup (no bad publish, just no adoption).
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (!state) return null;
|
|
215
|
+
|
|
216
|
+
const flagged = (state.renders || []).find((r) => r.id === spec.pendingFlag.renderId);
|
|
217
|
+
const humanApproved = flagged && flagged.status === "APPROVED";
|
|
218
|
+
const liveIsFlagged = state.liveRenderId && state.liveRenderId === spec.pendingFlag.renderId;
|
|
219
|
+
|
|
220
|
+
if (humanApproved || liveIsFlagged) {
|
|
221
|
+
spec.reference = {
|
|
222
|
+
signature: spec.pendingFlag.signature,
|
|
223
|
+
capturedAt: now,
|
|
224
|
+
adoptedFrom: "human-approval",
|
|
225
|
+
adoptedRenderId: spec.pendingFlag.renderId,
|
|
226
|
+
};
|
|
227
|
+
spec.liveRenderId = state.liveRenderId || spec.pendingFlag.renderId;
|
|
228
|
+
spec.pendingFlag = null;
|
|
229
|
+
if (evaluation.remountPng) {
|
|
230
|
+
await specStore.writeReferencePng(spec.compositionId, evaluation.remountPng);
|
|
231
|
+
}
|
|
232
|
+
await specStore.writeSpec(spec);
|
|
233
|
+
return { mode: "human-approval", liveRenderId: spec.liveRenderId };
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function defaultUpload(spec, pack, { autoApprove }) {
|
|
239
|
+
const ctx = await compose.resolveComposeContext(spec.composePath, {});
|
|
240
|
+
const apiBaseUrl = apiClient.getApiBaseUrl();
|
|
241
|
+
const apiKey =
|
|
242
|
+
process.env.RESHOT_API_KEY ||
|
|
243
|
+
(compose.resolveComposeProjectContext({ projectOption: spec.projectId }).apiKey);
|
|
244
|
+
return compose.uploadComposition({
|
|
245
|
+
apiBaseUrl,
|
|
246
|
+
apiKey,
|
|
247
|
+
projectId: spec.projectId,
|
|
248
|
+
name: spec.name || compose.deriveSlug(spec.composePath),
|
|
249
|
+
slug: spec.slug || ctx.slug,
|
|
250
|
+
sourceTsx: await fs.readFile(ctx.compositionPath, "utf8"),
|
|
251
|
+
metadataJson: await fs.readFile(ctx.metadataPath, "utf8"),
|
|
252
|
+
pack,
|
|
253
|
+
autoApprove,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function refreshComposition(spec, deps = {}) {
|
|
258
|
+
const au = deps.autoUpdate || (await loadAutoUpdate());
|
|
259
|
+
const evaluate = deps.evaluate || au.evaluateUrl;
|
|
260
|
+
const render = deps.render || defaultRender;
|
|
261
|
+
const upload = deps.upload || defaultUpload;
|
|
262
|
+
const now = new Date().toISOString();
|
|
263
|
+
|
|
264
|
+
// Re-resolve auth each run (sessions/demo state may rotate) so the loop can
|
|
265
|
+
// recapture authenticated /app screens, not just public pages.
|
|
266
|
+
const storageState = await resolveStorageState(spec.source && spec.source.auth, spec.source.url, deps);
|
|
267
|
+
const evaluation = await evaluate(spec.source.url, { settings: captureSettings(spec), storageState });
|
|
268
|
+
|
|
269
|
+
// Reconcile any out-of-band human decision (Task C): if the previously-flagged
|
|
270
|
+
// candidate was approved in the dashboard, adopt it as the new baseline BEFORE
|
|
271
|
+
// deciding — otherwise the flag-dedup below would loop "awaiting human review"
|
|
272
|
+
// forever and this composition's baseline would never advance again.
|
|
273
|
+
const reconciled = await reconcilePendingFlag(spec, evaluation, deps, now);
|
|
274
|
+
|
|
275
|
+
// Did the source screen change vs the accepted reference frame?
|
|
276
|
+
let refDiff = null;
|
|
277
|
+
let changed = true;
|
|
278
|
+
const referencePng = await specStore.readReferencePng(spec.compositionId);
|
|
279
|
+
if (referencePng && evaluation.remountPng) {
|
|
280
|
+
refDiff = au.diffImages(au.decodePng(evaluation.remountPng), au.decodePng(referencePng));
|
|
281
|
+
changed = !au.isUnchanged(refDiff);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const decision = au.decideUpdate({
|
|
285
|
+
prevSignature: (spec.reference && spec.reference.signature) || null,
|
|
286
|
+
evaluation,
|
|
287
|
+
changed,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const summary = {
|
|
291
|
+
compositionId: spec.compositionId,
|
|
292
|
+
slug: spec.slug,
|
|
293
|
+
route: evaluation.decision.route,
|
|
294
|
+
eligible: decision.eligible,
|
|
295
|
+
qualityPass: decision.qualityPass,
|
|
296
|
+
structureStable: decision.structureStable,
|
|
297
|
+
metrics: decision.metrics,
|
|
298
|
+
referenceDiffPct: refDiff ? refDiff.pixelDiffPct : null,
|
|
299
|
+
reason: decision.reason,
|
|
300
|
+
reconciled: reconciled ? reconciled.mode : null,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if (decision.action === "skip") {
|
|
304
|
+
return { ...summary, action: "skip", rendered: false, reviewItemCreated: false };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Flag idempotence: the same outstanding redesign is not re-flagged while a
|
|
308
|
+
// human review is still pending for it.
|
|
309
|
+
if (
|
|
310
|
+
decision.action === "flag" &&
|
|
311
|
+
spec.pendingFlag &&
|
|
312
|
+
au.signatureSimilarity(spec.pendingFlag.signature, decision.signature) >= au.STRUCTURE_STABLE_MIN
|
|
313
|
+
) {
|
|
314
|
+
return {
|
|
315
|
+
...summary,
|
|
316
|
+
action: "skip",
|
|
317
|
+
rendered: false,
|
|
318
|
+
reviewItemCreated: false,
|
|
319
|
+
reason: "already flagged; awaiting human review (idempotent)",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Clean-data publish sub-gate (Phase 4.6): never AUTO-PUBLISH a clip whose
|
|
324
|
+
// captured screen carries messy/empty/placeholder data or leaked PII. Only the
|
|
325
|
+
// PUBLISH (auto-approve) path is gated — flags still go to human review — and only
|
|
326
|
+
// when we have a snapshot (reconstruction-eligible) and the comp didn't disable it.
|
|
327
|
+
// A dirty screen → SKIP (keep the last good clip live, reference unchanged): never
|
|
328
|
+
// render or upload a dirty clip. The guard surfaces dirty data; it does not fix it.
|
|
329
|
+
if (
|
|
330
|
+
decision.action === "publish" &&
|
|
331
|
+
evaluation.snapshot &&
|
|
332
|
+
!(spec.cleanData && spec.cleanData.disabled)
|
|
333
|
+
) {
|
|
334
|
+
const guard = deps.checkCleanData || au.checkCleanData;
|
|
335
|
+
if (typeof guard === "function") {
|
|
336
|
+
const cleanData = guard({ html: evaluation.snapshot.html }, (spec.cleanData && spec.cleanData.config) || {});
|
|
337
|
+
if (cleanData && !cleanData.clean) {
|
|
338
|
+
return {
|
|
339
|
+
...summary,
|
|
340
|
+
action: "skip",
|
|
341
|
+
rendered: false,
|
|
342
|
+
reviewItemCreated: false,
|
|
343
|
+
reason: `clean-data guard: ${cleanData.reasons.join("; ")}`,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const pack = await render(spec, deps, evaluation);
|
|
350
|
+
const uploaded = await upload(spec, pack, { autoApprove: decision.action === "publish" }, deps);
|
|
351
|
+
const renderId = (uploaded && (uploaded.render?.id || uploaded.render_id)) || null;
|
|
352
|
+
|
|
353
|
+
if (decision.action === "publish") {
|
|
354
|
+
if (evaluation.remountPng) {
|
|
355
|
+
await specStore.writeReferencePng(spec.compositionId, evaluation.remountPng);
|
|
356
|
+
}
|
|
357
|
+
spec.reference = { signature: decision.signature, capturedAt: now };
|
|
358
|
+
spec.pendingFlag = null;
|
|
359
|
+
spec.liveRenderId = renderId || spec.liveRenderId || null;
|
|
360
|
+
await specStore.writeSpec(spec);
|
|
361
|
+
return {
|
|
362
|
+
...summary,
|
|
363
|
+
action: "publish",
|
|
364
|
+
rendered: true,
|
|
365
|
+
reviewItemCreated: false,
|
|
366
|
+
renderId,
|
|
367
|
+
liveRenderId: spec.liveRenderId,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// flag
|
|
372
|
+
spec.pendingFlag = { signature: decision.signature, renderId, flaggedAt: now };
|
|
373
|
+
await specStore.writeSpec(spec);
|
|
374
|
+
return {
|
|
375
|
+
...summary,
|
|
376
|
+
action: "flag",
|
|
377
|
+
rendered: true,
|
|
378
|
+
reviewItemCreated: true,
|
|
379
|
+
renderId,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Validate a scene config (the `spec.scene` that makes the loop render a crisp
|
|
384
|
+
// <Scene>+motion clip instead of the video fallback). Pure → unit-testable. Needs a
|
|
385
|
+
// non-empty camera and/or motion array (else sceneEligible falls back to video).
|
|
386
|
+
function validateSceneConfig(scene) {
|
|
387
|
+
if (!scene || typeof scene !== "object" || Array.isArray(scene)) {
|
|
388
|
+
throw new Error("scene config must be a JSON object");
|
|
389
|
+
}
|
|
390
|
+
const hasCamera = Array.isArray(scene.camera) && scene.camera.length > 0;
|
|
391
|
+
const hasMotion = Array.isArray(scene.motion) && scene.motion.length > 0;
|
|
392
|
+
if (!hasCamera && !hasMotion) {
|
|
393
|
+
throw new Error("scene config needs a non-empty `camera` and/or `motion` array (else the loop renders video)");
|
|
394
|
+
}
|
|
395
|
+
const MOTION_TYPES = new Set(["reveal", "highlight", "cursor"]);
|
|
396
|
+
for (const step of scene.motion || []) {
|
|
397
|
+
if (!step || !MOTION_TYPES.has(step.type)) {
|
|
398
|
+
throw new Error(`scene motion step has invalid type ${JSON.stringify(step && step.type)} (expected reveal|highlight|cursor)`);
|
|
399
|
+
}
|
|
400
|
+
if (!step.at) throw new Error(`scene motion step ${JSON.stringify(step.id || "?")} is missing "at"`);
|
|
401
|
+
}
|
|
402
|
+
for (const step of scene.camera || []) {
|
|
403
|
+
if (!step || !step.at) throw new Error(`scene camera step ${JSON.stringify((step && step.id) || "?")} is missing "at"`);
|
|
404
|
+
}
|
|
405
|
+
return scene;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Attach/update the scene config on an ALREADY-enrolled composition, so the loop
|
|
409
|
+
// renders it as a crisp <Scene>+motion clip. Use after --register (or to iterate on
|
|
410
|
+
// the motion) without recapturing the baseline.
|
|
411
|
+
async function setScene(compositionId, scene) {
|
|
412
|
+
if (!compositionId) throw new Error("--set-scene requires --composition <id>");
|
|
413
|
+
const validated = validateSceneConfig(scene);
|
|
414
|
+
let spec;
|
|
415
|
+
try {
|
|
416
|
+
spec = await specStore.readSpec(compositionId);
|
|
417
|
+
} catch {
|
|
418
|
+
spec = null;
|
|
419
|
+
}
|
|
420
|
+
if (!spec) throw new Error(`No spec for composition ${compositionId} — enroll it with --register first.`);
|
|
421
|
+
spec.scene = validated;
|
|
422
|
+
await specStore.writeSpec(spec);
|
|
423
|
+
return { compositionId, updated: true, mode: validated.motion ? "scene+motion" : "scene" };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Build a Playwright storageState (cookies + per-origin localStorage) so the capture
|
|
427
|
+
// pipeline can reach authenticated /app screens. Stored on the spec as `source.auth`
|
|
428
|
+
// and re-resolved on every recapture, so the daily loop stays authenticated too.
|
|
429
|
+
// Modes:
|
|
430
|
+
// { mode: "demo-bootstrap", bootstrapUrl?, email? } — POST the demo bootstrap and
|
|
431
|
+
// synthesize the demo session (turnkey for local/seeded dev).
|
|
432
|
+
// { mode: "storage-state", path } — load a Playwright storageState JSON exported
|
|
433
|
+
// from a real session (the production-grade path).
|
|
434
|
+
async function resolveStorageState(auth, sourceUrl, deps = {}) {
|
|
435
|
+
if (!auth || !auth.mode) return undefined;
|
|
436
|
+
if (auth.mode === "storage-state") {
|
|
437
|
+
if (!auth.path) throw new Error("auth.mode 'storage-state' requires a path");
|
|
438
|
+
return JSON.parse(await fs.readFile(auth.path, "utf8"));
|
|
439
|
+
}
|
|
440
|
+
if (auth.mode === "demo-bootstrap") {
|
|
441
|
+
const doFetch = deps.fetch || globalThis.fetch;
|
|
442
|
+
if (!doFetch) throw new Error("global fetch unavailable (need Node 18+) for demo-bootstrap auth");
|
|
443
|
+
const parsed = new URL(sourceUrl);
|
|
444
|
+
const bootstrapUrl = auth.bootstrapUrl || `${parsed.origin}/api/internal/demo/bootstrap`;
|
|
445
|
+
const email = auth.email || "demo@example.com";
|
|
446
|
+
const resp = await doFetch(bootstrapUrl, {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: { "content-type": "application/json" },
|
|
449
|
+
body: JSON.stringify({ email }),
|
|
450
|
+
});
|
|
451
|
+
if (!resp.ok) {
|
|
452
|
+
throw new Error(`demo bootstrap failed: HTTP ${resp.status} at ${bootstrapUrl} (is the seeded app running?)`);
|
|
453
|
+
}
|
|
454
|
+
const state = (await resp.json()).data;
|
|
455
|
+
const authStateCache = JSON.stringify({
|
|
456
|
+
data: {
|
|
457
|
+
user: {
|
|
458
|
+
id: state.user.id, email: state.user.email, fullName: state.user.fullName,
|
|
459
|
+
workspaceId: state.workspace.id, workspace: state.workspace, role: "OWNER",
|
|
460
|
+
onboardingStatus: "COMPLETED",
|
|
461
|
+
},
|
|
462
|
+
isAuthenticated: true,
|
|
463
|
+
needsOnboarding: false,
|
|
464
|
+
workspaces: [{ ...state.workspace, role: "OWNER" }],
|
|
465
|
+
},
|
|
466
|
+
timestamp: Date.now(),
|
|
467
|
+
});
|
|
468
|
+
const cookie = (name, value) => ({
|
|
469
|
+
name, value, domain: parsed.hostname, path: "/", expires: -1, httpOnly: false, secure: parsed.protocol === "https:", sameSite: "Lax",
|
|
470
|
+
});
|
|
471
|
+
return {
|
|
472
|
+
cookies: [cookie("test-user-email", state.user.email), cookie("reshot-demo-mode", "true")],
|
|
473
|
+
origins: [
|
|
474
|
+
{
|
|
475
|
+
origin: parsed.origin,
|
|
476
|
+
localStorage: [
|
|
477
|
+
{ name: "reshot-demo-mode", value: "true" },
|
|
478
|
+
{ name: "auth_state_cache", value: authStateCache },
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
throw new Error(`unknown auth.mode: ${JSON.stringify(auth.mode)} (expected demo-bootstrap | storage-state)`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Enroll an existing composition into the auto-update loop: capture its source
|
|
488
|
+
// screen once to record the accepted baseline (structure signature + reference
|
|
489
|
+
// frame), and persist the spec so future `reshot refresh` runs are reproducible.
|
|
490
|
+
// The spec/`spec.js` error message references `--register`; this is it.
|
|
491
|
+
// `scene` (optional) attaches the crisp <Scene>+motion render config at enroll time.
|
|
492
|
+
// `auth` (optional) lets the capture reach authenticated screens (see resolveStorageState).
|
|
493
|
+
async function registerComposition(
|
|
494
|
+
{ compositionId, projectId, url, viewport, composePath, slug, name, outBase, scene, auth } = {},
|
|
495
|
+
deps = {},
|
|
496
|
+
) {
|
|
497
|
+
if (!compositionId) throw new Error("--register requires --composition <id>");
|
|
498
|
+
if (!projectId) throw new Error("--register requires --project <id> (or RESHOT_PROJECT_ID)");
|
|
499
|
+
if (!url) throw new Error("--register requires --url <sourceScreenUrl>");
|
|
500
|
+
|
|
501
|
+
const au = deps.autoUpdate || (await loadAutoUpdate());
|
|
502
|
+
const evaluate = deps.evaluate || au.evaluateUrl;
|
|
503
|
+
const settings = viewport
|
|
504
|
+
? { width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.deviceScaleFactor || 2 }
|
|
505
|
+
: undefined;
|
|
506
|
+
|
|
507
|
+
const storageState = await resolveStorageState(auth, url, deps);
|
|
508
|
+
const evaluation = await evaluate(url, { settings, storageState });
|
|
509
|
+
if (!evaluation.classification.eligible) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`Source screen is not reconstruction-eligible, cannot enroll: ${evaluation.classification.reasons.join("; ")}` +
|
|
512
|
+
(auth ? "" : " (if this is an authenticated screen, pass --demo-auth or --storage-state)"),
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const spec = {
|
|
517
|
+
compositionId,
|
|
518
|
+
projectId,
|
|
519
|
+
slug,
|
|
520
|
+
name,
|
|
521
|
+
composePath,
|
|
522
|
+
outBase,
|
|
523
|
+
source: { url, viewport: settings, ...(auth ? { auth } : {}) },
|
|
524
|
+
reference: { signature: au.structureSignature(evaluation.classification), capturedAt: new Date().toISOString() },
|
|
525
|
+
pendingFlag: null,
|
|
526
|
+
};
|
|
527
|
+
if (scene !== undefined) spec.scene = validateSceneConfig(scene);
|
|
528
|
+
await specStore.writeSpec(spec);
|
|
529
|
+
if (evaluation.remountPng) await specStore.writeReferencePng(compositionId, evaluation.remountPng);
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
compositionId,
|
|
533
|
+
slug,
|
|
534
|
+
registered: true,
|
|
535
|
+
route: evaluation.decision.route,
|
|
536
|
+
eligible: evaluation.classification.eligible,
|
|
537
|
+
quality: evaluation.quality,
|
|
538
|
+
signature: spec.reference.signature,
|
|
539
|
+
render: spec.scene ? (spec.scene.motion ? "scene+motion" : "scene") : "video (no --scene)",
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function refresh({ compositionId, projectId } = {}, deps = {}) {
|
|
544
|
+
let specs;
|
|
545
|
+
if (compositionId) {
|
|
546
|
+
specs = [await specStore.readSpec(compositionId)];
|
|
547
|
+
} else {
|
|
548
|
+
specs = await specStore.listSpecs(projectId);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const summaries = [];
|
|
552
|
+
for (const spec of specs) {
|
|
553
|
+
// Fail-safe per-composition isolation: one screen that fails to capture or
|
|
554
|
+
// upload must NOT abort a project-wide CI run, and must never touch the live
|
|
555
|
+
// clip. Record the error and carry on (handoff §5 — a wrong "publish" ships a
|
|
556
|
+
// broken demo; doing nothing is always safe).
|
|
557
|
+
try {
|
|
558
|
+
summaries.push(await refreshComposition(spec, deps));
|
|
559
|
+
} catch (error) {
|
|
560
|
+
summaries.push({
|
|
561
|
+
compositionId: spec.compositionId,
|
|
562
|
+
slug: spec.slug,
|
|
563
|
+
action: "error",
|
|
564
|
+
rendered: false,
|
|
565
|
+
reviewItemCreated: false,
|
|
566
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
summaries,
|
|
573
|
+
rendersCreated: summaries.filter((s) => s.rendered).length,
|
|
574
|
+
reviewItemsCreated: summaries.filter((s) => s.reviewItemCreated).length,
|
|
575
|
+
published: summaries.filter((s) => s.action === "publish").length,
|
|
576
|
+
flagged: summaries.filter((s) => s.action === "flag").length,
|
|
577
|
+
skipped: summaries.filter((s) => s.action === "skip").length,
|
|
578
|
+
errors: summaries.filter((s) => s.action === "error").length,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
module.exports = {
|
|
583
|
+
refresh,
|
|
584
|
+
refreshComposition,
|
|
585
|
+
registerComposition,
|
|
586
|
+
validateSceneConfig,
|
|
587
|
+
setScene,
|
|
588
|
+
defaultRender,
|
|
589
|
+
renderScene,
|
|
590
|
+
renderVideo,
|
|
591
|
+
sceneEligible,
|
|
592
|
+
defaultUpload,
|
|
593
|
+
defaultFetchLiveState,
|
|
594
|
+
reconcilePendingFlag,
|
|
595
|
+
loadAutoUpdate,
|
|
596
|
+
captureSettings,
|
|
597
|
+
resolveStorageState,
|
|
598
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { Composition, Scene } from "@reshot/compose";
|
|
4
|
+
|
|
5
|
+
// Phase 4 · Task 4.4 integration — the auto-update loop's <Scene> render template.
|
|
6
|
+
//
|
|
7
|
+
// When a composition opts into reconstruction rendering (`spec.scene`) and the live
|
|
8
|
+
// evaluation routes the source screen to "reconstruction", the refresh orchestrator
|
|
9
|
+
// recaptures the screen, writes the Phase 2 DOM artifact + a scene sidecar, and
|
|
10
|
+
// renders THIS template through the published Phase 1 pipeline. The artifact is
|
|
11
|
+
// mounted as REAL DOM and driven by the composition's authored camera move, so the
|
|
12
|
+
// published clip is vector-crisp (text re-rasterized per frame) instead of an
|
|
13
|
+
// upscaled video. On any failure the orchestrator falls back to the video render.
|
|
14
|
+
//
|
|
15
|
+
// Everything is read from the sidecar (RESHOT_SCENE_DEMO) so this single template
|
|
16
|
+
// serves every eligible composition without per-composition codegen.
|
|
17
|
+
const artifactPath = process.env.RESHOT_SCENE_ARTIFACT!;
|
|
18
|
+
const demoPath = process.env.RESHOT_SCENE_DEMO!;
|
|
19
|
+
|
|
20
|
+
const html = readFileSync(artifactPath, "utf8");
|
|
21
|
+
const demo = JSON.parse(readFileSync(demoPath, "utf8")) as {
|
|
22
|
+
viewport: { width: number; height: number };
|
|
23
|
+
durationMs: number;
|
|
24
|
+
scrolls?: { sel: string; x: number; y: number }[];
|
|
25
|
+
timeline?: { id: string; tMs: number }[];
|
|
26
|
+
targets?: Record<string, { x: number; y: number; width: number; height: number }>;
|
|
27
|
+
camera?: { id: string; at: string; until: string; target?: string; camera: string }[];
|
|
28
|
+
cameraOptions?: {
|
|
29
|
+
settings?: { mode?: string; damping?: number; padding?: number };
|
|
30
|
+
sampleIntervalMs?: number;
|
|
31
|
+
};
|
|
32
|
+
// Phase 4.5 — authored IN-SCENE motion (reveal/highlight/cursor).
|
|
33
|
+
motion?: { id: string; at: string; until?: string; type: string }[];
|
|
34
|
+
motionOptions?: Record<string, unknown>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const source = demo.viewport;
|
|
38
|
+
const durationMs = demo.durationMs;
|
|
39
|
+
|
|
40
|
+
const workflow = {
|
|
41
|
+
durationMs,
|
|
42
|
+
source,
|
|
43
|
+
timeline: demo.timeline ?? [
|
|
44
|
+
{ id: "start", tMs: 0 },
|
|
45
|
+
{ id: "end", tMs: durationMs },
|
|
46
|
+
],
|
|
47
|
+
targets: demo.targets ?? {},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const cameraOptions = demo.cameraOptions ?? {
|
|
51
|
+
settings: { padding: 1.2, damping: 0.9 },
|
|
52
|
+
sampleIntervalMs: 1000 / 60,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const hasCamera = Array.isArray(demo.camera) && demo.camera.length > 0;
|
|
56
|
+
const hasMotion = Array.isArray(demo.motion) && demo.motion.length > 0;
|
|
57
|
+
|
|
58
|
+
export default function SceneRuntime() {
|
|
59
|
+
return (
|
|
60
|
+
<Composition workflow={workflow} slug="scene-runtime" durationMs={durationMs}>
|
|
61
|
+
<Scene
|
|
62
|
+
artifact={{ html, scrolls: demo.scrolls ?? [], viewport: source }}
|
|
63
|
+
// Authored camera (ProductFilmStep[]) and/or in-scene motion
|
|
64
|
+
// (reveal/highlight/cursor) — both solved inside the composition's React
|
|
65
|
+
// context and applied per seeked frame on the deterministic clock.
|
|
66
|
+
camera={hasCamera ? (demo.camera as never) : undefined}
|
|
67
|
+
cameraOptions={hasCamera ? (cameraOptions as never) : undefined}
|
|
68
|
+
motion={hasMotion ? (demo.motion as never) : undefined}
|
|
69
|
+
motionOptions={hasMotion ? (demo.motionOptions as never) : undefined}
|
|
70
|
+
/>
|
|
71
|
+
</Composition>
|
|
72
|
+
);
|
|
73
|
+
}
|