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

@ -65,7 +65,11 @@ export function App(): React.ReactElement {
{current.steps.map((s, i) => (
<li key={s.id} className={i === stepIdx ? "active" : ""} onClick={() => setStepIdx(i)}>
<span>Step {i + 1}</span>
<button className="x" title="Delete step" onClick={(e) => { e.stopPropagation(); if (confirm("Delete step?")) api.deleteStep(s.id).then(reload); }}>×</button>
<span className="mini">
<button className="x" title="Move up" disabled={i === 0} onClick={(e) => { e.stopPropagation(); const ids = current.steps.map((x) => x.id); [ids[i - 1], ids[i]] = [ids[i], ids[i - 1]]; api.reorder(current.id, ids).then(reload); }}></button>
<button className="x" title="Move down" disabled={i === current.steps.length - 1} onClick={(e) => { e.stopPropagation(); const ids = current.steps.map((x) => x.id); [ids[i + 1], ids[i]] = [ids[i], ids[i + 1]]; api.reorder(current.id, ids).then(reload); }}></button>
<button className="x" title="Delete step" onClick={(e) => { e.stopPropagation(); if (confirm("Delete step?")) api.deleteStep(s.id).then(reload); }}>×</button>
</span>
</li>
))}
</ol>
@ -81,6 +85,14 @@ export function App(): React.ReactElement {
{current.status === "published" ? "Republish" : "Publish"}
</button>
{current.share_token && <a className="share" href={`/p/${current.share_token}`} target="_blank" rel="noreferrer">Open player </a>}
{current.share_token && <a className="share" href={`/p/${current.share_token}/guide`} target="_blank" rel="noreferrer">Step guide </a>}
{current.share_token && (
<button onClick={() => {
const code = `<iframe src="${location.origin}/p/${current.share_token}" width="100%" height="600" style="border:1px solid #e2e8f0;border-radius:8px" allowfullscreen></iframe>`;
navigator.clipboard.writeText(code); alert("Embed code copied:\n\n" + code);
}}>Copy embed code</button>
)}
<button onClick={() => api.renderVideo(current.id).then((r) => alert(`Video rendered${r.skipped_dom_steps ? ` (${r.skipped_dom_steps} DOM steps skipped)` : ""}. Download: ${location.origin}${r.download}`)).catch((e) => alert(String(e)))}>Export video</button>
</aside>
<main>
{err && <div className="err">{err}</div>}
@ -96,8 +108,9 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
const [box, setBox] = useState<Hotspot | null>(step.hotspot);
const [title, setTitle] = useState(step.annotation?.title ?? "");
const [body, setBody] = useState(step.annotation?.body ?? "");
const [gated, setGated] = useState(!!step.lead_gate?.enabled);
useEffect(() => { setBox(step.hotspot); setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? ""); }, [step.id]);
useEffect(() => { setBox(step.hotspot); setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? ""); setGated(!!step.lead_gate?.enabled); }, [step.id]);
const pct = (e: React.MouseEvent): { x: number; y: number } => {
const r = ref.current!.getBoundingClientRect();
@ -138,11 +151,16 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
<div className="ann">
<input placeholder="Tooltip title" value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea placeholder="Tooltip body" value={body} onChange={(e) => setBody(e.target.value)} rows={3} />
<label className="gate"><input type="checkbox" checked={gated} onChange={(e) => setGated(e.target.checked)} /> Lead gate: require name + email before advancing past this step</label>
<div className="row">
<button onClick={() =>
api.patchStep(step.id, {
hotspot: box,
annotation: title || body ? { title, body } : null,
lead_gate: gated ? { enabled: true, fields: [
{ name: "name", label: "Your name", required: true },
{ name: "email", label: "Work email", required: true },
] } : null,
}).then(onSaved)
}>Save step</button>
<button className="ghost" onClick={() => setBox(null)}>Clear hotspot</button>

View file

@ -20,6 +20,7 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
export interface Hotspot { x: number; y: number; w: number; h: number; }
export interface Annotation { title: string; body: string; }
export interface LeadGate { enabled: boolean; fields: { name: string; label: string; required: boolean }[]; }
export interface Step {
id: string;
position: number;
@ -27,6 +28,7 @@ export interface Step {
asset_id: string | null;
hotspot: Hotspot | null;
annotation: Annotation | null;
lead_gate?: LeadGate | null;
}
export interface Demo {
id: string;
@ -40,10 +42,12 @@ export const api = {
listDemos: () => req<Demo[]>("GET", "/api/demos"),
getDemo: (id: string) => req<Demo & { steps: Step[] }>("GET", `/api/demos/${id}`),
createDemo: (name: string) => req<Demo>("POST", "/api/demos", { name }),
patchStep: (id: string, patch: Partial<Pick<Step, "hotspot" | "annotation" | "position">>) =>
patchStep: (id: string, patch: Partial<Pick<Step, "hotspot" | "annotation" | "position" | "lead_gate">>) =>
req<Step>("PATCH", `/api/steps/${id}`, patch),
deleteStep: (id: string) => req<{ ok: boolean }>("DELETE", `/api/steps/${id}`),
publish: (id: string) => req<{ share_token: string; url: string }>("POST", `/api/demos/${id}/publish`),
reorder: (id: string, ids: string[]) => req<{ ok: boolean }>("POST", `/api/demos/${id}/reorder`, { ordered_step_ids: ids }),
renderVideo: (id: string) => req<{ asset_id: string; download: string; skipped_dom_steps: number }>("POST", `/api/demos/${id}/video`, {}),
addImageStep: async (demoId: string, file: File): Promise<Step> => {
const fd = new FormData();
fd.append("file", file);

View file

@ -37,3 +37,8 @@ input,textarea { font:inherit; border:1px solid var(--line); border-radius:6px;
.ann { display:flex; flex-direction:column; gap:8px; margin-top:12px; }
.ann .row { display:flex; gap:8px; }
.ghost { color:var(--muted); }
.mini { display:flex; gap:2px; }
.mini .x[disabled] { opacity:.3; cursor:default; }
.gate { font-size:13px; color:var(--muted); display:flex; gap:8px; align-items:center; }
.gate input { width:auto; }