feat(playback): ansi n styles

This commit is contained in:
ethernet
2026-08-14 15:58:51 -04:00
parent 4bc6bfede1
commit 4c3ee593c0
+58 -29
View File
@@ -13,8 +13,8 @@
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
#status { position: fixed; inset: 0; display: flex; align-items: center;
justify-content: center; color: var(--dim); font-size: 15px; z-index: 5; }
#status {text-align: center; position: fixed; inset: 0; display: flex-col; align-items: center;
justify-content: center; color: var(--dim); font-size: 15px; z-index: 5; padding: 24px;}
#status.error { color: #f85149; }
#app { display: none; height: 100vh; flex-direction: column; }
@@ -107,7 +107,7 @@
/* Hermes install-e2e leg player.
*
* One static file. Point it at a run's artifact zip:
* playback.html?zip=<artifact-zip-url>
* playback.html#zip=<artifact-zip-url>
* It unzips in-browser (JSZip from CDN), plays recording.mp4 (preferred) or
* recording.mkv, and renders every *.log with a top-left timer and
* video↔log sync. Sync axis: driver log lines carry a [+MM:SS] prefix,
@@ -171,6 +171,43 @@ function selectTab(name) {
renderPane(true);
}
function ansiToHtml(text) {
const colors = {
30: 'black', 31: 'red', 32: 'green', 33: 'yellow',
34: 'blue', 35: 'magenta', 36: 'cyan', 37: 'white',
90: 'gray', 91: 'lightred', 92: 'lightgreen', 93: 'lightyellow',
94: 'lightblue', 95: 'lightmagenta', 96: 'lightcyan', 97: 'white'
};
const escapeHtml = s => s.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
let html = '';
let openSpan = false;
const parts = text.split(/\x1b\[([0-9;]*)m/);
// parts alternates: [text, codes, text, codes, ...]
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) {
html += escapeHtml(parts[i]);
} else {
if (openSpan) { html += '</span>'; openSpan = false; }
const codes = parts[i].split(';').map(Number);
const styles = [];
for (const code of codes) {
if (code === 0) continue; // reset
if (code === 1) styles.push('font-weight:bold');
else if (colors[code]) styles.push(`color:${colors[code]}`);
}
if (styles.length) {
html += `<span style="${styles.join(';')}">`;
openSpan = true;
}
}
}
if (openSpan) html += '</span>';
return html;
}
function renderPane(scrollToEnd) {
const pane = $('pane');
const entry = state.logs.get(state.activeTab);
@@ -198,7 +235,9 @@ function renderPane(scrollToEnd) {
file.textContent = `${ln.file}: `;
div.appendChild(file);
}
div.appendChild(document.createTextNode(ln.text));
const text = document.createElement('span')
text.innerHTML = ansiToHtml(ln.text);
div.appendChild(text);
frag.appendChild(div);
}
pane.appendChild(frag);
@@ -278,26 +317,20 @@ async function loadZipFromBlob(blob, label) {
}
async function loadZip(url) {
statusEl.textContent = `fetching ${url} …`;
try {
const resp = await fetch(url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error(`zip fetch failed: ${resp.status} ${resp.statusText}`);
await loadZipFromBlob(await resp.blob(), url.split('/').pop());
} catch (e) {
// GitHub artifact downloads are auth-gated: a cross-origin page (this
// file opened locally) cannot fetch them. The page then becomes the
// download-and-drop path — a normal link click DOES carry the user's
// GitHub session.
statusEl.className = 'error';
statusEl.innerHTML =
`Could not fetch the zip directly (GitHub artifact downloads need your session — ` +
`a locally-opened page cannot send it). ` +
`Download the leg zip: <a href="${url}" style="color:#58a6ff">${url.split('/').pop()}</a> ` +
`— then drop it anywhere on this page (or click here to pick the file).`;
statusEl.onclick = () => fileInput.click();
return false;
// GitHub artifact downloads are auth-gated: a cross-origin page (this
// file opened locally) cannot fetch them. The page then becomes the
// download-and-drop path — a normal link click DOES carry the user's
// GitHub session.
statusEl.innerHTML =
`<div>Download the zip from here: <a href="${url}" style="color:#58a6ff" id="egg">${url.split('/').pop()}</a></div>` +
`<div>then drop it anywhere on this page<br>(or click anywhere on the page to pick the file).</div>`;
statusEl.onclick = (e) => {
fileInput.click()
};
egg.onclick = (e) => {
e.stopPropagation();
}
return true;
return false;
}
/**
@@ -385,16 +418,12 @@ function boot() {
async function main() {
const zipUrl = resolveZipUrl();
if (zipUrl) {
// Attempt the direct fetch (works when the page is served on
// github.com itself — same-origin, session cookies flow). On failure
// the status line becomes a download link + drop target.
if (await loadZip(zipUrl)) boot();
loadZip(zipUrl)
} else {
// No zip param: this is the manual path — the leg player artifact
// page or a local copy. Drop or pick a leg zip.
statusEl.className = 'error';
statusEl.innerHTML =
'Drop a leg artifact zip anywhere on this page (or click here to pick the file).';
'Drop a leg artifact zip anywhere on this page<br>(or click here to pick the file).';
statusEl.onclick = () => fileInput.click();
}
}