feat(playback): ansi n styles
This commit is contained in:
@@ -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 => ({'&':'&','<':'<','>':'>'}[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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user