Add inline media previews for runs list and detail page
- Run card shows thumbnail previews for inputs and output - Run detail shows output media inline (image/video/audio) - Add audio detection (MP3, FLAC, OGG, WAV) to detect_media_type - Add debug logging for recipe count on home page - Add console.log debugging for DAG elements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,11 +43,15 @@ async def home(request: Request):
|
|||||||
try:
|
try:
|
||||||
from ..services.recipe_service import RecipeService
|
from ..services.recipe_service import RecipeService
|
||||||
from ..dependencies import get_redis_client, get_cache_manager
|
from ..dependencies import get_redis_client, get_cache_manager
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
|
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
|
||||||
recipes = await recipe_service.list_recipes(user.actor_id)
|
recipes = await recipe_service.list_recipes(user.actor_id)
|
||||||
stats["recipes"] = len(recipes)
|
stats["recipes"] = len(recipes)
|
||||||
except Exception:
|
logger.info(f"Home page: found {len(recipes)} recipes for {user.actor_id}")
|
||||||
pass
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).error(f"Failed to get recipe count: {e}")
|
||||||
try:
|
try:
|
||||||
from ..services.run_service import RunService
|
from ..services.run_service import RunService
|
||||||
from ..dependencies import get_redis_client, get_cache_manager
|
from ..dependencies import get_redis_client, get_cache_manager
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ async def get_run(
|
|||||||
|
|
||||||
# Build artifacts list from output and inputs
|
# Build artifacts list from output and inputs
|
||||||
artifacts = []
|
artifacts = []
|
||||||
|
output_media_type = None
|
||||||
if run.get("output_hash"):
|
if run.get("output_hash"):
|
||||||
# Detect media type using magic bytes
|
# Detect media type using magic bytes
|
||||||
output_hash = run["output_hash"]
|
output_hash = run["output_hash"]
|
||||||
@@ -192,6 +193,7 @@ async def get_run(
|
|||||||
if cache_path and cache_path.exists():
|
if cache_path and cache_path.exists():
|
||||||
simple_type = detect_media_type(cache_path)
|
simple_type = detect_media_type(cache_path)
|
||||||
media_type = type_to_mime(simple_type)
|
media_type = type_to_mime(simple_type)
|
||||||
|
output_media_type = media_type
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
artifacts.append({
|
artifacts.append({
|
||||||
@@ -263,6 +265,7 @@ async def get_run(
|
|||||||
artifacts=artifacts,
|
artifacts=artifacts,
|
||||||
run_inputs=run_inputs,
|
run_inputs=run_inputs,
|
||||||
dag_elements=dag_elements,
|
dag_elements=dag_elements,
|
||||||
|
output_media_type=output_media_type,
|
||||||
active_tab="runs",
|
active_tab="runs",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -308,6 +311,46 @@ async def list_runs(
|
|||||||
if wants_json(request):
|
if wants_json(request):
|
||||||
return {"runs": runs, "offset": offset, "limit": limit, "has_more": has_more}
|
return {"runs": runs, "offset": offset, "limit": limit, "has_more": has_more}
|
||||||
|
|
||||||
|
# Add media info for inline previews (only for HTML)
|
||||||
|
cache_manager = get_cache_manager()
|
||||||
|
from ..services.run_service import detect_media_type
|
||||||
|
|
||||||
|
def type_to_mime(simple_type: str) -> str:
|
||||||
|
if simple_type == "video":
|
||||||
|
return "video/mp4"
|
||||||
|
elif simple_type == "image":
|
||||||
|
return "image/jpeg"
|
||||||
|
elif simple_type == "audio":
|
||||||
|
return "audio/mpeg"
|
||||||
|
return None
|
||||||
|
|
||||||
|
for run in runs:
|
||||||
|
# Add output media info
|
||||||
|
if run.get("output_hash"):
|
||||||
|
try:
|
||||||
|
cache_path = cache_manager.get_by_content_hash(run["output_hash"])
|
||||||
|
if cache_path and cache_path.exists():
|
||||||
|
simple_type = detect_media_type(cache_path)
|
||||||
|
run["output_media_type"] = type_to_mime(simple_type)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add input media info (first 3 inputs)
|
||||||
|
input_previews = []
|
||||||
|
inputs = run.get("inputs", [])
|
||||||
|
if isinstance(inputs, list):
|
||||||
|
for input_hash in inputs[:3]:
|
||||||
|
preview = {"hash": input_hash, "media_type": None}
|
||||||
|
try:
|
||||||
|
cache_path = cache_manager.get_by_content_hash(input_hash)
|
||||||
|
if cache_path and cache_path.exists():
|
||||||
|
simple_type = detect_media_type(cache_path)
|
||||||
|
preview["media_type"] = type_to_mime(simple_type)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
input_previews.append(preview)
|
||||||
|
run["input_previews"] = input_previews
|
||||||
|
|
||||||
templates = get_templates(request)
|
templates = get_templates(request)
|
||||||
return render(templates, "runs/list.html", request,
|
return render(templates, "runs/list.html", request,
|
||||||
runs=runs,
|
runs=runs,
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ def detect_media_type(cache_path: Path) -> str:
|
|||||||
# Video signatures
|
# Video signatures
|
||||||
if header[:4] == b'\x1a\x45\xdf\xa3': # WebM/MKV
|
if header[:4] == b'\x1a\x45\xdf\xa3': # WebM/MKV
|
||||||
return "video"
|
return "video"
|
||||||
if len(header) > 8 and header[4:8] == b'ftyp': # MP4/MOV
|
if len(header) > 8 and header[4:8] == b'ftyp': # MP4/MOV/M4A
|
||||||
|
# Check for audio-only M4A
|
||||||
|
if len(header) > 11 and header[8:12] in (b'M4A ', b'm4a '):
|
||||||
|
return "audio"
|
||||||
return "video"
|
return "video"
|
||||||
if header[:4] == b'RIFF' and len(header) > 12 and header[8:12] == b'AVI ': # AVI
|
if header[:4] == b'RIFF' and len(header) > 12 and header[8:12] == b'AVI ': # AVI
|
||||||
return "video"
|
return "video"
|
||||||
@@ -65,6 +68,16 @@ def detect_media_type(cache_path: Path) -> str:
|
|||||||
if header[:4] == b'RIFF' and len(header) > 12 and header[8:12] == b'WEBP': # WebP
|
if header[:4] == b'RIFF' and len(header) > 12 and header[8:12] == b'WEBP': # WebP
|
||||||
return "image"
|
return "image"
|
||||||
|
|
||||||
|
# Audio signatures
|
||||||
|
if header[:3] == b'ID3' or header[:2] == b'\xff\xfb': # MP3
|
||||||
|
return "audio"
|
||||||
|
if header[:4] == b'fLaC': # FLAC
|
||||||
|
return "audio"
|
||||||
|
if header[:4] == b'OggS': # Ogg (could be audio or video, assume audio)
|
||||||
|
return "audio"
|
||||||
|
if header[:4] == b'RIFF' and len(header) > 12 and header[8:12] == b'WAVE': # WAV
|
||||||
|
return "audio"
|
||||||
|
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<span class="text-gray-500 text-sm">{{ run.created_at }}</span>
|
<span class="text-gray-500 text-sm">{{ run.created_at }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center space-x-4 text-sm">
|
<div class="flex items-center space-x-4 text-sm">
|
||||||
<span class="text-gray-400">
|
<span class="text-gray-400">
|
||||||
Recipe: <span class="text-white">{{ run.recipe_name or (run.recipe[:12] ~ '...' if run.recipe and run.recipe|length > 12 else run.recipe) or 'Unknown' }}</span>
|
Recipe: <span class="text-white">{{ run.recipe_name or (run.recipe[:12] ~ '...' if run.recipe and run.recipe|length > 12 else run.recipe) or 'Unknown' }}</span>
|
||||||
@@ -34,15 +34,56 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Media previews row #}
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
{# Input previews #}
|
||||||
|
{% if run.input_previews %}
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<span class="text-xs text-gray-500 mr-1">In:</span>
|
||||||
|
{% for inp in run.input_previews %}
|
||||||
|
{% if inp.media_type and inp.media_type.startswith('image/') %}
|
||||||
|
<img src="/cache/{{ inp.hash }}/raw" alt="" class="w-10 h-10 object-cover rounded">
|
||||||
|
{% elif inp.media_type and inp.media_type.startswith('video/') %}
|
||||||
|
<video src="/cache/{{ inp.hash }}/raw" class="w-10 h-10 object-cover rounded" muted></video>
|
||||||
|
{% else %}
|
||||||
|
<div class="w-10 h-10 bg-gray-700 rounded flex items-center justify-center text-gray-500 text-xs">?</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if run.inputs and run.inputs|length > 3 %}
|
||||||
|
<span class="text-xs text-gray-500">+{{ run.inputs|length - 3 }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif run.inputs %}
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{{ run.inputs|length }} input(s)
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Arrow #}
|
||||||
|
<span class="text-gray-600">-></span>
|
||||||
|
|
||||||
|
{# Output preview #}
|
||||||
|
{% if run.output_hash %}
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<span class="text-xs text-gray-500 mr-1">Out:</span>
|
||||||
|
{% if run.output_media_type and run.output_media_type.startswith('image/') %}
|
||||||
|
<img src="/cache/{{ run.output_hash }}/raw" alt="" class="w-10 h-10 object-cover rounded">
|
||||||
|
{% elif run.output_media_type and run.output_media_type.startswith('video/') %}
|
||||||
|
<video src="/cache/{{ run.output_hash }}/raw" class="w-10 h-10 object-cover rounded" muted></video>
|
||||||
|
{% else %}
|
||||||
|
<div class="w-10 h-10 bg-gray-700 rounded flex items-center justify-center text-gray-500 text-xs">?</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-gray-500">No output yet</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex-grow"></div>
|
||||||
|
|
||||||
{% if run.output_hash %}
|
{% if run.output_hash %}
|
||||||
<span class="font-mono text-xs text-gray-500">{{ run.output_hash[:16] }}...</span>
|
<span class="font-mono text-xs text-gray-600">{{ run.output_hash[:12] }}...</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if run.inputs %}
|
|
||||||
<div class="mt-2 text-xs text-gray-500">
|
|
||||||
Inputs: {{ run.inputs | length }} file(s)
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -351,8 +351,29 @@
|
|||||||
{% if run.output_hash %}
|
{% if run.output_hash %}
|
||||||
<div class="mt-8 bg-gray-800 rounded-lg p-6">
|
<div class="mt-8 bg-gray-800 rounded-lg p-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">Output</h3>
|
<h3 class="text-lg font-semibold mb-4">Output</h3>
|
||||||
|
|
||||||
|
{# Inline media preview #}
|
||||||
|
<div class="mb-4">
|
||||||
|
{% if output_media_type and output_media_type.startswith('image/') %}
|
||||||
|
<a href="/cache/{{ run.output_hash }}" class="block">
|
||||||
|
<img src="/cache/{{ run.output_hash }}/raw" alt="Output"
|
||||||
|
class="max-w-full max-h-96 rounded-lg mx-auto">
|
||||||
|
</a>
|
||||||
|
{% elif output_media_type and output_media_type.startswith('video/') %}
|
||||||
|
<video src="/cache/{{ run.output_hash }}/raw" controls
|
||||||
|
class="max-w-full max-h-96 rounded-lg mx-auto"></video>
|
||||||
|
{% elif output_media_type and output_media_type.startswith('audio/') %}
|
||||||
|
<audio src="/cache/{{ run.output_hash }}/raw" 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">
|
<div class="flex items-center justify-between">
|
||||||
<a href="/cache/{{ run.output_hash }}" class="font-mono text-blue-400 hover:text-blue-300">
|
<a href="/cache/{{ run.output_hash }}" class="font-mono text-sm text-blue-400 hover:text-blue-300">
|
||||||
{{ run.output_hash }}
|
{{ run.output_hash }}
|
||||||
</a>
|
</a>
|
||||||
{% if run.output_ipfs_cid %}
|
{% if run.output_ipfs_cid %}
|
||||||
@@ -394,6 +415,8 @@ document.querySelectorAll('.step-item').forEach(el => {
|
|||||||
config: JSON.parse(el.dataset.stepConfig || '{}')
|
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 cy = null;
|
||||||
let selectedNode = null;
|
let selectedNode = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user