Files
workbuddy_skills/browser-harness/src/browser_harness/video-template.html
T

970 lines
69 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<!-- browser-harness video template: renders a recording as a structured,
privacy-safe explanatory video — chapter plan, semantic routes, readable
narration, cursor-led camera, immediate click results, explanation/outcome
cards — and exports webm via MediaRecorder. Everything is a pure function
of the playhead: seek(t) is exact. See make-video.md for the workflow. -->
<html>
<head>
<meta charset="utf-8">
<title>browser-harness video</title>
<style>
html, body { margin: 0; height: 100%; background: #0b0e14; overflow: hidden; }
#stage { display: block; width: 100vw; height: 100vh; object-fit: contain; }
#hud { position: fixed; left: 12px; bottom: 10px; color: #9aa; font: 12px/1.4 ui-monospace, monospace;
background: rgba(0,0,0,.55); padding: 6px 10px; border-radius: 8px; user-select: none; }
</style>
</head>
<body>
<canvas id="stage" width="1920" height="1080"></canvas>
<div id="hud"></div>
<script src="composition.js"></script>
<script>
"use strict";
/* composition.js is compiler-owned. Author edit-brief.json; do not hand-edit
renderer fields or copy this template as an alternative workflow. */
const C = window.COMPOSITION;
const VW = () => C.viewport?.w || 1392, VH = () => C.viewport?.h || 1170;
// ------------------------------ tuning ------------------------------
const W = 1920, H = 1080;
const PAD = 108; // generous breathing room around the window
const FRAME_STYLE = C.frameStyle || "native";
const TABSTRIP = FRAME_STYLE === "browser" ? 36 : 32;
const TOOLBAR = FRAME_STYLE === "browser" ? 44 : 0;
const CHROME = FRAME_STYLE === "raw" ? 0 : TABSTRIP + TOOLBAR;
const MOTION = C.motion || {};
const PACING = C.pacing || {};
const REDUCED_MOTION = matchMedia("(prefers-reduced-motion: reduce)").matches;
const PUNCH_S = REDUCED_MOTION ? 0 : (MOTION.zoomDuration ?? 0.46);
const DRIFT_MAX = REDUCED_MOTION ? 0 : (MOTION.panDuration ?? 0.58);
const CUR_S = REDUCED_MOTION ? 0 : (MOTION.cursorDuration ?? 0.52);
const AUTO_ZOOM = MOTION.autoZoom ?? 1.7;
const WIDE_SCALE = MOTION.wideScale ?? 0.8;
const CLICK_SAFE = {
left: 140, right: 140, top: 150, bottom: 190,
...(MOTION.clickSafeInset || {}),
};
const REACTION_LAG = REDUCED_MOTION ? 0 : (MOTION.reactionLag ?? 0.025);
const REACTION_FADE = REDUCED_MOTION ? 0.001 : (MOTION.reactionFade ?? 0.04);
const RADIUS = 12;
const RAIL = H - 142; // readable narration above the action/time rail
const STAGE = 0.94; // camera output inset — backdrop margin at any zoom
const ACCENT = "#6ee7ff";
const MONO = "ui-monospace, 'SF Mono', Menlo, monospace";
const SANS = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
const SERIF = "'Tiempos Headline', 'Tiempos Text', 'Iowan Old Style', Georgia, serif";
const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
const easeOutQuart = t => 1 - Math.pow(1 - t, 4);
const easeOutQuint = t => 1 - Math.pow(1 - t, 5);
const easeInOut = t => t < .5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2;
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
// ------------------------------ timeline ------------------------------
function cardText(b) {
const details = b.kind === "intro" ? (C.plan || [])
: b.kind === "explanation" ? (b.points || []).flatMap(p => [p.label, p.text])
: b.kind === "outcome" ? (b.outcomes || [])
: [];
return [b.title, b.sub, ...details].filter(Boolean).join(" ");
}
const wordCount = text => text ? String(text).trim().split(/\s+/).length : 0;
const cardReadSeconds = b => 0.4 + wordCount(cardText(b)) * 60 / (C.readingWpm || 380);
const cardBaseSeconds = b => b.kind === "intro" || b.kind === "outcome" ? 4.5
: b.kind === "explanation" ? 4 : 3.5;
const cardTargetSeconds = b => Math.max(cardBaseSeconds(b), cardReadSeconds(b));
function defaultDur(b) {
const text = b.card ? cardText(b) : (b.narration || b.caption);
const words = text ? text.trim().split(/\s+/).length : 0;
let base = b.card ? 3.5 : 0.7;
if (b.kind === "intro" || b.kind === "outcome") base = 4.5;
if (b.kind === "explanation") base = 4;
if (b.click) base = 1.15;
if (b.after) base = Math.max(base, 1.4); // let the consequence land
if (b.type) base = Math.max(base, 0.6 + b.type.text.length * 0.035);
if (b.card) return cardTargetSeconds(b);
const captionBase = PACING.captionBaseSeconds ?? 0.35;
const secondsPerWord = PACING.captionSecondsPerWord ?? 0.2;
return Math.max(base, words ? captionBase + secondsPerWord * words : base);
}
const beats = C.beats;
beats.forEach(b => { if (b.dur == null) b.dur = defaultDur(b); });
const starts = []; let T = 0;
for (const b of beats) { starts.push(T); T += b.dur; }
const DURATION = T;
const beatAt = t => { let i = beats.length - 1; while (i > 0 && t < starts[i]) i--; return i; };
const firstWindowBeat = beats.findIndex(b => !b.card);
beats.forEach(b => {
if (b.label === undefined) {
if (b.click && b.cursor) b.label = `click(${Math.round(b.cursor.x)}, ${Math.round(b.cursor.y)})`;
else if (b.type) b.label = `type("${b.type.text}")`;
}
});
const stepOf = []; let stepCount = 0;
beats.forEach((b, i) => { if (b.label) stepCount++; stepOf[i] = stepCount; });
// ------------------------------ preflight ------------------------------
const usedFrames = [...new Set(beats.flatMap(b => [b.frame, b.after]).filter(Boolean))];
const reviewedFrames = new Set(C.privacy?.reviewedFrames || []);
const preflightErrors = [];
const preflightWarnings = [];
const unsafeRoute = /@|[?#]|:\/\/|onmicrosoft|(?:tenant|user|object)[_-]?id|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
const opaqueHex = /^#[0-9a-f]{6}$/i;
if (C.schemaVersion !== 1)
preflightErrors.push("composition must use schema version 1");
if (C.frameStyle !== "native" || C.readingWpm !== 380)
preflightErrors.push("composition changed generated typography or frame style");
if (!Number.isFinite(C.durationBudget) || DURATION > C.durationBudget + 0.001)
preflightErrors.push(
`video is ${DURATION.toFixed(1)}s; shorten it to the ${Number(C.durationBudget).toFixed(1)}s budget`,
);
if (!Array.isArray(C.plan) || C.plan.length < 2 || C.plan.length > 5)
preflightErrors.push("plan must contain 2–5 steps");
if (!beats[0]?.card || beats[0]?.kind !== "intro" || beats[0]?.dur < 4)
preflightErrors.push("first beat must be an intro card lasting at least 4 seconds");
if (!beats.at(-1)?.card || beats.at(-1)?.kind !== "outcome" || !beats.at(-1)?.outcomes?.length)
preflightErrors.push("last beat must be an outcome card with achieved outcomes");
for (const frame of usedFrames)
if (!reviewedFrames.has(frame)) preflightErrors.push(`privacy review missing: ${frame}`);
for (const [i, b] of beats.entries()) {
if (b.card && b.dur + 0.001 < cardTargetSeconds(b))
preflightErrors.push(`beat ${i + 1} card is shorter than its structured scan target`);
if (b.kind === "explanation") {
const labels = (b.points || []).map(point => String(point.label || "").toLowerCase());
if (labels.join("|") !== "observed|mistake|correction")
preflightErrors.push(`beat ${i + 1} explanation must be Observed, Mistake, Correction`);
}
}
if (FRAME_STYLE === "browser" && C.authenticity?.allowSyntheticChrome !== true)
preflightErrors.push("synthetic browser chrome requires authenticity.allowSyntheticChrome=true");
for (const frame of usedFrames) {
for (const [i, rect] of (C.redact?.[frame] || []).entries()) {
const mask = C.privacy?.mask || {};
const fill = rect.fill ?? mask.fill ?? "#f2f4f7";
const stroke = rect.stroke ?? mask.stroke ?? "#e2e7ec";
if (!opaqueHex.test(fill))
preflightErrors.push(`${frame} mask ${i + 1} fill must be opaque six-digit hex`);
if (stroke && !opaqueHex.test(stroke))
preflightErrors.push(`${frame} mask ${i + 1} stroke must be opaque six-digit hex`);
}
}
for (let i = 0; i < beats.length; i++) {
const b = beats[i];
const text = b.narration || b.caption;
const wc = text ? String(text).trim().split(/\s+/).length : 0;
if (wc > 7) preflightErrors.push(`beat ${i + 1} narration exceeds 7 words`);
if (!b.card && (!Number.isInteger(b.chapter) || b.chapter < 0 || b.chapter >= (C.plan || []).length))
preflightErrors.push(`beat ${i + 1} has no valid chapter`);
if (!b.card && !b.route) preflightErrors.push(`beat ${i + 1} has no semantic route`);
if (b.url) preflightErrors.push(`beat ${i + 1} uses a raw url; use route`);
if (unsafeRoute.test(String(b.route || "")) || unsafeRoute.test(String(b.afterRoute || "")))
preflightErrors.push(`beat ${i + 1} route contains sensitive/raw URL material`);
}
window.videoPreflight = () => ({
errors: [...preflightErrors], warnings: [...preflightWarnings], frames: [...usedFrames],
});
// ------------------------------ assets ------------------------------
const images = new Map();
let ready = false;
const srcs = new Set();
beats.forEach(b => { if (b.frame) srcs.add(b.frame); if (b.after) srcs.add(b.after); });
Promise.all([...srcs].map(src => new Promise(res => {
const im = new Image();
im.onload = () => { images.set(src, im); res(); };
im.onerror = () => res();
im.src = src;
}))).then(() => { ready = true; draw(); });
window.videoReady = () => ready;
// browser-use logo as Path2D (1000×1000 viewBox), injected at build time.
const LOGO_D = "M585.941 27.9374C672.468 -1.01696 755.454 -8.30297 825.373 10.0229L826.395 10.2948L827 10.4392L827.641 10.6284C847.195 15.931 865.182 23.1177 881.555 32.0311L882.227 32.3732L882.764 32.6944C906.114 45.592 926.136 62.0317 942.682 81.5504L943.373 82.306L944.245 83.4133C946.291 85.8804 948.277 88.3966 950.214 90.9591L950.305 91.0486L950.355 91.109L950.764 91.6906C955.445 97.9479 959.8 104.485 963.827 111.286L964.182 111.755H964.105C965.309 113.795 966.482 115.86 967.623 117.947L967.736 118.16C967.754 118.194 967.773 118.228 967.791 118.261L968.136 118.852C968.418 119.369 968.673 119.888 968.95 120.408L969.173 120.816C970.409 123.152 971.6 125.517 972.764 127.909L973.191 128.769L973.268 128.959C974.382 131.276 975.459 133.619 976.5 135.988L976.668 136.32L976.841 136.761C979.995 143.992 982.832 151.46 985.345 159.154C985.564 159.824 985.782 160.496 985.995 161.169L986.032 161.204L986.064 161.32L986.186 161.773C988.514 169.161 990.55 176.753 992.291 184.537L992.386 184.823L992.404 184.879L993.014 187.773L993.091 188.222C993.645 190.862 994.164 193.524 994.65 196.208L994.745 196.627H994.841V197.246C996.318 205.56 997.473 214.078 998.3 222.786L998.309 222.849C999.132 231.502 999.627 240.388 999.8 249.407L999.818 249.586L999.823 249.643L999.864 250.083L999.823 250.618C999.882 254.587 999.886 258.592 999.823 262.628V263.4L999.818 263.827C999.645 272.954 999.132 282.249 998.314 291.701V291.914C997.127 305.459 995.259 319.327 992.723 333.483L992.414 335.331L992 337.438C986.227 368.283 977.241 400.477 964.936 433.68L964.459 435.07L964.132 436.006L963.7 435.256C963.627 435.192 963.577 435.106 963.555 435.011L954.727 419.751L945.673 404.357L936.286 389.309L936.041 388.924L926.414 374.112L926.35 374.017L916.232 358.915L910.195 350.317L905.823 343.979L905.691 343.8L894.95 329.184L888.609 320.729L883.923 314.384L883.809 314.228L883.841 314.052C883.841 314.047 883.841 314.057 883.841 314.052L885.195 306.069V305.778L886.255 298.195L887.091 291.914L887.168 291.374L887.209 291.159L887.314 290.632L888.064 283.111L888.518 275.73L888.968 268.646L889.118 261.552L889.123 261.489V261.107L889.273 254.779L889.132 250.083L889.123 249.675V248.142L888.818 241.65L888.4 236.643L888.364 236.23V235.466L887.764 229.309L887.014 223.577L886.109 217.852L885.327 213.031L885.205 212.282L884.609 210.191L883.995 206.979L882.795 202.035L882.777 201.975L881.536 197.835H881.423V197.452L881.418 197.382V197.148L879.932 192.541L878.423 188.011L877.227 185.173L876.768 183.805L874.968 179.752L873.186 175.897L873.159 175.839L873.118 175.758L871.205 172.075L869.25 168.471L867.15 165.018L864.891 161.703L862.636 158.547L857.964 152.519L852.845 147.102L849.091 143.651L845.195 140.201L840.977 136.887L837.068 134.192L836.623 133.883L833.164 131.651L831.954 130.872L829.104 129.372L826.977 128.159L821.718 125.455L816.164 123.053L810.286 120.792L805.627 119.44L803.977 118.838L797.332 117.026L790.409 115.371L783.032 113.866L782.509 113.783L775.341 112.659L767.2 111.754L758.623 111.151L749.75 110.851H740.432L730.868 110.999L720.918 111.754L716.018 112.187L710.65 112.662L700.255 113.865L689.391 115.524L678.373 117.639L667.059 120.052L667.009 120.063L655.518 122.749L655.441 122.768L643.673 125.936L631.759 129.402L631.318 129.543L619.541 133.325L607.309 137.705L597.955 141.325L595.055 142.429L594.786 142.533L582.264 147.66L569.604 153.085L556.75 158.983L544.077 165.244L543.936 165.318L543.841 165.368L530.95 171.955L517.964 178.904L504.982 186.149L492.005 193.843L482.95 199.428L478.864 201.849L475.436 204.039L465.891 210.146L463.341 211.946L453.068 218.742L452.82 218.917L444.462 224.784L440.077 227.805L427.248 237.012L415.051 246.158L414.57 246.52L408.533 251.197L401.89 256.332L392.468 263.977L391.527 264.74L391.477 264.782L389.356 266.449L376.99 276.851L364.764 287.567L352.744 298.537L352.687 298.59L340.755 309.613L329.135 321.082L328.985 321.229L317.509 332.708L306.345 344.471L300.758 350.514L295.279 356.287L295.168 356.406L284.456 368.622L273.9 380.837L273.781 381.016L263.471 393.383L261.663 395.642L253.357 405.912L249.588 410.884L244.251 417.862L243.698 418.588L234.18
const LOGO = LOGO_D.startsWith("M") ? new Path2D(LOGO_D) : null;
function drawLogo(x, y, size, color, alpha = 1) {
if (!LOGO) return;
ctx.save();
ctx.globalAlpha = alpha; ctx.fillStyle = color;
ctx.translate(x, y); ctx.scale(size / 1000, size / 1000);
ctx.fill(LOGO);
ctx.restore();
}
// ------------------------------ layout ------------------------------
function frameRect() {
const availW = W - PAD * 2, availH = H - PAD * 2;
const s = Math.min(availW / VW(), (availH - CHROME) / VH());
const w = VW() * s, h = VH() * s + CHROME;
return { x: (W - w) / 2, y: (H - h) / 2 - 14, w, h, s }; // lifted: bottom rail breathes
}
const FR = frameRect();
const pageX = x => FR.x + x * FR.s;
const pageY = y => FR.y + CHROME + y * FR.s;
// ------------------------------ cursor ------------------------------
const curTargets = [];
{
let last = C.cursorStart || { x: VW() * 0.55, y: VH() * 0.25 };
for (const b of beats) {
if (b.cursor) last = b.cursor;
else if (b.type) last = { x: b.type.box.x + b.type.box.w * 0.3, y: b.type.box.y + b.type.box.h / 2 };
curTargets.push(last);
}
}
const cursorActive = i => !!(beats[i].cursor || beats[i].click || beats[i].type);
const glideDelay = i => REDUCED_MOTION ? 0 : (beats[i].cursorDelay ?? 0.06);
const glideDuration = i => Math.min(CUR_S, beats[i].dur * 0.42);
const clickTime = i => glideDelay(i) + glideDuration(i) + (REDUCED_MOTION ? 0 : 0.035);
function cursorMotion(i, lt) {
const cur = curTargets[i];
if (i <= 0) return { ...cur, arrive: 0 };
const prev = curTargets[i - 1];
const glide = glideDuration(i);
const k = glide ? easeOutQuint(clamp((lt - glideDelay(i)) / glide, 0, 1)) : 1;
return {
x: lerp(prev.x, cur.x, k),
y: lerp(prev.y, cur.y, k),
arrive: glideDelay(i) + glide,
};
}
function cursor(t) {
const i = beatAt(t);
const lt = t - starts[i];
const motion = cursorMotion(i, lt);
// fade out on idle beats, fade in when working again
let alpha;
if (cursorActive(i)) alpha = (i > 0 && !cursorActive(i - 1)) ? easeOutCubic(clamp(lt / 0.25, 0, 1)) : 1;
else alpha = (i > 0 && cursorActive(i - 1)) ? 1 - easeInOut(clamp(lt / 0.35, 0, 1)) : 0;
return { ...motion, alpha };
}
// ------------------------------ camera ------------------------------
const WIDE = { s: WIDE_SCALE, x: W / 2, y: H / 2, mode: "wide" };
function framedTarget(point, scale, mode = "fixed") {
let px = pageX(point.x), py = pageY(point.y);
const vw2 = W / (2 * scale), vh2 = H / (2 * scale);
px = clamp(px, FR.x + Math.min(vw2, FR.w / 2), FR.x + FR.w - Math.min(vw2, FR.w / 2));
py = clamp(py, FR.y + Math.min(vh2, FR.h / 2), FR.y + FR.h - Math.min(vh2, FR.h / 2));
if (py - vh2 - FR.y < CHROME + 190 * FR.s) py = FR.y + Math.min(vh2, FR.h / 2);
if (px - vw2 - FR.x < 56 * FR.s) px = FR.x + Math.min(vw2, FR.w / 2);
return { s: scale, x: px, y: py, mode };
}
function projectedPoint(point, cam) {
return {
x: W / 2 + STAGE * ((cam.shake || 0) + cam.s * (pageX(point.x) - cam.x)),
y: H / 2 + STAGE * cam.s * (pageY(point.y) - cam.y),
};
}
function clickPointIsSafe(point, cam) {
const p = projectedPoint(point, cam);
return p.x >= CLICK_SAFE.left && p.x <= W - CLICK_SAFE.right &&
p.y >= CLICK_SAFE.top && p.y <= H - CLICK_SAFE.bottom;
}
function nudgePointIntoSafeArea(cam, point) {
const p = projectedPoint(point, cam);
const safeX = clamp(p.x, CLICK_SAFE.left, W - CLICK_SAFE.right);
const safeY = clamp(p.y, CLICK_SAFE.top, H - CLICK_SAFE.bottom);
return {
...cam,
x: cam.x + (p.x - safeX) / (STAGE * cam.s),
y: cam.y + (p.y - safeY) / (STAGE * cam.s),
};
}
function clickSafeTarget(focus, point, scale, mode = "fixed") {
const requested = framedTarget(focus, scale, mode);
if (clickPointIsSafe(point, requested)) return requested;
// Edge actions should pull back before they push the click under captions,
// telemetry, or the video edge. Keep the largest zoom that remains safe.
let low = Math.min(scale, WIDE_SCALE), high = scale;
let best = framedTarget(focus, low, mode);
if (!clickPointIsSafe(point, best)) return nudgePointIntoSafeArea(best, point);
for (let n = 0; n < 14; n++) {
const mid = (low + high) / 2;
const candidate = framedTarget(focus, mid, mode);
if (clickPointIsSafe(point, candidate)) { best = candidate; low = mid; }
else high = mid;
}
return best;
}
const camTargets = [];
for (let i = 0; i < beats.length; i++) {
const b = beats[i];
if (b.card || b.wide) { camTargets.push(WIDE); continue; }
if (b.zoom) {
const scale = b.zoom.scale || AUTO_ZOOM;
camTargets.push(b.click && b.cursor
? clickSafeTarget(b.zoom, b.cursor, scale)
: framedTarget(b.zoom, scale));
continue;
}
if (MOTION.autoFollow !== false && cursorActive(i) && !b.noFollow) {
const scale = b.followScale || AUTO_ZOOM;
camTargets.push(b.click
? clickSafeTarget(curTargets[i], curTargets[i], scale, "follow")
: framedTarget(curTargets[i], scale, "follow"));
continue;
}
if (i === 0 || beats[i - 1].card) camTargets.push(WIDE);
else camTargets.push({ ...camTargets[i - 1], mode: "fixed" });
}
function camera(t) {
const i = beatAt(t), b = beats[i], lt = t - starts[i];
const final = camTargets[i];
const desired = final.mode === "follow"
? (b.click
? clickSafeTarget(cursorMotion(i, lt), cursorMotion(i, lt), final.s, "follow")
: framedTarget(cursorMotion(i, lt), final.s, "follow"))
: final;
let cam;
if (i === 0 || b.card || b.cameraCut) cam = { ...desired };
else {
const prev = beats[i - 1].card ? WIDE : camTargets[i - 1];
const zooming = Math.abs(prev.s - desired.s) > 0.01;
const dur = zooming ? PUNCH_S : Math.min(DRIFT_MAX, b.dur * 0.65);
const k = dur ? easeOutQuint(clamp(lt / dur, 0, 1)) : 1;
cam = {
s: lerp(prev.s, desired.s, k),
x: lerp(prev.x, desired.x, k),
y: lerp(prev.y, desired.y, k),
};
}
if (b.errorMotion && !REDUCED_MOTION) {
const damp = 1 - clamp(lt / 0.42, 0, 1);
cam.shake = Math.sin(lt * 42) * 5 * damp;
} else cam.shake = 0;
return cam;
}
const clickVisibility = beats.flatMap((b, i) => {
if (!b.click || !b.cursor) return [];
const t = starts[i] + Math.min(clickTime(i), Math.max(0, b.dur - 0.001));
const cam = camera(t);
const point = projectedPoint(b.cursor, cam);
const visible = clickPointIsSafe(b.cursor, cam);
if (!visible) preflightErrors.push(`beat ${i + 1} click falls outside the safe viewport`);
const resultTime = b.after
? starts[i] + Math.min(clickTime(i) + REACTION_LAG + REACTION_FADE, b.dur)
: t;
return [{
beat: i + 1, label: b.label, time: t, resultTime,
x: point.x, y: point.y, scale: cam.s, visible,
}];
});
window.clickVisibility = () => clickVisibility.map(item => ({ ...item }));
// ------------------------------ drawing ------------------------------
const canvas = document.getElementById("stage");
const ctx = canvas.getContext("2d");
const BG = Array.isArray(C.bg) ? C.bg : [C.bg || "#F0EEE6"];
const LIGHT_BG = (() => { // perceived luminance of the backdrop
const c = h => parseInt(h, 16);
const lum = s => { const h = s.replace("#", "");
return 0.299 * c(h.slice(0, 2)) + 0.587 * c(h.slice(2, 4)) + 0.114 * c(h.slice(4, 6)); };
return BG.reduce((a, s) => a + lum(s), 0) / BG.length > 128;
})();
const INK = LIGHT_BG ? "#1f1e1b" : "#ffffff"; // text/logo on the backdrop
function drawBackdrop() {
if (BG.length < 2) { ctx.fillStyle = BG[0]; ctx.fillRect(0, 0, W, H); return; }
const g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, BG[0]); g.addColorStop(1, BG[1]);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
// --- realistic Chrome window (macOS, light theme) ---
function afterIsVisible(i, lt) {
const b = beats[i];
if (!b.after) return false;
const at = b.click ? clickTime(i) + REACTION_LAG : Math.min(0.22, b.dur * 0.22);
return lt >= at + REACTION_FADE;
}
function drawChromeWindow(i, lt) {
ctx.save();
ctx.shadowColor = LIGHT_BG ? "rgba(40,35,25,0.3)" : "rgba(0,0,0,0.6)";
ctx.shadowBlur = LIGHT_BG ? 54 : 70; ctx.shadowOffsetY = LIGHT_BG ? 18 : 28;
roundRect(FR.x, FR.y, FR.w, FR.h, RADIUS);
ctx.fillStyle = FRAME_STYLE === "browser" ? "#DEE1E6" : "#EAECF0"; ctx.fill();
ctx.restore();
if (FRAME_STYLE === "raw") return;
const tcy = FR.y + TABSTRIP / 2;
[["#FF5F57"], ["#FEBC2E"], ["#28C840"]].forEach(([c], k) => {
ctx.beginPath(); ctx.arc(FR.x + 22 + k * 20, tcy, 6, 0, 7); ctx.fillStyle = c; ctx.fill();
});
// Native framing keeps the recording honest: no reconstructed tab title or
// fake URL masquerading as captured browser evidence.
if (FRAME_STYLE !== "browser") return;
const tabX = FR.x + 84, tabW = Math.min(240, FR.w * 0.3), tabH = TABSTRIP - 8;
ctx.beginPath();
ctx.moveTo(tabX - 8, FR.y + TABSTRIP);
ctx.quadraticCurveTo(tabX, FR.y + TABSTRIP, tabX, FR.y + TABSTRIP - 8);
ctx.lineTo(tabX, FR.y + 12);
ctx.quadraticCurveTo(tabX, FR.y + 4, tabX + 8, FR.y + 4);
ctx.lineTo(tabX + tabW - 8, FR.y + 4);
ctx.quadraticCurveTo(tabX + tabW, FR.y + 4, tabX + tabW, FR.y + 12);
ctx.lineTo(tabX + tabW, FR.y + TABSTRIP - 8);
ctx.quadraticCurveTo(tabX + tabW, FR.y + TABSTRIP, tabX + tabW + 8, FR.y + TABSTRIP);
ctx.closePath();
ctx.fillStyle = "#fff"; ctx.fill();
const after = afterIsVisible(i, lt);
const route = (after && beats[i].afterRoute) || sticky(i, "route") || sticky(i, "url") || "Browser";
const tab = (after && beats[i].afterTab) || sticky(i, "tab") || route.split("/").pop().trim() || "Browser";
ctx.beginPath(); ctx.arc(tabX + 18, FR.y + 4 + tabH / 2, 6.5, 0, 7);
ctx.fillStyle = "#F19E38"; ctx.fill();
ctx.fillStyle = "#3C4043"; ctx.font = `500 13px ${SANS}`;
ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillText(fitText(tab, tabW - 64), tabX + 32, FR.y + 5 + tabH / 2);
ctx.fillStyle = "#5F6368"; ctx.font = `400 14px ${SANS}`;
ctx.fillText("×", tabX + tabW - 20, FR.y + 5 + tabH / 2);
ctx.font = `400 18px ${SANS}`;
ctx.fillText("+", tabX + tabW + 18, FR.y + 5 + tabH / 2);
ctx.fillStyle = "#fff";
ctx.fillRect(FR.x, FR.y + TABSTRIP, FR.w, TOOLBAR);
const ty = FR.y + TABSTRIP + TOOLBAR / 2;
arrow(FR.x + 26, ty, -1, "#5F6368");
arrow(FR.x + 58, ty, 1, "#C0C3C7");
reloadIcon(FR.x + 90, ty);
const ox = FR.x + 116, ow = FR.w - 116 - 118, oh = 30;
roundRect(ox, ty - oh / 2, ow, oh, oh / 2);
ctx.fillStyle = "#F1F3F4"; ctx.fill();
lockIcon(ox + 18, ty);
ctx.fillStyle = "#202124"; ctx.font = `400 14.5px ${SANS}`;
ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillText(fitText(route, ow - 70), ox + 34, ty + 1);
starIcon(ox + ow - 20, ty);
ctx.beginPath(); ctx.arc(FR.x + FR.w - 62, ty, 12, 0, 7);
ctx.fillStyle = "#7C4DFF"; ctx.fill();
ctx.fillStyle = "#fff"; ctx.font = `600 12px ${SANS}`; ctx.textAlign = "center";
ctx.fillText("G", FR.x + FR.w - 62, ty + 1);
ctx.fillStyle = "#5F6368";
for (let k = -1; k <= 1; k++) { ctx.beginPath(); ctx.arc(FR.x + FR.w - 28, ty + k * 5, 1.8, 0, 7); ctx.fill(); }
}
function arrow(x, y, dir, color) {
ctx.strokeStyle = color; ctx.lineWidth = 1.8; ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x + 6 * dir, y - 5); ctx.lineTo(x - 3 * dir, y); ctx.lineTo(x + 6 * dir, y + 5);
ctx.moveTo(x - 3 * dir, y); ctx.lineTo(x + 8 * dir, y);
ctx.stroke();
}
function reloadIcon(x, y) {
ctx.strokeStyle = "#5F6368"; ctx.lineWidth = 1.8;
ctx.beginPath(); ctx.arc(x, y, 7, -0.4, 5.0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(x + 7, y - 5); ctx.lineTo(x + 7, y - 1); ctx.lineTo(x + 3, y - 2);
ctx.fillStyle = "#5F6368"; ctx.fill();
}
function lockIcon(x, y) {
ctx.strokeStyle = "#5F6368"; ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(x, y - 3, 3.5, Math.PI, 0); ctx.stroke();
ctx.fillStyle = "#5F6368";
roundRect(x - 5.5, y - 3, 11, 8, 2); ctx.fill();
}
function starIcon(x, y) {
ctx.strokeStyle = "#9AA0A6"; ctx.lineWidth = 1.4;
ctx.beginPath();
for (let k = 0; k < 10; k++) {
const r = k % 2 ? 3.2 : 7, a = -Math.PI / 2 + k * Math.PI / 5;
ctx[k ? "lineTo" : "moveTo"](x + r * Math.cos(a), y + r * Math.sin(a));
}
ctx.closePath(); ctx.stroke();
}
function fitText(s, maxW) {
if (ctx.measureText(s).width <= maxW) return s;
while (s.length > 1 && ctx.measureText(s + "…").width > maxW) s = s.slice(0, -1);
return s + "…";
}
function sticky(i, key) {
for (let k = i; k >= 0; k--)
if (Object.prototype.hasOwnProperty.call(beats[k], key) && beats[k][key] != null && beats[k][key] !== "")
return beats[k][key];
return null;
}
// Frame shown at the END of beat i (its consequence, if any)
const finalFrame = i => beats[i].after || beats[i].frame;
function fadeDur(i) {
const b = beats[i];
return b.fade != null ? b.fade : 0; // hard cut unless explicitly asked
}
function drawContent(t, i, lt) {
ctx.save();
roundRect(FR.x, FR.y + CHROME, FR.w, FR.h - CHROME, 2);
ctx.clip();
const b = beats[i];
// entry transition from the previous beat's final frame
const prevSrc = i > 0 && !beats[i - 1].card ? finalFrame(i - 1) : null;
const fd = fadeDur(i);
let base = images.get(b.frame);
const shown = [];
if (prevSrc && fd > 0 && lt < fd && prevSrc !== b.frame) {
drawPage(images.get(prevSrc));
shown.push(prevSrc);
ctx.globalAlpha = clamp(lt / fd, 0, 1);
}
if (base) { drawPage(base); shown.push(b.frame); }
ctx.globalAlpha = 1;
// in-beat consequence: swap to `after` once the click lands
if (b.after) {
const at = b.click ? clickTime(i) + REACTION_LAG : Math.min(0.22, b.dur * 0.22);
const k = clamp((lt - at) / REACTION_FADE, 0, 1);
if (k > 0) {
const im = images.get(b.after);
ctx.globalAlpha = k; drawPage(im); ctx.globalAlpha = 1;
if (im) { if (k >= 1) shown.length = 0; shown.push(b.after); }
}
}
if (C.redact)
for (const src of shown)
for (const r of C.redact[src] || []) drawRedact(r);
if (b.type) drawTyping(b, lt);
ctx.restore();
}
function drawPage(im) { if (im) ctx.drawImage(im, FR.x, FR.y + CHROME, FR.w, VH() * FR.s); }
// Opaque masks may match their surrounding surface, but never use alpha: unlike
// blur or pixelation, secrets cannot be reconstructed from them.
function drawRedact(r) {
const pad = r.pad ?? C.privacy?.pad ?? 8;
const mask = C.privacy?.mask || {};
const x = pageX(r.x - pad), y = pageY(r.y - pad);
const w = (r.w + pad * 2) * FR.s, h = (r.h + pad * 2) * FR.s;
if (w < 2 || h < 2) return;
const fill = r.fill ?? mask.fill ?? "#f2f4f7";
const stroke = r.stroke ?? mask.stroke ?? "#e2e7ec";
const radius = Math.min(r.radius ?? mask.radius ?? 7, w / FR.s / 2, h / FR.s / 2) * FR.s;
ctx.save();
ctx.globalAlpha = 1;
roundRect(x, y, w, h, radius);
ctx.fillStyle = fill; ctx.fill();
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = Math.max(1, FR.s);
ctx.stroke();
}
ctx.restore();
}
function drawTyping(b, lt) {
const { box, text } = b.type;
const visibleText = b.type.redact ? "••••••" : text;
const i = beats.indexOf(b);
const mag = camTargets[i].s < 1.25 ? 1.7 : 1; // synthetic pill: enlarge when wide
let x = pageX(box.x), y = pageY(box.y), w = box.w * FR.s * mag, h = box.h * FR.s * mag;
x -= (mag - 1) * box.w * FR.s * 0.15; y -= (mag - 1) * box.h * FR.s * 0.5;
const t0 = 0.35, t1 = Math.max(t0 + 0.25, b.dur - 0.3);
const chars = Math.round(clamp((lt - t0) / (t1 - t0), 0, 1) * visibleText.length);
ctx.save();
roundRect(x, y, w, h, Math.min(8, h / 3));
ctx.fillStyle = "rgba(255,255,255,0.98)"; ctx.fill();
ctx.strokeStyle = ACCENT; ctx.lineWidth = 1.6; ctx.stroke();
ctx.fillStyle = "#111"; ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.font = `500 ${Math.max(13, h * 0.46)}px ${SANS}`;
const shown = visibleText.slice(0, chars);
ctx.fillText(shown, x + h * 0.32, y + h / 2 + 1);
if (lt >= t0 && (lt % 0.9) < 0.5) {
const tw = ctx.measureText(shown).width;
ctx.fillStyle = "#333";
ctx.fillRect(x + h * 0.32 + tw + 2, y + h * 0.22, 1.6, h * 0.56);
}
ctx.restore();
}
function drawCursor(t, i, lt, cam) {
const b = beats[i];
if (b.card || b.hideCursor) return;
const c = cursor(t);
if (c.alpha <= 0.01) return;
const x = pageX(c.x), y = pageY(c.y);
const clickAt = clickTime(i);
const pressed = b.click && lt >= clickAt && lt < clickAt + 0.18;
const inv = 1 / Math.max(0.01, cam.s * STAGE);
const cursorPx = cam.s < 1 ? 52 : 38;
ctx.save();
ctx.globalAlpha = c.alpha;
if (b.click && lt >= clickAt) {
const rt = clamp((lt - clickAt) / 0.5, 0, 1);
ctx.strokeStyle = `rgba(110,231,255,${0.5 * (1 - rt)})`; ctx.lineWidth = inv;
ctx.beginPath();
ctx.moveTo(x - 34 * inv, y); ctx.lineTo(x - 12 * inv, y); ctx.moveTo(x + 12 * inv, y); ctx.lineTo(x + 34 * inv, y);
ctx.moveTo(x, y - 34 * inv); ctx.lineTo(x, y - 12 * inv); ctx.moveTo(x, y + 12 * inv); ctx.lineTo(x, y + 34 * inv);
ctx.stroke();
ctx.beginPath(); ctx.arc(x, y, (7 + easeOutCubic(rt) * 34) * inv, 0, 7);
ctx.strokeStyle = `rgba(255,255,255,${0.8 * (1 - rt)})`;
ctx.lineWidth = (3 * (1 - rt) + 1) * inv; ctx.stroke();
}
const k = (cursorPx / 18.1) * inv * (pressed ? 0.85 : 1);
ctx.translate(x, y); ctx.scale(k, k);
ctx.shadowColor = "rgba(0,0,0,0.45)"; ctx.shadowBlur = 5; ctx.shadowOffsetY = 2;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, 16.5); ctx.lineTo(3.9, 12.8); ctx.lineTo(6.3, 18.1);
ctx.lineTo(9.3, 16.8); ctx.lineTo(6.9, 11.6); ctx.lineTo(12.2, 11.4); ctx.closePath();
ctx.fillStyle = "#fff"; ctx.fill();
ctx.shadowColor = "transparent";
ctx.strokeStyle = "#111"; ctx.lineWidth = 1.1; ctx.stroke();
ctx.restore();
}
// --- telemetry (screen space) ---
function chip(x, y, parts, align = "left", error = false) {
// parts: [[text, color], ...] rendered in one pill
// One palette: ink on paper (light bg) / paper on ink (dark bg). `parts`
// entries are [text, muted?] — no accent colors.
ctx.font = `600 21px ${MONO}`;
const texts = parts.map(p => p[0]);
const logoPad = parts.logo ? 30 : 0;
const w = texts.reduce((a, s) => a + ctx.measureText(s).width, 0) + 30 + (parts.length - 1) * 12 + logoPad;
const bx = align === "right" ? x - w : x;
roundRect(bx, y, w, 48, 24);
if (error) {
ctx.fillStyle = "rgba(160,40,40,0.95)"; ctx.fill();
} else if (LIGHT_BG) {
ctx.fillStyle = "rgba(255,253,247,0.9)"; ctx.fill();
ctx.strokeStyle = "rgba(31,30,27,0.22)"; ctx.lineWidth = 1; ctx.stroke();
} else {
ctx.fillStyle = "rgba(9,12,20,0.72)"; ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.13)"; ctx.lineWidth = 1; ctx.stroke();
}
const ink = error ? "#fff" : (LIGHT_BG ? "#1f1e1b" : "#f4f2ec");
ctx.textAlign = "left"; ctx.textBaseline = "middle";
let cx = bx + 15;
if (parts.logo) { drawLogo(cx - 2, y + 14, 20, ink, 0.9); cx += logoPad; }
for (const [text, muted] of parts) {
ctx.globalAlpha = muted ? 0.52 : 1;
ctx.fillStyle = ink;
ctx.fillText(text, cx, y + 25);
ctx.globalAlpha = 1;
cx += ctx.measureText(text).width + 12;
}
}
function drawPlan(i) {
const chapter = sticky(i, "chapter") ?? 0;
const detour = !!beats[i].detour;
const gap = 10, h = 58, maxW = 420;
ctx.font = `600 19px ${SANS}`;
const widths = C.plan.map((step, n) => Math.min(maxW, Math.max(240, ctx.measureText(`${n + 1}. ${step}`).width + 62)));
const total = widths.reduce((sum, width) => sum + width, 0) + gap * (widths.length - 1);
let x = (W - total) / 2;
for (let n = 0; n < C.plan.length; n++) {
const active = n === chapter;
roundRect(x, 26, widths[n], h, 18);
ctx.fillStyle = active ? "rgba(10,12,18,0.93)" : "rgba(10,12,18,0.58)"; ctx.fill();
ctx.strokeStyle = active ? "rgba(110,231,255,0.7)" : "rgba(255,255,255,0.12)";
ctx.lineWidth = active ? 2 : 1; ctx.stroke();
ctx.fillStyle = active ? "#fff" : "rgba(255,255,255,0.68)";
ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillText(`${n + 1}. ${C.plan[n]}`, x + 24, 56);
x += widths[n] + gap;
}
if (detour) {
ctx.font = `700 15px ${MONO}`;
const label = "DETOUR";
const w = ctx.measureText(label).width + 30;
roundRect(W / 2 - w / 2, 92, w, 32, 16);
ctx.fillStyle = "rgba(128,63,22,0.94)"; ctx.fill();
ctx.fillStyle = "#fff"; ctx.textAlign = "center";
ctx.fillText(label, W / 2, 108);
}
}
function drawTelemetry(t, i, lt) {
const b = beats[i];
if (b.card) return;
drawPlan(i);
const cy = H - 74;
if (b.label || b.error) {
const parts = [];
if (b.label) {
parts.push([`${String(stepOf[i]).padStart(2, "0")}/${String(stepCount).padStart(2, "0")}`, true]);
parts.push([b.label, false]);
}
if (b.error) parts.push(["⚠ ERROR", false]);
chip(28, cy, parts, "left", b.error);
}
const bt = sticky(i, "t");
if (bt != null) {
let next = null;
for (let k = i + 1; k < beats.length; k++) if (beats[k].t != null) { next = beats[k].t; break; }
const cur = beats[i].t != null ? beats[i].t : bt;
const shown = next != null ? lerp(cur, next, clamp(lt / b.dur, 0, 1)) : cur;
const m = Math.floor(shown / 60), s = Math.floor(shown % 60);
const parts = [[`${m}:${String(s).padStart(2, "0")}`, false]];
parts.logo = true;
chip(W - 28, cy, parts, "right");
}
ctx.save();
const px0 = 28, pw = W - 56, py = H - 12;
ctx.fillStyle = LIGHT_BG ? "rgba(31,30,27,0.12)" : "rgba(255,255,255,0.1)";
ctx.fillRect(px0, py, pw, 2);
ctx.fillStyle = LIGHT_BG ? "rgba(31,30,27,0.5)" : "rgba(255,255,255,0.55)";
ctx.fillRect(px0, py, pw * (t / DURATION), 2);
ctx.restore();
}
function narrationState(i) {
for (let k = i; k >= 0; k--) {
if (beats[k].card) return null;
if (Object.prototype.hasOwnProperty.call(beats[k], "narration")) {
if (!beats[k].narration) return null;
return {
text: String(beats[k].narration),
index: k,
error: beats[k].narrationError ?? beats[k].error ?? false,
};
}
}
return null;
}
function captionState(i) {
const b = beats[i];
if (b.card) return null;
if (b.caption) return { text: b.caption, index: i, error: !!b.error };
return narrationState(i);
}
function drawCaptionPill(state, alpha, rise = 0) {
if (!state || alpha <= 0) return;
ctx.save();
ctx.globalAlpha = alpha;
let fs = 56;
ctx.font = `600 ${fs}px ${SANS}`;
while (ctx.measureText(state.text).width > W * 0.64 && fs > 44) {
fs -= 2; ctx.font = `600 ${fs}px ${SANS}`;
}
ctx.textAlign = "center"; ctx.textBaseline = "middle";
const ph = fs * 1.9;
const y = RAIL + rise;
const w = ctx.measureText(state.text).width;
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.4)"; ctx.shadowBlur = 24; ctx.shadowOffsetY = 6;
roundRect(W / 2 - w / 2 - 26, y - ph / 2, w + 52, ph, ph / 2);
ctx.fillStyle = state.error ? "rgba(52,10,14,0.9)" : "rgba(10,12,18,0.82)"; ctx.fill();
ctx.restore();
ctx.strokeStyle = state.error ? "rgba(255,90,90,0.55)" : "rgba(255,255,255,0.14)"; ctx.lineWidth = 1;
roundRect(W / 2 - w / 2 - 26, y - ph / 2, w + 52, ph, ph / 2); ctx.stroke();
ctx.fillStyle = state.error ? "#ffd7d7" : "#fff";
ctx.fillText(state.text, W / 2, y + 1);
ctx.restore();
}
function drawCaption(t, i) {
const state = captionState(i);
if (!state) return;
const elapsed = t - starts[state.index];
const k = REDUCED_MOTION ? 1 : easeOutQuart(clamp((elapsed - 0.04) / 0.22, 0, 1));
if (state.index === i && i > 0 && k < 1) {
const previous = captionState(i - 1);
if (previous && previous.text !== state.text)
drawCaptionPill(previous, 1 - k, -8 * k);
}
drawCaptionPill(state, k, 12 * (1 - k));
}
function drawCard(i, lt) {
const b = beats[i];
const last = i === beats.length - 1;
const aIn = easeOutCubic(clamp(lt / 0.35, 0, 1));
const aOut = last ? 1 : 1 - easeInOut(clamp((lt - (b.dur - 0.3)) / 0.3, 0, 1));
const a = Math.min(aIn, aOut);
ctx.save();
ctx.globalAlpha = a;
// Center each card's complete visual stack, not just its title. The intro is
// shorter than explanation/outcome cards, so it needs the larger offset.
const centerShift = b.kind === "intro" ? 154
: b.kind === "explanation" ? 106
: b.kind === "outcome" ? 116
: 0;
const rise = centerShift + (1 - aIn) * 16;
drawLogo(W / 2 - 40, 130 + rise, 80, INK, 1);
ctx.fillStyle = INK; ctx.textAlign = "center"; ctx.textBaseline = "middle";
let titleSize = 72;
ctx.font = `600 ${titleSize}px ${SANS}`;
while (ctx.measureText(b.title || "").width > W * 0.8 && titleSize > 52) {
titleSize -= 2; ctx.font = `600 ${titleSize}px ${SANS}`;
}
ctx.fillText(b.title || "", W / 2, 300 + rise);
if (b.sub) {
ctx.font = `500 25px ${MONO}`;
ctx.globalAlpha = a * 0.6; ctx.fillStyle = INK;
ctx.fillText(b.sub, W / 2, 386 + rise);
ctx.globalAlpha = a;
}
if (b.kind === "intro" && C.plan) {
const gap = 18, w = 430, h = 118;
const total = C.plan.length * w + (C.plan.length - 1) * gap;
let x = (W - total) / 2;
for (let n = 0; n < C.plan.length; n++) {
roundRect(x, 525 + rise, w, h, 20);
ctx.fillStyle = LIGHT_BG ? "rgba(255,255,255,0.62)" : "rgba(255,255,255,0.08)"; ctx.fill();
ctx.strokeStyle = LIGHT_BG ? "rgba(31,30,27,0.18)" : "rgba(255,255,255,0.14)"; ctx.stroke();
ctx.font = `700 20px ${MONO}`; ctx.textAlign = "left"; ctx.fillStyle = INK;
ctx.fillText(String(n + 1).padStart(2, "0"), x + 28, 559 + rise);
ctx.font = `600 26px ${SANS}`; ctx.fillText(C.plan[n], x + 28, 607 + rise);
x += w + gap;
}
}
if (b.kind === "explanation" && b.points) {
const firstReveal = 1.1;
const finalHold = 3.0;
const revealSpan = Math.max(0, b.dur - firstReveal - finalHold);
const revealGap = b.points.length > 1 ? revealSpan / (b.points.length - 1) : 0;
const current = Math.min(
b.points.length - 1,
Math.max(-1, Math.floor((lt - firstReveal) / Math.max(0.001, revealGap))),
);
let y = 505 + rise;
for (let n = 0; n < b.points.length; n++) {
const point = b.points[n];
const revealAt = firstReveal + n * revealGap;
const k = lt < revealAt ? 0 : REDUCED_MOTION ? 1 : easeOutQuart(clamp((lt - revealAt) / 0.28, 0, 1));
if (k <= 0) { y += 106; continue; }
const active = n === current;
const rowAlpha = active ? 1 : 0.56;
const slide = REDUCED_MOTION ? 0 : 14 * (1 - k);
ctx.textAlign = "left";
ctx.font = `700 18px ${MONO}`; ctx.globalAlpha = a * k * rowAlpha; ctx.fillStyle = INK;
const label = `${String(n + 1).padStart(2, "0")} ${String(point.label || "").toUpperCase()}`;
ctx.fillText(label, 360 + slide, y);
ctx.font = `${active ? 650 : 560} 31px ${SANS}`;
ctx.fillText(point.text || "", 590 + slide, y);
y += 106;
}
ctx.globalAlpha = a;
}
if (b.kind === "outcome" && b.outcomes) {
let y = 515 + rise;
for (const outcome of b.outcomes) {
ctx.beginPath(); ctx.arc(520, y, 18, 0, 7); ctx.fillStyle = "#157f63"; ctx.fill();
ctx.fillStyle = "#fff"; ctx.font = `700 19px ${SANS}`; ctx.textAlign = "center"; ctx.fillText("✓", 520, y + 1);
ctx.textAlign = "left"; ctx.fillStyle = INK; ctx.font = `600 32px ${SANS}`;
ctx.fillText(outcome, 570, y);
y += 92;
}
}
ctx.restore();
}
function draw() {
if (!ready) return;
const t = clamp(playhead, 0, DURATION - 0.001);
const i = beatAt(t), lt = t - starts[i];
drawBackdrop();
if (beats[i].card) { drawCard(i, lt); drawHud(t, i); return; }
const cam = camera(t);
ctx.save();
// Stage inset: the camera's output maps to a slightly smaller rect so a
// margin of backdrop stays visible on every side at ANY zoom (Screen Studio
// keeps this breathing room; full-bleed content reads as a raw screencap).
ctx.translate(W / 2, H / 2); ctx.scale(STAGE, STAGE); ctx.translate(-W / 2, -H / 2);
ctx.translate(W / 2 + (cam.shake || 0), H / 2); ctx.scale(cam.s, cam.s); ctx.translate(-cam.x, -cam.y);
// Screen-Studio window entrance after a card
if (i === firstWindowBeat) {
const k = easeOutCubic(clamp(lt / 0.45, 0, 1));
const ws = 0.94 + 0.06 * k;
ctx.translate(W / 2, H / 2); ctx.scale(ws, ws); ctx.translate(-W / 2, -H / 2);
ctx.globalAlpha = 0.25 + 0.75 * k;
}
drawChromeWindow(i, lt);
drawContent(t, i, lt);
drawCursor(t, i, lt, cam);
ctx.restore();
ctx.globalAlpha = 1;
if (beats[i].errorMotion) {
const a = 0.12 * easeOutCubic(clamp(lt / 0.3, 0, 1));
const g = ctx.createRadialGradient(W/2, H/2, H * 0.42, W/2, H/2, H * 0.85);
g.addColorStop(0, "rgba(255,60,60,0)"); g.addColorStop(1, `rgba(255,50,50,${a})`);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
drawTelemetry(t, i, lt);
drawCaption(t, i, lt);
drawHud(t, i);
}
function drawHud(t, i) {
hud.textContent = `${t.toFixed(2)}s / ${DURATION.toFixed(2)}s beat ${i + 1}/${beats.length}` +
(recorder ? " ● EXPORTING" : playing ? " ▶" : " ⏸");
}
// ------------------------------ playback ------------------------------
const hud = document.getElementById("hud");
let playhead = 0, playing = false, rafT0 = 0, recorder = null;
function tick(ts) {
if (playing) {
playhead = (ts - rafT0) / 1000;
if (playhead >= DURATION) {
playhead = DURATION; playing = false;
if (recorder) recorder.stop();
}
}
draw();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
window.seek = t => { playing = false; playhead = clamp(t, 0, DURATION); draw(); };
window.play = (from = playhead >= DURATION ? 0 : playhead) => {
playhead = from; rafT0 = performance.now() - from * 1000; playing = true;
};
window.pause = () => { playing = false; };
window.duration = () => DURATION;
// Realtime export: plays once from 0 while MediaRecorder captures the canvas.
// Keep this tab focused — rAF throttles in background tabs and stalls capture.
window.exportVideo = (filename = "video.webm") => new Promise((resolve, reject) => {
if (preflightErrors.length) {
const message = `Export blocked: ${preflightErrors.join("; ")}`;
window.__exportError = message;
reject(new Error(message));
return;
}
const stream = canvas.captureStream(60);
const mime = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"]
.find(m => MediaRecorder.isTypeSupported(m));
recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 14_000_000 });
const chunks = [];
recorder.ondataavailable = e => e.data.size && chunks.push(e.data);
recorder.onstop = () => {
recorder = null;
const blob = new Blob(chunks, { type: "video/webm" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.download = filename; a.click();
window.__exported = filename;
resolve(filename);
};
recorder.start();
window.play(0);
});
document.addEventListener("keydown", e => {
if (e.key === " ") { e.preventDefault(); playing ? pause() : play(); }
if (e.key === "ArrowRight") seek(playhead + (e.shiftKey ? 1 : 0.1));
if (e.key === "ArrowLeft") seek(playhead - (e.shiftKey ? 1 : 0.1));
if (e.key === "e") exportVideo();
});
</script>
</body>
</html>