v0.2.0-alpha: DOM capture, all three output slices, lead gates

- Extension DOM mode: auth-context serializer — inlines CSS + images as data
  URLs, freezes form state, masks [data-dp-mask], strips scripts (R3 client)
- API: extractDataUrls rewrites large inline resources into content-addressed
  assets (R3 server; verified 939KB inline -> 82-byte row + served asset)
- Lead-gate steps: config on step, player overlay form blocks advance,
  submits to leads endpoint (editor toggle + MCP set_lead_gate)
- Step-guide export (v2 slice): authed markdown + public HTML at /p/:token/guide
- Video export (v3 slice, v0): ffmpeg slideshow of image steps + optional
  narration mux; DOM rasterization deferred to Chromium service (R4)
- Editor: reorder arrows, embed-code copy, guide link, video export
- REST reorder endpoint; MCP surface now 13 tools (export_guide,
  set_lead_gate, render_video)
- ffmpeg added to runtime image

E2E verified incl. R3 extraction round-trip, lead_gate in public JSON,
guide renders, MCP agent exported guide + rendered video.
This commit is contained in:
Scot Thom 2026-07-18 05:27:58 +00:00
parent 9b09f14916
commit a535e15253
18 changed files with 541 additions and 22 deletions

View file

@ -1,11 +1,17 @@
// Screenshot-flow capture: on each click in the recorded tab, grab a screenshot
// plus the click position, building image Steps with pre-placed hotspots.
let recording = { active: false, tabId: null, steps: [] };
let recording = { active: false, tabId: null, mode: "screenshot", steps: [] };
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "start") {
recording = { active: true, tabId: msg.tabId, steps: [] };
chrome.scripting.executeScript({ target: { tabId: msg.tabId }, files: ["content.js"] });
recording = { active: true, tabId: msg.tabId, mode: msg.mode || "screenshot", steps: [] };
chrome.scripting.executeScript({
target: { tabId: msg.tabId },
files: [recording.mode === "dom" ? "dom-content.js" : "content.js"],
});
sendResponse({ ok: true });
} else if (msg.type === "domstep" && recording.active && sender.tab?.id === recording.tabId) {
recording.steps.push({ html: msg.html, xPct: msg.xPct, yPct: msg.yPct });
sendResponse({ ok: true });
} else if (msg.type === "click" && recording.active && sender.tab?.id === recording.tabId) {
chrome.tabs.captureVisibleTab({ format: "png" }, (dataUrl) => {
@ -17,7 +23,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ active: recording.active, count: recording.steps.length });
} else if (msg.type === "stop") {
recording.active = false;
sendResponse({ steps: recording.steps });
sendResponse({ steps: recording.steps, mode: recording.mode });
} else if (msg.type === "discard") {
recording = { active: false, tabId: null, steps: [] };
sendResponse({ ok: true });

110
extension/dom-content.js Normal file
View file

@ -0,0 +1,110 @@
// DOM-mode capture: on each click, serialize the live document into a
// self-contained HTML snapshot (R3 client half — runs inside the user's auth
// context so protected images/styles resolve). Scripts stripped; form state
// frozen; [data-dp-mask] elements masked.
(function () {
if (window.__dpDomCaptureInstalled) return;
window.__dpDomCaptureInstalled = true;
var busy = Promise.resolve();
async function toDataUrl(url) {
try {
var res = await fetch(url, { credentials: "include" });
if (!res.ok) return null;
var blob = await res.blob();
if (blob.size > 4 * 1024 * 1024) return null; // cap single asset at 4MB
return await new Promise(function (resolve) {
var r = new FileReader();
r.onload = function () { resolve(r.result); };
r.onerror = function () { resolve(null); };
r.readAsDataURL(blob);
});
} catch (_) { return null; }
}
async function collectCss() {
var parts = [];
for (var i = 0; i < document.styleSheets.length; i++) {
var sheet = document.styleSheets[i];
try {
var rules = sheet.cssRules;
var buf = [];
for (var j = 0; j < rules.length; j++) buf.push(rules[j].cssText);
parts.push(buf.join("\n"));
} catch (_) {
// cross-origin: fetch the stylesheet ourselves
if (sheet.href) {
try {
var res = await fetch(sheet.href, { credentials: "include" });
if (res.ok) parts.push(await res.text());
} catch (_) { /* skip */ }
}
}
}
return parts.join("\n\n");
}
async function serialize() {
var root = document.documentElement.cloneNode(true);
// strip live/script-ish elements
root.querySelectorAll("script,noscript,iframe,link[rel=stylesheet],style,object,embed").forEach(function (el) { el.remove(); });
// mask sensitive elements
root.querySelectorAll("[data-dp-mask]").forEach(function (el) { el.textContent = "•••••"; });
// freeze form state from the LIVE dom onto the clone (index-aligned)
var liveInputs = document.querySelectorAll("input,textarea,select");
var cloneInputs = root.querySelectorAll("input,textarea,select");
liveInputs.forEach(function (live, i) {
var c = cloneInputs[i];
if (!c) return;
if (live.tagName === "TEXTAREA") c.textContent = live.value;
else if (live.tagName === "SELECT") {
Array.prototype.forEach.call(c.options, function (o, k) { if (k === live.selectedIndex) o.setAttribute("selected", ""); else o.removeAttribute("selected"); });
} else {
if (live.type === "checkbox" || live.type === "radio") { if (live.checked) c.setAttribute("checked", ""); else c.removeAttribute("checked"); }
else if (live.type === "password") c.setAttribute("value", "•••••");
else c.setAttribute("value", live.value);
}
});
// inline images from the LIVE dom (auth context), index-aligned
var liveImgs = document.querySelectorAll("img");
var cloneImgs = root.querySelectorAll("img");
for (var i = 0; i < liveImgs.length; i++) {
var src = liveImgs[i].currentSrc || liveImgs[i].src;
if (!src || src.startsWith("data:")) continue;
var d = await toDataUrl(src);
if (cloneImgs[i]) {
if (d) { cloneImgs[i].setAttribute("src", d); cloneImgs[i].removeAttribute("srcset"); }
else cloneImgs[i].setAttribute("src", src); // fall back to absolute URL
}
}
var css = await collectCss();
var head = root.querySelector("head") || root.insertBefore(document.createElement("head"), root.firstChild);
var style = document.createElement("style");
style.textContent = css;
head.appendChild(style);
var base = document.createElement("base");
base.setAttribute("href", location.origin);
head.insertBefore(base, head.firstChild);
return "<!doctype html>\n" + root.outerHTML;
}
document.addEventListener(
"click",
function (e) {
var xPct = (e.clientX / window.innerWidth) * 100;
var yPct = (e.clientY / window.innerHeight) * 100;
busy = busy.then(async function () {
var html = await serialize();
chrome.runtime.sendMessage({ type: "domstep", html: html, xPct: xPct, yPct: yPct });
});
},
true,
);
})();

View file

@ -12,6 +12,12 @@
<body>
<label>Platform URL <input id="base" placeholder="https://demo.example.com"></label>
<label>API key <input id="key" type="password" placeholder="dp_..."></label>
<label>Capture mode
<select id="mode" style="width:100%;margin:4px 0 8px;padding:6px 8px;border:1px solid #e2e8f0;border-radius:6px">
<option value="screenshot">Screenshot (works everywhere)</option>
<option value="dom">DOM (editable, web apps)</option>
</select>
</label>
<div id="status">Not recording.</div>
<button id="start" class="primary">Start recording this tab</button>
<button id="finish" hidden>Finish &amp; upload</button>

View file

@ -1,9 +1,11 @@
const $ = (id) => document.getElementById(id);
chrome.storage.local.get(["base", "key"], (v) => {
chrome.storage.local.get(["base", "key", "mode"], (v) => {
if (v.base) $("base").value = v.base;
if (v.key) $("key").value = v.key;
if (v.mode) $("mode").value = v.mode;
});
$("mode").addEventListener("change", () => chrome.storage.local.set({ mode: $("mode").value }));
const save = () => chrome.storage.local.set({ base: $("base").value.trim(), key: $("key").value.trim() });
$("base").addEventListener("change", save);
$("key").addEventListener("change", save);
@ -21,13 +23,13 @@ refresh();
$("start").addEventListener("click", async () => {
save();
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
chrome.runtime.sendMessage({ type: "start", tabId: tab.id }, refresh);
chrome.runtime.sendMessage({ type: "start", tabId: tab.id, mode: $("mode").value }, refresh);
});
$("discard").addEventListener("click", () => chrome.runtime.sendMessage({ type: "discard" }, refresh));
$("finish").addEventListener("click", () => {
chrome.runtime.sendMessage({ type: "stop" }, async ({ steps }) => {
chrome.runtime.sendMessage({ type: "stop" }, async ({ steps, mode }) => {
const base = $("base").value.trim().replace(/\/$/, "");
const key = $("key").value.trim();
if (!base || !key) { $("status").textContent = "Set platform URL and API key first."; return; }
@ -41,15 +43,24 @@ $("finish").addEventListener("click", () => {
body: JSON.stringify({ name: `Capture ${new Date().toLocaleString()}` }),
})).json();
for (const s of steps) {
const blob = await (await fetch(s.dataUrl)).blob();
const fd = new FormData();
fd.append("file", blob, "step.png");
const asset = await (await fetch(`${base}/api/assets`, { method: "POST", headers: auth, body: fd })).json();
const step = await (await fetch(`${base}/api/demos/${demo.id}/steps`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ snapshot_type: "image", asset_id: asset.id }),
})).json();
let step;
if (mode === "dom") {
step = await (await fetch(`${base}/api/demos/${demo.id}/steps`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ snapshot_type: "dom", dom_html: s.html }),
})).json();
} else {
const blob = await (await fetch(s.dataUrl)).blob();
const fd = new FormData();
fd.append("file", blob, "step.png");
const asset = await (await fetch(`${base}/api/assets`, { method: "POST", headers: auth, body: fd })).json();
step = await (await fetch(`${base}/api/demos/${demo.id}/steps`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ snapshot_type: "image", asset_id: asset.id }),
})).json();
}
// pre-place a hotspot centred on the recorded click
await fetch(`${base}/api/steps/${step.id}`, {
method: "PATCH",