Files
celery/app/templates/runs/detail.html
gilesb ca4e86d07e
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m26s
Fix S-expression syntax highlighting - HTML was leaking into displayed text
Cascading regex replacements corrupted their own output: the string regex
matched CSS class names inside previously-generated span tags. Replaced with
a single-pass character tokenizer that never re-processes its own HTML output.
Also added highlighting to recipe detail page (previously had none).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 01:02:22 +00:00

1074 lines
48 KiB
HTML

{% extends "base.html" %}
{% block title %}Run {{ run.run_id[:12] }} - Art-DAG L1{% endblock %}
{% block head %}
{{ super() }}
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.23.0/cytoscape.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script>
{% endblock %}
{% block content %}
{% set status_colors = {'completed': 'green', 'running': 'blue', 'pending': 'yellow', 'failed': 'red', 'paused': 'yellow'} %}
{% set color = status_colors.get(run.status, 'gray') %}
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="flex items-center space-x-4 mb-6">
<a href="/runs" class="text-gray-400 hover:text-white">&larr; Runs</a>
<h1 class="text-2xl font-bold font-mono">{{ run.run_id[:16] }}...</h1>
<span class="bg-{{ color }}-900 text-{{ color }}-300 px-3 py-1 rounded text-sm uppercase">
{{ run.status }}
</span>
{% if run.cached %}
<span class="bg-purple-900 text-purple-300 px-3 py-1 rounded text-sm">Cached</span>
{% endif %}
{% if run.error %}
<span class="text-red-400 text-sm ml-2">{{ run.error }}</span>
{% endif %}
{% if run.checkpoint_frame %}
<span class="text-gray-400 text-sm ml-2">
Checkpoint: {{ run.checkpoint_frame }}{% if run.total_frames %} / {{ run.total_frames }}{% endif %} frames
</span>
{% endif %}
<div class="flex-grow"></div>
<!-- Pause button for running renders -->
{% if run.status == 'running' %}
<button hx-post="/runs/{{ run.run_id }}/pause"
hx-target="#action-result"
hx-swap="innerHTML"
class="bg-yellow-600 hover:bg-yellow-700 px-3 py-1 rounded text-sm font-medium">
Pause
</button>
{% endif %}
<!-- Resume/Restart buttons for failed/paused renders -->
{% if run.status in ['failed', 'paused'] %}
{% if run.checkpoint_frame %}
<button hx-post="/runs/{{ run.run_id }}/resume"
hx-target="#action-result"
hx-swap="innerHTML"
class="bg-green-600 hover:bg-green-700 px-3 py-1 rounded text-sm font-medium">
Resume{% if run.total_frames %} ({{ ((run.checkpoint_frame / run.total_frames) * 100)|round|int }}%){% endif %}
</button>
{% endif %}
<button hx-post="/runs/{{ run.run_id }}/restart"
hx-target="#action-result"
hx-swap="innerHTML"
hx-confirm="Discard progress and start over?"
class="bg-yellow-600 hover:bg-yellow-700 px-3 py-1 rounded text-sm font-medium">
Restart
</button>
{% endif %}
{% if run.recipe %}
<button hx-post="/runs/rerun/{{ run.recipe }}"
hx-target="#action-result"
hx-swap="innerHTML"
class="bg-blue-600 hover:bg-blue-700 px-3 py-1 rounded text-sm font-medium">
Run Again
</button>
{% endif %}
<button hx-post="/runs/{{ run.run_id }}/publish"
hx-target="#action-result"
class="bg-purple-600 hover:bg-purple-700 px-3 py-1 rounded text-sm font-medium">
Share to L2
</button>
<button hx-delete="/runs/{{ run.run_id }}/ui"
hx-target="#action-result"
hx-confirm="Delete this run and all its artifacts? This cannot be undone."
class="bg-red-600 hover:bg-red-700 px-3 py-1 rounded text-sm font-medium">
Delete
</button>
<span id="action-result"></span>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">Recipe</div>
<div class="text-white font-medium">
{% if run.recipe %}
<a href="/recipes/{{ run.recipe }}" class="hover:text-blue-400">
{{ run.recipe_name or (run.recipe[:16] ~ '...') }}
</a>
{% else %}
Unknown
{% endif %}
</div>
</div>
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">Steps</div>
<div class="text-white font-medium">
{% if run.recipe == 'streaming' %}
{% if run.status == 'completed' %}1 / 1{% else %}0 / 1{% endif %}
{% else %}
{{ run.executed or 0 }} / {{ run.total_steps or (plan.steps|length if plan and plan.steps else '?') }}
{% endif %}
{% if run.cached_steps %}
<span class="text-purple-400 text-sm">({{ run.cached_steps }} cached)</span>
{% endif %}
</div>
</div>
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">Created</div>
<div class="text-white font-medium">{{ run.created_at }}</div>
</div>
<div class="bg-gray-800 rounded-lg p-4">
<div class="text-gray-500 text-sm">User</div>
<div class="text-white font-medium">{{ run.username or 'Unknown' }}</div>
</div>
</div>
<!-- Unified HLS Player (shown during rendering, for paused/failed runs with checkpoint, OR for completed HLS streams) -->
{% if run.status == 'rendering' or run.ipfs_playlist_cid or (run.status in ['paused', 'failed'] and run.checkpoint_frame) %}
<div id="hls-player-container" class="mb-6 bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold flex items-center">
{% if run.status == 'rendering' %}
<span id="live-indicator" class="w-3 h-3 bg-red-500 rounded-full mr-2 animate-pulse"></span>
<span id="player-title">Live Preview</span>
{% elif run.status == 'paused' %}
<span id="live-indicator" class="w-3 h-3 bg-yellow-500 rounded-full mr-2"></span>
<span id="player-title">Partial Output (Paused)</span>
{% elif run.status == 'failed' and run.checkpoint_frame %}
<span id="live-indicator" class="w-3 h-3 bg-red-500 rounded-full mr-2"></span>
<span id="player-title">Partial Output (Failed)</span>
{% else %}
<span id="live-indicator" class="w-3 h-3 bg-green-500 rounded-full mr-2 hidden"></span>
<span id="player-title">Video</span>
{% endif %}
</h3>
<div class="flex items-center space-x-4">
<!-- Mode toggle -->
<div class="flex items-center space-x-2 text-sm">
<button id="mode-replay" onclick="setPlayerMode('replay')"
class="px-2 py-1 rounded {% if run.status != 'rendering' %}bg-blue-600 text-white{% else %}bg-gray-700 text-gray-400 hover:bg-gray-600{% endif %}">
From Start
</button>
<button id="mode-live" onclick="setPlayerMode('live')"
class="px-2 py-1 rounded {% if run.status == 'rendering' %}bg-blue-600 text-white{% else %}bg-gray-700 text-gray-400 hover:bg-gray-600{% endif %}">
Live Edge
</button>
</div>
<div id="stream-status" class="text-sm text-gray-400">Connecting...</div>
</div>
</div>
<div class="relative bg-black rounded-lg overflow-hidden" style="aspect-ratio: 16/9;">
<video id="hls-video" class="w-full h-full" controls autoplay muted playsinline></video>
<div id="stream-loading" class="absolute inset-0 flex items-center justify-center bg-gray-900/80">
<div class="text-center">
<div class="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2"></div>
<div class="text-gray-400">Waiting for stream...</div>
</div>
</div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-gray-500">
<span>Stream: <code class="bg-gray-900 px-1 rounded">/runs/{{ run.run_id }}/playlist.m3u8</code></span>
<span id="stream-info"></span>
</div>
</div>
<script>
(function() {
const video = document.getElementById('hls-video');
const statusEl = document.getElementById('stream-status');
const loadingEl = document.getElementById('stream-loading');
const streamInfoEl = document.getElementById('stream-info');
const liveIndicator = document.getElementById('live-indicator');
const playerTitle = document.getElementById('player-title');
const modeReplayBtn = document.getElementById('mode-replay');
const modeLiveBtn = document.getElementById('mode-live');
const baseUrl = '/runs/{{ run.run_id }}/playlist.m3u8';
const isRendering = {{ 'true' if run.status == 'rendering' else 'false' }};
const isPausedOrFailed = {{ 'true' if run.status in ['paused', 'failed'] else 'false' }};
let hls = null;
let retryCount = 0;
const maxRetries = 120;
let segmentsLoaded = 0;
// Start in replay mode for paused/failed (shows partial output from start)
// Start in live mode for rendering (follows the render progress)
let currentMode = isRendering ? 'live' : 'replay';
function getHlsUrl() {
return baseUrl + '?_t=' + Date.now();
}
// Custom playlist loader that adds cache-busting to every request
class CacheBustingPlaylistLoader extends Hls.DefaultConfig.loader {
load(context, config, callbacks) {
if (context.type === 'manifest' || context.type === 'level') {
const url = new URL(context.url, window.location.origin);
url.searchParams.set('_t', Date.now());
context.url = url.toString();
}
super.load(context, config, callbacks);
}
}
function getHlsConfig(mode) {
const baseConfig = {
maxBufferLength: 120,
maxMaxBufferLength: 180,
maxBufferSize: 100 * 1024 * 1024,
maxBufferHole: 0.5,
backBufferLength: 60,
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 4,
levelLoadingTimeOut: 10000,
levelLoadingMaxRetry: 4,
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 6,
startLevel: 0,
abrEwmaDefaultEstimate: 500000,
};
if (mode === 'live') {
// Live mode: follow the edge, cache-bust playlists
return {
...baseConfig,
pLoader: CacheBustingPlaylistLoader,
liveSyncDurationCount: 10,
liveMaxLatencyDurationCount: 20,
liveDurationInfinity: true,
};
} else {
// Replay mode: start from beginning, no live sync
return {
...baseConfig,
pLoader: CacheBustingPlaylistLoader, // Still bust cache for fresh playlist
startPosition: 0,
liveDurationInfinity: false,
};
}
}
function updateModeUI(mode) {
currentMode = mode;
if (mode === 'live') {
modeLiveBtn.classList.add('bg-blue-600', 'text-white');
modeLiveBtn.classList.remove('bg-gray-700', 'text-gray-400');
modeReplayBtn.classList.remove('bg-blue-600', 'text-white');
modeReplayBtn.classList.add('bg-gray-700', 'text-gray-400');
liveIndicator.classList.remove('hidden', 'bg-green-500');
liveIndicator.classList.add('bg-red-500', 'animate-pulse');
playerTitle.textContent = isRendering ? 'Live Preview' : 'Live Edge';
} else {
modeReplayBtn.classList.add('bg-blue-600', 'text-white');
modeReplayBtn.classList.remove('bg-gray-700', 'text-gray-400');
modeLiveBtn.classList.remove('bg-blue-600', 'text-white');
modeLiveBtn.classList.add('bg-gray-700', 'text-gray-400');
liveIndicator.classList.add('hidden');
liveIndicator.classList.remove('animate-pulse');
playerTitle.textContent = 'Replay';
}
}
window.setPlayerMode = function(mode) {
if (mode === currentMode) return;
const currentTime = video.currentTime;
const wasPlaying = !video.paused;
// Destroy current HLS instance
if (hls) {
hls.destroy();
hls = null;
}
updateModeUI(mode);
segmentsLoaded = 0;
retryCount = 0;
// Reinitialize with new config
initHls(mode, mode === 'replay' ? 0 : null); // Start from 0 in replay, live edge in live
};
function initHls(mode, startPosition) {
mode = mode || currentMode;
if (Hls.isSupported()) {
const config = getHlsConfig(mode);
if (startPosition !== null && startPosition !== undefined) {
config.startPosition = startPosition;
}
hls = new Hls(config);
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
loadingEl.classList.add('hidden');
statusEl.textContent = 'Buffering...';
statusEl.classList.remove('text-gray-400');
statusEl.classList.add('text-yellow-400');
streamInfoEl.textContent = `${data.levels.length} quality level(s)`;
video.play().catch(() => {});
});
hls.on(Hls.Events.FRAG_LOADED, function(event, data) {
retryCount = 0;
segmentsLoaded++;
const modeLabel = currentMode === 'live' ? 'Live' : 'Replay';
statusEl.textContent = `${modeLabel} (${segmentsLoaded} segments)`;
statusEl.classList.remove('text-yellow-400', 'text-gray-400');
statusEl.classList.add('text-green-400');
});
hls.on(Hls.Events.BUFFER_APPENDED, function() {
loadingEl.classList.add('hidden');
});
hls.on(Hls.Events.ERROR, function(event, data) {
console.log('HLS error:', data.type, data.details, data.fatal);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
if (retryCount < maxRetries) {
retryCount++;
statusEl.textContent = `Waiting for stream... (${retryCount})`;
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
const delay = Math.min(1000 * Math.pow(1.5, Math.min(retryCount, 6)), 10000);
setTimeout(() => {
hls.loadSource(getHlsUrl());
}, delay + Math.random() * 1000);
} else {
statusEl.textContent = 'Stream unavailable';
statusEl.classList.add('text-red-400');
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('Media error, attempting recovery');
hls.recoverMediaError();
break;
default:
statusEl.textContent = 'Stream error';
statusEl.classList.add('text-red-400');
break;
}
} else {
if (data.details === 'bufferStalledError') {
statusEl.textContent = 'Buffering...';
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
}
}
});
video.addEventListener('waiting', function() {
if (currentMode === 'live' && hls && hls.liveSyncPosition) {
const liveEdge = hls.liveSyncPosition;
const behindLive = liveEdge - video.currentTime;
if (behindLive < 8) {
statusEl.textContent = 'Waiting for rendering...';
} else {
statusEl.textContent = 'Buffering...';
}
} else {
statusEl.textContent = 'Buffering...';
}
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
});
video.addEventListener('playing', function() {
const modeLabel = currentMode === 'live' ? 'Live' : 'Replay';
statusEl.textContent = `${modeLabel} (${segmentsLoaded} segments)`;
statusEl.classList.remove('text-yellow-400');
statusEl.classList.add('text-green-400');
});
// Live mode: periodic check for catching up to live edge
if (currentMode === 'live') {
setInterval(function() {
if (hls && !video.paused && hls.levels && hls.levels.length > 0) {
const buffered = video.buffered;
if (buffered.length > 0) {
const bufferEnd = buffered.end(buffered.length - 1);
const bufferAhead = bufferEnd - video.currentTime;
if (bufferAhead < 4) {
statusEl.textContent = 'Waiting for rendering...';
statusEl.classList.remove('text-green-400');
statusEl.classList.add('text-yellow-400');
}
}
}
}, 1000);
}
hls.loadSource(getHlsUrl());
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = getHlsUrl();
video.addEventListener('loadedmetadata', function() {
loadingEl.classList.add('hidden');
statusEl.textContent = 'Playing';
video.play().catch(() => {});
});
} else {
statusEl.textContent = 'HLS not supported';
statusEl.classList.add('text-red-400');
}
}
// Initialize with appropriate mode
updateModeUI(currentMode);
initHls(currentMode);
window.addEventListener('beforeunload', function() {
if (hls) hls.destroy();
});
})();
</script>
{% endif %}
<!-- Tabs -->
<div class="border-b border-gray-700 mb-6">
<nav class="flex space-x-8">
<a href="#plan" class="tab-link border-b-2 border-blue-500 text-white pb-3 px-1"
onclick="showTab('plan')">Plan</a>
<a href="#artifacts" class="tab-link border-b-2 border-transparent text-gray-400 hover:text-white pb-3 px-1"
onclick="showTab('artifacts')">Artifacts</a>
{% if analysis %}
<a href="#analysis" class="tab-link border-b-2 border-transparent text-gray-400 hover:text-white pb-3 px-1"
onclick="showTab('analysis')">Analysis</a>
{% endif %}
<a href="#inputs" class="tab-link border-b-2 border-transparent text-gray-400 hover:text-white pb-3 px-1"
onclick="showTab('inputs')">Inputs</a>
</nav>
</div>
<!-- Plan Tab -->
<div id="tab-plan" class="tab-content">
{% if plan %}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4">
<!-- DAG Visualization -->
<div class="lg:col-span-2">
<div id="dag-container" class="bg-gray-900 rounded-lg border border-gray-700 h-96"></div>
</div>
<!-- Node Detail Panel -->
<div id="node-detail" class="bg-gray-800 rounded-lg border border-gray-700 p-4 h-96 overflow-y-auto">
<div id="node-detail-empty" class="h-full flex items-center justify-center text-gray-500">
Click a node to view details
</div>
<div id="node-detail-content" class="hidden">
<div class="flex items-center justify-between mb-4">
<h3 id="node-name" class="text-lg font-semibold"></h3>
<span id="node-type" class="text-sm px-2 py-1 rounded"></span>
</div>
<div id="node-status" class="mb-4"></div>
<!-- Inputs -->
<div id="node-inputs-section" class="mb-4">
<h4 class="text-gray-400 text-sm mb-2">Inputs</h4>
<div id="node-inputs" class="space-y-2"></div>
</div>
<!-- Output -->
<div id="node-output-section">
<h4 class="text-gray-400 text-sm mb-2">Output</h4>
<div id="node-output"></div>
</div>
<!-- Config -->
<div id="node-config-section" class="mt-4 hidden">
<h4 class="text-gray-400 text-sm mb-2">Config</h4>
<pre id="node-config" class="text-xs bg-gray-900 rounded p-2 overflow-x-auto"></pre>
</div>
</div>
</div>
</div>
<!-- Step List -->
<div class="space-y-2">
{% for step in plan.steps %}
{% set step_color = 'green' if step.status == 'completed' or step.cache_id else ('purple' if step.cached else ('blue' if step.status == 'running' else 'gray')) %}
<div class="step-item bg-gray-800 rounded p-3 cursor-pointer hover:bg-gray-750 transition-colors"
data-step-id="{{ step.id }}"
data-step-name="{{ step.name }}"
data-step-type="{{ step.type }}"
data-step-status="{{ step.status or 'pending' }}"
data-step-inputs="{{ step.inputs | tojson }}"
data-step-cache-id="{{ step.cache_id or '' }}"
data-step-config="{{ (step.config or {}) | tojson }}"
onclick="selectStep('{{ step.id }}')">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span class="w-6 h-6 rounded-full bg-{{ step_color }}-600 flex items-center justify-center text-xs">
{{ loop.index }}
</span>
<span class="font-medium">{{ step.name }}</span>
<span class="text-gray-500 text-sm">{{ step.type }}</span>
</div>
<div class="flex items-center space-x-3">
{% if step.cached %}
<span class="text-purple-400 text-sm">cached</span>
{% elif step.status == 'completed' %}
<span class="text-green-400 text-sm">completed</span>
{% endif %}
</div>
</div>
{% if step.cache_id %}
<div class="mt-2 ml-9 flex items-center space-x-2">
<span class="text-gray-500 text-xs">Output:</span>
<a href="/cache/{{ step.cache_id }}" class="font-mono text-xs text-blue-400 hover:text-blue-300" onclick="event.stopPropagation()">
{{ step.cache_id[:24] }}...
</a>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Recipe/Plan S-expression -->
{% if plan_sexp %}
<details class="mt-6" open>
<summary class="cursor-pointer text-gray-400 hover:text-white text-sm mb-2 flex items-center justify-between">
<span>Recipe (S-expression)</span>
{% if recipe_ipfs_cid %}
<a href="https://ipfs.io/ipfs/{{ recipe_ipfs_cid }}"
target="_blank"
onclick="event.stopPropagation()"
class="text-blue-400 hover:text-blue-300 text-xs font-mono ml-4">
ipfs://{{ recipe_ipfs_cid[:16] }}...
</a>
{% endif %}
</summary>
<div class="bg-gray-900 rounded-lg border border-gray-700 p-4 overflow-x-auto">
<pre class="text-sm font-mono sexp-code">{{ plan_sexp }}</pre>
</div>
</details>
{% endif %}
<style>
.sexp-code {
line-height: 1.6;
}
</style>
<script>
// Single-pass S-expression syntax highlighter (avoids regex corruption)
function highlightSexp(text) {
const SPECIAL = new Set(['plan','recipe','def','->','stream','let','lambda','if','cond','define']);
const PRIMS = new Set(['source','effect','sequence','segment','resize','transform','layer','blend','mux','analyze','fused-pipeline']);
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function span(cls, s) { return '<span class="' + cls + '">' + esc(s) + '</span>'; }
let out = '', i = 0, len = text.length;
while (i < len) {
// Comments
if (text[i] === ';' && i + 1 < len && text[i+1] === ';') {
let end = text.indexOf('\n', i);
if (end === -1) end = len;
out += span('text-gray-500', text.slice(i, end));
i = end;
}
// Strings
else if (text[i] === '"') {
let j = i + 1;
while (j < len && text[j] !== '"') { if (text[j] === '\\') j++; j++; }
if (j < len) j++; // closing quote
out += span('text-green-400', text.slice(i, j));
i = j;
}
// Keywords (:keyword)
else if (text[i] === ':' && i + 1 < len && /[a-zA-Z_-]/.test(text[i+1])) {
let j = i + 1;
while (j < len && /[a-zA-Z0-9_-]/.test(text[j])) j++;
out += span('text-purple-400', text.slice(i, j));
i = j;
}
// Open paren - check for primitive/special after it
else if (text[i] === '(') {
out += span('text-yellow-500', '(');
i++;
// Skip whitespace after paren
let ws = '';
while (i < len && (text[i] === ' ' || text[i] === '\t')) { ws += text[i]; i++; }
out += esc(ws);
// Check if next word is a special form or primitive
if (i < len && /[a-zA-Z_>-]/.test(text[i])) {
let j = i;
while (j < len && /[a-zA-Z0-9_>-]/.test(text[j])) j++;
let word = text.slice(i, j);
if (SPECIAL.has(word)) out += span('text-pink-400 font-semibold', word);
else if (PRIMS.has(word)) out += span('text-blue-400', word);
else out += esc(word);
i = j;
}
}
// Close paren
else if (text[i] === ')') {
out += span('text-yellow-500', ')');
i++;
}
// Numbers
else if (/[0-9]/.test(text[i]) && (i === 0 || /[\s(]/.test(text[i-1]))) {
let j = i;
while (j < len && /[0-9.]/.test(text[j])) j++;
out += span('text-orange-300', text.slice(i, j));
i = j;
}
// Regular text
else {
let j = i;
while (j < len && !'(;":)'.includes(text[j])) {
if (text[j] === ':' && j + 1 < len && /[a-zA-Z_-]/.test(text[j+1])) break;
if (/[0-9]/.test(text[j]) && (j === 0 || /[\s(]/.test(text[j-1]))) break;
j++;
}
if (j === i) { out += esc(text[i]); i++; } // safety: advance at least 1 char
else { out += esc(text.slice(i, j)); i = j; }
}
}
return out;
}
document.querySelectorAll('.sexp-code').forEach(el => {
el.innerHTML = highlightSexp(el.textContent);
});
</script>
{% else %}
<p class="text-gray-500">No plan available for this run.</p>
{% endif %}
</div>
<!-- Artifacts Tab -->
<div id="tab-artifacts" class="tab-content hidden">
{% if artifacts %}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for artifact in artifacts %}
<a href="/cache/{{ artifact.cid }}"
class="bg-gray-800 rounded-lg p-4 hover:bg-gray-750 transition-colors">
{% if artifact.media_type and artifact.media_type.startswith('image/') %}
<img src="/cache/{{ artifact.cid }}/raw" alt=""
class="w-full h-32 object-cover rounded mb-2">
{% elif artifact.media_type and artifact.media_type.startswith('video/') %}
<video src="/cache/{{ artifact.cid }}/raw"
class="w-full h-32 object-cover rounded mb-2" muted></video>
{% else %}
<div class="w-full h-32 bg-gray-900 rounded mb-2 flex items-center justify-center text-gray-600">
{{ artifact.media_type or 'Unknown' }}
</div>
{% endif %}
<div class="font-mono text-xs text-gray-500 truncate">{{ artifact.cid[:16] }}...</div>
<div class="text-sm text-gray-400">{{ artifact.step_name }}</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500">No artifacts generated yet.</p>
{% endif %}
</div>
<!-- Analysis Tab -->
<div id="tab-analysis" class="tab-content hidden">
{% if analysis %}
<div class="space-y-6">
{% for item in analysis %}
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">{{ item.input_name }}</h3>
<a href="/cache/{{ item.input_cid }}" class="font-mono text-xs text-blue-400 hover:text-blue-300">
{{ item.input_cid[:16] }}...
</a>
</div>
{% if item.has_analysis %}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
{% if item.tempo %}
<div class="bg-gray-900 rounded p-3">
<div class="text-gray-500 text-sm">Tempo</div>
<div class="text-2xl font-bold text-pink-400">{{ item.tempo | round(1) }}</div>
<div class="text-gray-500 text-xs">BPM</div>
</div>
{% endif %}
{% if item.beat_times %}
<div class="bg-gray-900 rounded p-3">
<div class="text-gray-500 text-sm">Beats</div>
<div class="text-2xl font-bold text-blue-400">{{ item.beat_times | length }}</div>
<div class="text-gray-500 text-xs">detected</div>
</div>
{% endif %}
{% if item.energy is not none %}
<div class="bg-gray-900 rounded p-3">
<div class="text-gray-500 text-sm">Energy</div>
<div class="text-2xl font-bold text-green-400">{{ (item.energy * 100) | round(1) }}%</div>
<div class="text-gray-500 text-xs">average</div>
</div>
{% endif %}
{% if item.beat_times %}
<div class="bg-gray-900 rounded p-3">
<div class="text-gray-500 text-sm">Duration</div>
<div class="text-2xl font-bold text-purple-400">
{{ (item.beat_times[-1] | default(0)) | round(1) }}s
</div>
<div class="text-gray-500 text-xs">analyzed</div>
</div>
{% endif %}
</div>
<!-- Beat visualization -->
{% if item.beat_times and item.beat_times | length > 0 %}
<div class="mt-4">
<div class="text-gray-500 text-sm mb-2">Beat Timeline</div>
<div class="bg-gray-900 rounded h-8 relative overflow-hidden">
{% set max_time = item.beat_times[-1] if item.beat_times else 1 %}
{% for beat_time in item.beat_times[:100] %}
<div class="absolute top-0 bottom-0 w-px bg-pink-500 opacity-60"
style="left: {{ (beat_time / max_time * 100) | round(2) }}%"></div>
{% endfor %}
</div>
<div class="flex justify-between text-xs text-gray-600 mt-1">
<span>0s</span>
<span>{{ max_time | round(1) }}s</span>
</div>
</div>
{% endif %}
<!-- Raw data toggle -->
<details class="mt-4">
<summary class="cursor-pointer text-gray-400 hover:text-white text-sm">
Show Raw Analysis Data
</summary>
<div class="bg-gray-900 rounded-lg border border-gray-700 p-4 mt-2 overflow-x-auto">
<pre class="text-sm text-gray-300 whitespace-pre-wrap">{{ item.raw | tojson(indent=2) }}</pre>
</div>
</details>
{% else %}
<p class="text-gray-500">No analysis data available for this input.</p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500">No inputs with analysis data.</p>
{% endif %}
</div>
<!-- Inputs Tab -->
<div id="tab-inputs" class="tab-content hidden">
{% if run_inputs %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for input in run_inputs %}
<div class="bg-gray-800 rounded-lg overflow-hidden">
<!-- Media Preview -->
{% if input.media_type and input.media_type.startswith('image/') %}
<a href="/cache/{{ input.cid }}" class="block">
<img src="/cache/{{ input.cid }}/raw" alt="{{ input.name or 'Input' }}"
class="w-full h-48 object-cover">
</a>
{% elif input.media_type and input.media_type.startswith('video/') %}
<a href="/cache/{{ input.cid }}" class="block">
<video src="/cache/{{ input.cid }}/raw"
class="w-full h-48 object-cover" muted controls></video>
</a>
{% elif input.media_type and input.media_type.startswith('audio/') %}
<div class="p-4 bg-gray-900">
<audio src="/cache/{{ input.cid }}/raw" controls class="w-full"></audio>
</div>
{% else %}
<a href="/cache/{{ input.cid }}" class="block">
<div class="w-full h-48 bg-gray-900 flex items-center justify-center text-gray-600">
<div class="text-center">
<div class="text-4xl mb-2">📄</div>
<div>{{ input.media_type or 'Unknown type' }}</div>
</div>
</div>
</a>
{% endif %}
<!-- Info -->
<div class="p-3">
{% if input.name %}
<div class="font-medium text-white mb-1">{{ input.name }}</div>
{% endif %}
<a href="/cache/{{ input.cid }}" class="font-mono text-xs text-blue-400 hover:text-blue-300 block truncate">
{{ input.cid }}
</a>
{% if input.media_type %}
<div class="text-xs text-gray-500 mt-1">{{ input.media_type }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% elif run.inputs %}
<!-- Fallback to simple list if run_inputs not available -->
<div class="space-y-2">
{% for input_hash in run.inputs %}
<a href="/cache/{{ input_hash }}"
class="block bg-gray-800 rounded p-3 font-mono text-sm hover:bg-gray-750">
{{ input_hash }}
</a>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500">No inputs recorded.</p>
{% endif %}
</div>
<!-- Output -->
{% if run.output_cid %}
<div class="mt-8 bg-gray-800 rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Output</h3>
{# Inline media preview - prefer IPFS URLs when available #}
<div class="mb-4">
{% if output_media_type and output_media_type.startswith('image/') %}
<a href="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}{% endif %}" class="block">
<img src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" alt="Output"
class="max-w-full max-h-96 rounded-lg mx-auto">
</a>
{% elif output_media_type and output_media_type.startswith('video/') %}
{# HLS streams use the unified player above; show direct video for non-HLS #}
{% if run.ipfs_playlist_cid %}
<div class="text-gray-400 text-sm py-4">
HLS stream available in player above. Use "From Start" to watch from beginning or "Live Edge" to follow rendering progress.
</div>
{% else %}
{# Direct video file #}
<video src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" controls
class="max-w-full max-h-96 rounded-lg mx-auto"></video>
{% endif %}
{% elif output_media_type and output_media_type.startswith('audio/') %}
<audio src="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}/raw{% endif %}" controls class="w-full"></audio>
{% else %}
<div class="bg-gray-900 rounded-lg p-8 text-center text-gray-500">
<div class="text-4xl mb-2">?</div>
<div>{{ output_media_type or 'Unknown media type' }}</div>
</div>
{% endif %}
</div>
<div class="flex items-center justify-between">
<a href="{% if run.ipfs_cid %}/ipfs/{{ run.ipfs_cid }}{% else %}/cache/{{ run.output_cid }}{% endif %}"
class="font-mono text-sm text-blue-400 hover:text-blue-300">
{% if run.ipfs_cid %}{{ run.ipfs_cid }}{% else %}{{ run.output_cid }}{% endif %}
</a>
<div class="flex items-center space-x-4">
{% if run.ipfs_playlist_cid %}
<a href="/ipfs/{{ run.ipfs_playlist_cid }}"
class="text-gray-400 hover:text-white text-sm">
HLS Playlist
</a>
{% endif %}
{% if run.ipfs_cid %}
<a href="https://ipfs.io/ipfs/{{ run.ipfs_cid }}"
target="_blank"
class="text-gray-400 hover:text-white text-sm">
View on IPFS Gateway
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
<script>
function showTab(name) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.querySelectorAll('.tab-link').forEach(el => {
el.classList.remove('border-blue-500', 'text-white');
el.classList.add('border-transparent', 'text-gray-400');
});
document.getElementById('tab-' + name).classList.remove('hidden');
event.target.classList.add('border-blue-500', 'text-white');
event.target.classList.remove('border-transparent', 'text-gray-400');
}
{% if plan %}
// Store step data for quick lookup
const stepData = {};
document.querySelectorAll('.step-item').forEach(el => {
const id = el.dataset.stepId;
stepData[id] = {
id: id,
name: el.dataset.stepName,
type: el.dataset.stepType,
status: el.dataset.stepStatus,
inputs: JSON.parse(el.dataset.stepInputs || '[]'),
cacheId: el.dataset.stepCacheId,
config: JSON.parse(el.dataset.stepConfig || '{}')
};
});
console.log('stepData loaded:', Object.keys(stepData).length, 'steps');
console.log('dag_elements:', {{ dag_elements | tojson }});
let cy = null;
let selectedNode = null;
function selectStep(stepId) {
// Update URL hash
history.pushState(null, '', '#node-' + stepId);
showNodeDetail(stepId);
}
function showNodeDetail(stepId) {
const step = stepData[stepId];
if (!step) return;
selectedNode = stepId;
// Update step list selection
document.querySelectorAll('.step-item').forEach(el => {
el.classList.remove('ring-2', 'ring-blue-500');
if (el.dataset.stepId === stepId) {
el.classList.add('ring-2', 'ring-blue-500');
}
});
// Update cytoscape selection
if (cy) {
cy.nodes().removeClass('selected');
cy.nodes().style('border-width', 0);
const node = cy.$('#' + stepId);
if (node.length) {
node.style('border-width', 3);
node.style('border-color', '#3b82f6');
}
}
// Show detail panel
document.getElementById('node-detail-empty').classList.add('hidden');
document.getElementById('node-detail-content').classList.remove('hidden');
// Populate node info
document.getElementById('node-name').textContent = step.name;
const typeEl = document.getElementById('node-type');
typeEl.textContent = step.type;
const typeColors = {
'SOURCE': 'bg-blue-600',
'EFFECT': 'bg-purple-600',
'SEQUENCE': 'bg-pink-600',
'transform': 'bg-green-600',
'output': 'bg-yellow-600'
};
typeEl.className = 'text-sm px-2 py-1 rounded ' + (typeColors[step.type] || 'bg-gray-600');
// Status
const statusEl = document.getElementById('node-status');
const statusColors = {
'completed': 'text-green-400',
'running': 'text-blue-400',
'pending': 'text-gray-400',
'cached': 'text-purple-400'
};
statusEl.innerHTML = `<span class="${statusColors[step.status] || 'text-gray-400'}">${step.status}</span>`;
// Inputs
const inputsEl = document.getElementById('node-inputs');
if (step.inputs.length > 0) {
document.getElementById('node-inputs-section').classList.remove('hidden');
inputsEl.innerHTML = step.inputs.map(inp => {
const inputStep = stepData[inp];
const inputCacheId = inputStep ? inputStep.cacheId : '';
if (inputCacheId) {
return `<div class="bg-gray-900 rounded p-2">
<div class="text-sm text-gray-300 cursor-pointer hover:text-blue-400" onclick="selectStep('${inp}')">${inp}</div>
<a href="/cache/${inputCacheId}" class="font-mono text-xs text-blue-400 hover:text-blue-300 block mt-1" onclick="event.stopPropagation()">
${inputCacheId.substring(0, 24)}...
</a>
</div>`;
} else {
return `<div class="bg-gray-900 rounded p-2">
<div class="text-sm text-gray-300 cursor-pointer hover:text-blue-400" onclick="selectStep('${inp}')">${inp}</div>
<span class="text-xs text-gray-500">No cached output</span>
</div>`;
}
}).join('');
} else {
document.getElementById('node-inputs-section').classList.add('hidden');
}
// Output
const outputEl = document.getElementById('node-output');
if (step.cacheId) {
outputEl.innerHTML = `
<div class="bg-gray-900 rounded p-2">
<a href="/cache/${step.cacheId}" class="font-mono text-xs text-blue-400 hover:text-blue-300 block">
${step.cacheId}
</a>
<a href="/cache/${step.cacheId}/raw" target="_blank" class="text-xs text-gray-500 hover:text-gray-300 mt-1 inline-block">
View raw
</a>
</div>`;
} else {
outputEl.innerHTML = '<span class="text-gray-500 text-sm">No output yet</span>';
}
// Config
const configSection = document.getElementById('node-config-section');
const configEl = document.getElementById('node-config');
if (step.config && Object.keys(step.config).length > 0) {
configSection.classList.remove('hidden');
configEl.textContent = JSON.stringify(step.config, null, 2);
} else {
configSection.classList.add('hidden');
}
}
// Initialize DAG
document.addEventListener('DOMContentLoaded', function() {
cy = cytoscape({
container: document.getElementById('dag-container'),
style: [
{ selector: 'node', style: {
'label': 'data(label)',
'background-color': 'data(color)',
'color': '#fff',
'text-valign': 'center',
'font-size': '10px',
'width': 40,
'height': 40,
'border-width': 0
}},
{ selector: 'edge', style: {
'width': 2,
'line-color': '#4b5563',
'target-arrow-color': '#4b5563',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier'
}}
],
elements: {{ dag_elements | tojson }},
layout: { name: 'dagre', rankDir: 'LR', padding: 30 }
});
// Node click handler
cy.on('tap', 'node', function(evt) {
const nodeId = evt.target.id();
selectStep(nodeId);
});
// Handle initial hash
const hash = window.location.hash;
if (hash && hash.startsWith('#node-')) {
const nodeId = hash.substring(6);
if (stepData[nodeId]) {
showNodeDetail(nodeId);
}
}
});
// Handle hash changes
window.addEventListener('hashchange', function() {
const hash = window.location.hash;
if (hash && hash.startsWith('#node-')) {
const nodeId = hash.substring(6);
if (stepData[nodeId]) {
showNodeDetail(nodeId);
}
}
});
{% endif %}
</script>
{% endblock %}