Fix completed runs not appearing in list + add purge-failed endpoint
- Update save_run_cache to also update actor_id, recipe, inputs on conflict - Add logging for actor_id when saving runs to run_cache - Add admin endpoint DELETE /runs/admin/purge-failed to delete all failed runs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -208,13 +208,95 @@ async def import_from_ipfs(
|
||||
return {"cid": cid, "imported": True}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_content(
|
||||
file: UploadFile = File(...),
|
||||
@router.post("/upload/chunk")
|
||||
async def upload_chunk(
|
||||
request: Request,
|
||||
chunk: UploadFile = File(...),
|
||||
upload_id: str = Form(...),
|
||||
chunk_index: int = Form(...),
|
||||
total_chunks: int = Form(...),
|
||||
filename: str = Form(...),
|
||||
display_name: Optional[str] = Form(None),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Upload content to cache and IPFS."""
|
||||
"""Upload a file chunk. Assembles file when all chunks received."""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Create temp dir for this upload
|
||||
chunk_dir = Path(tempfile.gettempdir()) / "uploads" / upload_id
|
||||
chunk_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save this chunk
|
||||
chunk_path = chunk_dir / f"chunk_{chunk_index:05d}"
|
||||
chunk_data = await chunk.read()
|
||||
chunk_path.write_bytes(chunk_data)
|
||||
|
||||
# Check if all chunks received
|
||||
received = len(list(chunk_dir.glob("chunk_*")))
|
||||
|
||||
if received < total_chunks:
|
||||
return {"status": "partial", "received": received, "total": total_chunks}
|
||||
|
||||
# All chunks received - assemble file
|
||||
final_path = chunk_dir / filename
|
||||
with open(final_path, 'wb') as f:
|
||||
for i in range(total_chunks):
|
||||
cp = chunk_dir / f"chunk_{i:05d}"
|
||||
f.write(cp.read_bytes())
|
||||
cp.unlink() # Clean up chunk
|
||||
|
||||
# Read assembled file
|
||||
content = final_path.read_bytes()
|
||||
final_path.unlink()
|
||||
chunk_dir.rmdir()
|
||||
|
||||
# Now do the normal upload flow
|
||||
cid, ipfs_cid, error = await cache_service.upload_content(
|
||||
content=content,
|
||||
filename=filename,
|
||||
actor_id=ctx.actor_id,
|
||||
)
|
||||
|
||||
if error:
|
||||
raise HTTPException(400, error)
|
||||
|
||||
# Assign friendly name
|
||||
final_cid = ipfs_cid or cid
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly_entry = await naming.assign_name(
|
||||
cid=final_cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="media",
|
||||
display_name=display_name,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "complete",
|
||||
"cid": final_cid,
|
||||
"friendly_name": friendly_entry["friendly_name"],
|
||||
"filename": filename,
|
||||
"size": len(content),
|
||||
"uploaded": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_content(
|
||||
file: UploadFile = File(...),
|
||||
display_name: Optional[str] = Form(None),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Upload content to cache and IPFS.
|
||||
|
||||
Args:
|
||||
file: The file to upload
|
||||
display_name: Optional custom name for the media (used as friendly name)
|
||||
"""
|
||||
content = await file.read()
|
||||
cid, ipfs_cid, error = await cache_service.upload_content(
|
||||
content=content,
|
||||
@@ -233,6 +315,7 @@ async def upload_content(
|
||||
cid=final_cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="media",
|
||||
display_name=display_name, # Use custom name if provided
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
@@ -350,3 +433,83 @@ async def update_metadata_htmx(
|
||||
<div class="text-green-400 mb-4">Metadata saved!</div>
|
||||
<script>setTimeout(() => location.reload(), 1000);</script>
|
||||
''')
|
||||
|
||||
|
||||
# Friendly name editing
|
||||
@router.get("/{cid}/name-form", response_class=HTMLResponse)
|
||||
async def get_name_form(
|
||||
cid: str,
|
||||
request: Request,
|
||||
cache_service: CacheService = Depends(get_cache_service),
|
||||
):
|
||||
"""Get friendly name editing form (HTMX)."""
|
||||
ctx = await get_current_user(request)
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
||||
|
||||
# Get current friendly name
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
entry = await naming.get_by_cid(ctx.actor_id, cid)
|
||||
current_name = entry.get("base_name", "") if entry else ""
|
||||
|
||||
return HTMLResponse(f'''
|
||||
<form hx-post="/cache/{cid}/name"
|
||||
hx-target="#friendly-name-section"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Friendly Name</label>
|
||||
<input type="text" name="display_name" value="{current_name}"
|
||||
placeholder="e.g., my-background-video"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
<p class="text-gray-500 text-xs mt-1">A name to reference this media in recipes</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button type="submit"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Save
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="location.reload()"
|
||||
class="px-4 py-2 rounded border border-gray-600 hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
''')
|
||||
|
||||
|
||||
@router.post("/{cid}/name", response_class=HTMLResponse)
|
||||
async def update_friendly_name(
|
||||
cid: str,
|
||||
request: Request,
|
||||
):
|
||||
"""Update friendly name (HTMX form handler)."""
|
||||
ctx = await get_current_user(request)
|
||||
if not ctx:
|
||||
return HTMLResponse('<div class="text-red-400">Login required</div>')
|
||||
|
||||
form_data = await request.form()
|
||||
display_name = form_data.get("display_name", "").strip()
|
||||
|
||||
if not display_name:
|
||||
return HTMLResponse('<div class="text-red-400">Name cannot be empty</div>')
|
||||
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
|
||||
try:
|
||||
entry = await naming.assign_name(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="media",
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
return HTMLResponse(f'''
|
||||
<div class="text-green-400 mb-2">Name updated!</div>
|
||||
<script>setTimeout(() => location.reload(), 1000);</script>
|
||||
''')
|
||||
except Exception as e:
|
||||
return HTMLResponse(f'<div class="text-red-400">Error: {e}</div>')
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
Effects routes for L1 server.
|
||||
|
||||
Handles effect upload, listing, and metadata.
|
||||
Effects are stored in IPFS like all other content-addressed data.
|
||||
Effects are S-expression files stored in IPFS like all other content-addressed data.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
|
||||
from artdag_common import render
|
||||
@@ -40,12 +40,11 @@ def get_effects_dir() -> Path:
|
||||
|
||||
def parse_effect_metadata(source: str) -> dict:
|
||||
"""
|
||||
Parse effect metadata from source code.
|
||||
Parse effect metadata from S-expression source code.
|
||||
|
||||
Extracts PEP 723 dependencies and @-tag metadata from docstring.
|
||||
Extracts metadata from comment headers (;; @key value format)
|
||||
or from (defeffect name ...) form.
|
||||
"""
|
||||
import re
|
||||
|
||||
metadata = {
|
||||
"name": "",
|
||||
"version": "1.0.0",
|
||||
@@ -53,97 +52,54 @@ def parse_effect_metadata(source: str) -> dict:
|
||||
"temporal": False,
|
||||
"description": "",
|
||||
"params": [],
|
||||
"dependencies": [],
|
||||
"requires_python": ">=3.10",
|
||||
}
|
||||
|
||||
# Parse PEP 723 dependencies
|
||||
pep723_match = re.search(r"# /// script\n(.*?)# ///", source, re.DOTALL)
|
||||
if pep723_match:
|
||||
block = pep723_match.group(1)
|
||||
deps_match = re.search(r'# dependencies = \[(.*?)\]', block, re.DOTALL)
|
||||
if deps_match:
|
||||
metadata["dependencies"] = re.findall(r'"([^"]+)"', deps_match.group(1))
|
||||
python_match = re.search(r'# requires-python = "([^"]+)"', block)
|
||||
if python_match:
|
||||
metadata["requires_python"] = python_match.group(1)
|
||||
# Parse comment-based metadata (;; @key value)
|
||||
for line in source.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith(";"):
|
||||
# Stop parsing metadata at first non-comment line
|
||||
if stripped and not stripped.startswith("("):
|
||||
continue
|
||||
if stripped.startswith("("):
|
||||
break
|
||||
|
||||
# Parse docstring @-tags
|
||||
docstring_match = re.search(r'"""(.*?)"""', source, re.DOTALL)
|
||||
if not docstring_match:
|
||||
docstring_match = re.search(r"'''(.*?)'''", source, re.DOTALL)
|
||||
# Remove comment prefix
|
||||
comment = stripped.lstrip(";").strip()
|
||||
|
||||
if docstring_match:
|
||||
docstring = docstring_match.group(1)
|
||||
lines = docstring.split("\n")
|
||||
if comment.startswith("@effect "):
|
||||
metadata["name"] = comment[8:].strip()
|
||||
elif comment.startswith("@name "):
|
||||
metadata["name"] = comment[6:].strip()
|
||||
elif comment.startswith("@version "):
|
||||
metadata["version"] = comment[9:].strip()
|
||||
elif comment.startswith("@author "):
|
||||
metadata["author"] = comment[8:].strip()
|
||||
elif comment.startswith("@temporal"):
|
||||
val = comment[9:].strip().lower() if len(comment) > 9 else "true"
|
||||
metadata["temporal"] = val in ("true", "yes", "1", "")
|
||||
elif comment.startswith("@description "):
|
||||
metadata["description"] = comment[13:].strip()
|
||||
elif comment.startswith("@param "):
|
||||
# Format: @param name type [description]
|
||||
parts = comment[7:].split(None, 2)
|
||||
if len(parts) >= 2:
|
||||
param = {"name": parts[0], "type": parts[1]}
|
||||
if len(parts) > 2:
|
||||
param["description"] = parts[2]
|
||||
metadata["params"].append(param)
|
||||
|
||||
current_param = None
|
||||
desc_lines = []
|
||||
in_description = False
|
||||
# Also try to extract name from (defeffect "name" ...) or (effect "name" ...)
|
||||
if not metadata["name"]:
|
||||
name_match = re.search(r'\((defeffect|effect)\s+"([^"]+)"', source)
|
||||
if name_match:
|
||||
metadata["name"] = name_match.group(2)
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith("@effect "):
|
||||
metadata["name"] = stripped[8:].strip()
|
||||
in_description = False
|
||||
|
||||
elif stripped.startswith("@version "):
|
||||
metadata["version"] = stripped[9:].strip()
|
||||
|
||||
elif stripped.startswith("@author "):
|
||||
metadata["author"] = stripped[8:].strip()
|
||||
|
||||
elif stripped.startswith("@temporal "):
|
||||
val = stripped[10:].strip().lower()
|
||||
metadata["temporal"] = val in ("true", "yes", "1")
|
||||
|
||||
elif stripped.startswith("@description"):
|
||||
in_description = True
|
||||
desc_lines = []
|
||||
|
||||
elif stripped.startswith("@param "):
|
||||
in_description = False
|
||||
if current_param:
|
||||
metadata["params"].append(current_param)
|
||||
parts = stripped[7:].split()
|
||||
if len(parts) >= 2:
|
||||
current_param = {
|
||||
"name": parts[0],
|
||||
"type": parts[1],
|
||||
"description": "",
|
||||
}
|
||||
else:
|
||||
current_param = None
|
||||
|
||||
elif stripped.startswith("@range ") and current_param:
|
||||
range_parts = stripped[7:].split()
|
||||
if len(range_parts) >= 2:
|
||||
try:
|
||||
current_param["range"] = [float(range_parts[0]), float(range_parts[1])]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif stripped.startswith("@default ") and current_param:
|
||||
current_param["default"] = stripped[9:].strip()
|
||||
|
||||
elif stripped.startswith("@example"):
|
||||
in_description = False
|
||||
if current_param:
|
||||
metadata["params"].append(current_param)
|
||||
current_param = None
|
||||
|
||||
elif in_description and stripped:
|
||||
desc_lines.append(stripped)
|
||||
|
||||
elif current_param and stripped and not stripped.startswith("@"):
|
||||
current_param["description"] = stripped
|
||||
|
||||
if in_description:
|
||||
metadata["description"] = " ".join(desc_lines)
|
||||
|
||||
if current_param:
|
||||
metadata["params"].append(current_param)
|
||||
# Try to extract name from first (define ...) form
|
||||
if not metadata["name"]:
|
||||
define_match = re.search(r'\(define\s+(\w+)', source)
|
||||
if define_match:
|
||||
metadata["name"] = define_match.group(1)
|
||||
|
||||
return metadata
|
||||
|
||||
@@ -151,13 +107,18 @@ def parse_effect_metadata(source: str) -> dict:
|
||||
@router.post("/upload")
|
||||
async def upload_effect(
|
||||
file: UploadFile = File(...),
|
||||
display_name: Optional[str] = Form(None),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Upload an effect to IPFS.
|
||||
Upload an S-expression effect to IPFS.
|
||||
|
||||
Parses PEP 723 metadata and @-tag docstring.
|
||||
Parses metadata from comment headers.
|
||||
Returns IPFS CID for use in recipes.
|
||||
|
||||
Args:
|
||||
file: The .sexp effect file
|
||||
display_name: Optional custom friendly name for the effect
|
||||
"""
|
||||
content = await file.read()
|
||||
|
||||
@@ -166,7 +127,7 @@ async def upload_effect(
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(400, "Effect must be valid UTF-8 text")
|
||||
|
||||
# Parse metadata
|
||||
# Parse metadata from sexp source
|
||||
try:
|
||||
meta = parse_effect_metadata(source)
|
||||
except Exception as e:
|
||||
@@ -185,7 +146,7 @@ async def upload_effect(
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
effect_dir.mkdir(parents=True, exist_ok=True)
|
||||
(effect_dir / "effect.py").write_text(source, encoding="utf-8")
|
||||
(effect_dir / "effect.sexp").write_text(source, encoding="utf-8")
|
||||
|
||||
# Store metadata (locally and in IPFS)
|
||||
full_meta = {
|
||||
@@ -209,14 +170,14 @@ async def upload_effect(
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
# Assign friendly name
|
||||
# Assign friendly name (use custom display_name if provided, else from metadata)
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly_entry = await naming.assign_name(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="effect",
|
||||
display_name=meta.get("name"),
|
||||
display_name=display_name or meta.get("name"),
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
@@ -230,7 +191,6 @@ async def upload_effect(
|
||||
"version": meta.get("version"),
|
||||
"temporal": meta.get("temporal", False),
|
||||
"params": meta.get("params", []),
|
||||
"dependencies": meta.get("dependencies", []),
|
||||
"uploaded": True,
|
||||
}
|
||||
|
||||
@@ -258,7 +218,7 @@ async def get_effect(
|
||||
# Cache locally
|
||||
effect_dir.mkdir(parents=True, exist_ok=True)
|
||||
source = source_bytes.decode("utf-8")
|
||||
(effect_dir / "effect.py").write_text(source)
|
||||
(effect_dir / "effect.sexp").write_text(source)
|
||||
|
||||
# Parse metadata from source
|
||||
parsed_meta = parse_effect_metadata(source)
|
||||
@@ -297,12 +257,16 @@ async def get_effect_source(
|
||||
):
|
||||
"""Get effect source code."""
|
||||
effects_dir = get_effects_dir()
|
||||
source_path = effects_dir / cid / "effect.py"
|
||||
source_path = effects_dir / cid / "effect.sexp"
|
||||
|
||||
# Try local cache first
|
||||
# Try local cache first (check both .sexp and legacy .py)
|
||||
if source_path.exists():
|
||||
return PlainTextResponse(source_path.read_text())
|
||||
|
||||
legacy_path = effects_dir / cid / "effect.py"
|
||||
if legacy_path.exists():
|
||||
return PlainTextResponse(legacy_path.read_text())
|
||||
|
||||
# Fetch from IPFS
|
||||
source_bytes = ipfs_client.get_bytes(cid)
|
||||
if not source_bytes:
|
||||
|
||||
@@ -156,7 +156,6 @@ async def create_run(
|
||||
async def create_stream_run(
|
||||
request: StreamRequest,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
redis = Depends(get_redis_client),
|
||||
):
|
||||
"""Start a streaming video render.
|
||||
|
||||
@@ -166,13 +165,57 @@ async def create_stream_run(
|
||||
Assets can be referenced by CID or friendly name in the recipe.
|
||||
"""
|
||||
import uuid
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import database
|
||||
from tasks.streaming import run_stream
|
||||
|
||||
# Generate run ID
|
||||
run_id = str(uuid.uuid4())
|
||||
created_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Store recipe in cache so it appears on /recipes page
|
||||
recipe_id = None
|
||||
try:
|
||||
cache_manager = get_cache_manager()
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".sexp", mode="w") as tmp:
|
||||
tmp.write(request.recipe_sexp)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
cached, ipfs_cid = cache_manager.put(tmp_path, node_type="recipe", move=True)
|
||||
recipe_id = cached.cid
|
||||
|
||||
# Extract recipe name from S-expression (look for (stream "name" ...) pattern)
|
||||
import re
|
||||
name_match = re.search(r'\(stream\s+"([^"]+)"', request.recipe_sexp)
|
||||
recipe_name = name_match.group(1) if name_match else f"stream-{run_id[:8]}"
|
||||
|
||||
# Track ownership in item_types
|
||||
await database.save_item_metadata(
|
||||
cid=recipe_id,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="recipe",
|
||||
description=f"Streaming recipe: {recipe_name}",
|
||||
filename=f"{recipe_name}.sexp",
|
||||
)
|
||||
|
||||
# Assign friendly name
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
await naming.assign_name(
|
||||
cid=recipe_id,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="recipe",
|
||||
display_name=recipe_name,
|
||||
)
|
||||
|
||||
logger.info(f"Stored streaming recipe {recipe_id[:16]}... as '{recipe_name}'")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store recipe in cache: {e}")
|
||||
# Continue anyway - run will still work, just won't appear in /recipes
|
||||
|
||||
# Submit Celery task
|
||||
task = run_stream.delay(
|
||||
run_id=run_id,
|
||||
recipe_sexp=request.recipe_sexp,
|
||||
output_name=request.output_name,
|
||||
duration=request.duration,
|
||||
@@ -182,21 +225,15 @@ async def create_stream_run(
|
||||
audio_sexp=request.audio_sexp,
|
||||
)
|
||||
|
||||
# Store run metadata in Redis
|
||||
run_data = {
|
||||
"run_id": run_id,
|
||||
"status": "pending",
|
||||
"recipe": "streaming",
|
||||
"actor_id": ctx.actor_id,
|
||||
"created_at": created_at,
|
||||
"celery_task_id": task.id,
|
||||
"output_name": request.output_name,
|
||||
}
|
||||
|
||||
await redis.set(
|
||||
f"{RUNS_KEY_PREFIX}{run_id}",
|
||||
json.dumps(run_data),
|
||||
ex=86400 * 7 # 7 days
|
||||
# Store in database for durability
|
||||
pending = await database.create_pending_run(
|
||||
run_id=run_id,
|
||||
celery_task_id=task.id,
|
||||
recipe=recipe_id or "streaming", # Use recipe CID if available
|
||||
inputs=[], # Streaming recipes don't have traditional inputs
|
||||
actor_id=ctx.actor_id,
|
||||
dag_json=request.recipe_sexp, # Store recipe content for viewing
|
||||
output_name=request.output_name,
|
||||
)
|
||||
|
||||
logger.info(f"Started stream run {run_id} with task {task.id}")
|
||||
@@ -204,8 +241,8 @@ async def create_stream_run(
|
||||
return RunStatus(
|
||||
run_id=run_id,
|
||||
status="pending",
|
||||
recipe="streaming",
|
||||
created_at=created_at,
|
||||
recipe=recipe_id or "streaming",
|
||||
created_at=pending.get("created_at"),
|
||||
celery_task_id=task.id,
|
||||
)
|
||||
|
||||
@@ -305,6 +342,32 @@ async def get_run(
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load recipe for plan: {e}")
|
||||
|
||||
# Handle streaming runs - detect by recipe_sexp content or legacy "streaming" marker
|
||||
recipe_sexp_content = run.get("recipe_sexp")
|
||||
is_streaming = run.get("recipe") == "streaming" # Legacy marker
|
||||
if not is_streaming and recipe_sexp_content:
|
||||
# Check if content starts with (stream after skipping comments
|
||||
for line in recipe_sexp_content.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(';'):
|
||||
continue
|
||||
is_streaming = stripped.startswith('(stream')
|
||||
break
|
||||
if is_streaming and recipe_sexp_content and not plan:
|
||||
plan_sexp = recipe_sexp_content
|
||||
plan = {
|
||||
"steps": [{
|
||||
"id": "stream",
|
||||
"type": "STREAM",
|
||||
"name": "Streaming Recipe",
|
||||
"inputs": [],
|
||||
"config": {},
|
||||
"status": "completed" if run.get("status") == "completed" else "pending",
|
||||
}]
|
||||
}
|
||||
run["total_steps"] = 1
|
||||
run["executed"] = 1 if run.get("status") == "completed" else 0
|
||||
|
||||
# Helper to convert simple type to MIME type prefix for template
|
||||
def type_to_mime(simple_type: str) -> str:
|
||||
if simple_type == "video":
|
||||
@@ -564,10 +627,14 @@ async def run_detail(
|
||||
"analysis": analysis,
|
||||
}
|
||||
|
||||
# Extract plan_sexp for streaming runs
|
||||
plan_sexp = plan.get("sexp") if plan else None
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "runs/detail.html", request,
|
||||
run=run,
|
||||
plan=plan,
|
||||
plan_sexp=plan_sexp,
|
||||
artifacts=artifacts,
|
||||
analysis=analysis,
|
||||
dag_elements=dag_elements,
|
||||
@@ -824,3 +891,26 @@ async def publish_run(
|
||||
return HTMLResponse(f'<span class="text-green-400">Shared: {ipfs_cid[:16]}...</span>')
|
||||
|
||||
return {"ipfs_cid": ipfs_cid, "output_cid": output_cid, "published": True}
|
||||
|
||||
|
||||
@router.delete("/admin/purge-failed")
|
||||
async def purge_failed_runs(
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Delete all failed runs from pending_runs table."""
|
||||
import database
|
||||
|
||||
# Get all failed runs
|
||||
failed_runs = await database.list_pending_runs(status="failed")
|
||||
|
||||
deleted = []
|
||||
for run in failed_runs:
|
||||
run_id = run.get("run_id")
|
||||
try:
|
||||
await database.delete_pending_run(run_id)
|
||||
deleted.append(run_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete run {run_id}: {e}")
|
||||
|
||||
logger.info(f"Purged {len(deleted)} failed runs")
|
||||
return {"purged": len(deleted), "run_ids": deleted}
|
||||
|
||||
@@ -4,6 +4,7 @@ Cache Service - business logic for cache and media management.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -11,6 +12,8 @@ from typing import Optional, List, Dict, Any, Tuple, TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from database import Database
|
||||
from cache_manager import L1CacheManager
|
||||
@@ -513,7 +516,11 @@ class CacheService:
|
||||
filename: str,
|
||||
actor_id: str,
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Upload content to cache. Returns (cid, ipfs_cid, error)."""
|
||||
"""Upload content to cache. Returns (cid, ipfs_cid, error).
|
||||
|
||||
Files are stored locally first for fast response, then uploaded
|
||||
to IPFS in the background.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
@@ -525,21 +532,28 @@ class CacheService:
|
||||
# Detect media type (video/image/audio) before moving file
|
||||
media_type = detect_media_type(tmp_path)
|
||||
|
||||
# Store in cache (also stores in IPFS)
|
||||
cached, ipfs_cid = self.cache.put(tmp_path, node_type="upload", move=True)
|
||||
cid = ipfs_cid or cached.cid # Prefer IPFS CID
|
||||
# Store locally first (skip_ipfs=True for fast response)
|
||||
# IPFS upload happens in background
|
||||
cached, ipfs_cid = self.cache.put(tmp_path, node_type="upload", move=True, skip_ipfs=True)
|
||||
cid = cached.cid # Use local hash since we skipped IPFS
|
||||
|
||||
# Save to database with media category type
|
||||
# Using media_type ("video", "image", "audio") not mime_type ("video/mp4")
|
||||
# so list_media filtering works correctly
|
||||
await self.db.create_cache_item(cid, ipfs_cid)
|
||||
await self.db.create_cache_item(cid, ipfs_cid) # ipfs_cid is None initially
|
||||
await self.db.save_item_metadata(
|
||||
cid=cid,
|
||||
actor_id=actor_id,
|
||||
item_type=media_type, # Store media category for filtering
|
||||
item_type=media_type,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
# Queue background IPFS upload
|
||||
try:
|
||||
from tasks.ipfs_upload import upload_to_ipfs
|
||||
upload_to_ipfs.delay(cid, actor_id)
|
||||
logger.info(f"Queued background IPFS upload for {cid[:16]}...")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to queue IPFS upload (will retry manually): {e}")
|
||||
|
||||
return cid, ipfs_cid, None
|
||||
except Exception as e:
|
||||
return None, None, f"Upload failed: {e}"
|
||||
|
||||
@@ -60,16 +60,40 @@ class RecipeService:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if is_sexp_format(content):
|
||||
# Parse S-expression
|
||||
try:
|
||||
compiled = compile_string(content)
|
||||
recipe_data = compiled.to_dict()
|
||||
recipe_data["sexp"] = content
|
||||
recipe_data["format"] = "sexp"
|
||||
logger.info(f"Parsed sexp recipe {recipe_id[:16]}..., keys: {list(recipe_data.keys())}")
|
||||
except (ParseError, CompileError) as e:
|
||||
logger.warning(f"Failed to parse sexp recipe {recipe_id[:16]}...: {e}")
|
||||
return {"error": str(e), "recipe_id": recipe_id}
|
||||
# Detect if this is a streaming recipe (starts with (stream ...))
|
||||
def is_streaming_recipe(text):
|
||||
for line in text.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(';'):
|
||||
continue
|
||||
return stripped.startswith('(stream')
|
||||
return False
|
||||
|
||||
if is_streaming_recipe(content):
|
||||
# Streaming recipes have different format - parse manually
|
||||
import re
|
||||
name_match = re.search(r'\(stream\s+"([^"]+)"', content)
|
||||
recipe_name = name_match.group(1) if name_match else "streaming"
|
||||
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"sexp": content,
|
||||
"format": "sexp",
|
||||
"type": "streaming",
|
||||
"dag": {"nodes": []}, # Streaming recipes don't have traditional DAG
|
||||
}
|
||||
logger.info(f"Parsed streaming recipe {recipe_id[:16]}..., name: {recipe_name}")
|
||||
else:
|
||||
# Parse traditional (recipe ...) S-expression
|
||||
try:
|
||||
compiled = compile_string(content)
|
||||
recipe_data = compiled.to_dict()
|
||||
recipe_data["sexp"] = content
|
||||
recipe_data["format"] = "sexp"
|
||||
logger.info(f"Parsed sexp recipe {recipe_id[:16]}..., keys: {list(recipe_data.keys())}")
|
||||
except (ParseError, CompileError) as e:
|
||||
logger.warning(f"Failed to parse sexp recipe {recipe_id[:16]}...: {e}")
|
||||
return {"error": str(e), "recipe_id": recipe_id}
|
||||
else:
|
||||
# Parse YAML
|
||||
try:
|
||||
|
||||
@@ -128,10 +128,25 @@ class RunService:
|
||||
# Only return as completed if we have an output
|
||||
# (runs with no output should be re-executed)
|
||||
if output_cid:
|
||||
# Also fetch recipe content from pending_runs for streaming runs
|
||||
recipe_sexp = None
|
||||
recipe_name = None
|
||||
pending = await self.db.get_pending_run(run_id)
|
||||
if pending:
|
||||
recipe_sexp = pending.get("dag_json")
|
||||
|
||||
# Extract recipe name from streaming recipe content
|
||||
if recipe_sexp:
|
||||
import re
|
||||
name_match = re.search(r'\(stream\s+"([^"]+)"', recipe_sexp)
|
||||
if name_match:
|
||||
recipe_name = name_match.group(1)
|
||||
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"status": "completed",
|
||||
"recipe": cached.get("recipe"),
|
||||
"recipe_name": recipe_name,
|
||||
"inputs": self._ensure_inputs_list(cached.get("inputs")),
|
||||
"output_cid": output_cid,
|
||||
"ipfs_cid": cached.get("ipfs_cid"),
|
||||
@@ -140,6 +155,7 @@ class RunService:
|
||||
"actor_id": cached.get("actor_id"),
|
||||
"created_at": cached.get("created_at"),
|
||||
"completed_at": cached.get("created_at"),
|
||||
"recipe_sexp": recipe_sexp,
|
||||
}
|
||||
|
||||
# Check database for pending run
|
||||
@@ -175,6 +191,7 @@ class RunService:
|
||||
"output_name": pending.get("output_name"),
|
||||
"created_at": pending.get("created_at"),
|
||||
"error": pending.get("error"),
|
||||
"recipe_sexp": pending.get("dag_json"), # Recipe content for streaming runs
|
||||
}
|
||||
|
||||
# If task completed, get result
|
||||
@@ -209,6 +226,7 @@ class RunService:
|
||||
"actor_id": pending.get("actor_id"),
|
||||
"created_at": pending.get("created_at"),
|
||||
"error": pending.get("error"),
|
||||
"recipe_sexp": pending.get("dag_json"), # Recipe content for streaming runs
|
||||
}
|
||||
|
||||
# Fallback: Check Redis for backwards compatibility
|
||||
@@ -714,12 +732,21 @@ class RunService:
|
||||
"""Get execution plan for a run.
|
||||
|
||||
Plans are just node outputs - cached by content hash like everything else.
|
||||
For streaming runs, returns the recipe content as the plan.
|
||||
"""
|
||||
# Get run to find plan_cache_id
|
||||
run = await self.get_run(run_id)
|
||||
if not run:
|
||||
return None
|
||||
|
||||
# For streaming runs, return the recipe as the plan
|
||||
if run.get("recipe") == "streaming" and run.get("recipe_sexp"):
|
||||
return {
|
||||
"steps": [{"id": "stream", "type": "STREAM", "name": "Streaming Recipe"}],
|
||||
"sexp": run.get("recipe_sexp"),
|
||||
"format": "sexp",
|
||||
}
|
||||
|
||||
# Check plan_cid (stored in database) or plan_cache_id (legacy)
|
||||
plan_cid = run.get("plan_cid") or run.get("plan_cache_id")
|
||||
if plan_cid:
|
||||
|
||||
20
app/templates/cache/detail.html
vendored
20
app/templates/cache/detail.html
vendored
@@ -40,15 +40,23 @@
|
||||
</div>
|
||||
|
||||
<!-- Friendly Name -->
|
||||
{% if cache.friendly_name %}
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
|
||||
<div class="mb-2">
|
||||
<div id="friendly-name-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-gray-500 text-sm">Friendly Name</span>
|
||||
<p class="text-blue-400 font-medium text-lg mt-1">{{ cache.friendly_name }}</p>
|
||||
<button hx-get="/cache/{{ cache.cid }}/name-form"
|
||||
hx-target="#friendly-name-section"
|
||||
hx-swap="innerHTML"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-500 text-xs">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">{{ cache.base_name }}</code></p>
|
||||
{% if cache.friendly_name %}
|
||||
<p class="text-blue-400 font-medium text-lg">{{ cache.friendly_name }}</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">{{ cache.base_name }}</code></p>
|
||||
{% else %}
|
||||
<p class="text-gray-500 text-sm">No friendly name assigned. Click Edit to add one.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- User Metadata (editable) -->
|
||||
<div id="metadata-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
|
||||
|
||||
206
app/templates/cache/media_list.html
vendored
206
app/templates/cache/media_list.html
vendored
@@ -7,6 +7,10 @@
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Media</h1>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Upload Media
|
||||
</button>
|
||||
<select id="type-filter" onchange="filterMedia()"
|
||||
class="bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
<option value="">All Types</option>
|
||||
@@ -17,6 +21,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div id="upload-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md border border-gray-700">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">Upload Media</h2>
|
||||
<button onclick="document.getElementById('upload-modal').classList.add('hidden')"
|
||||
class="text-gray-400 hover:text-white">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="upload-form" enctype="multipart/form-data" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Files</label>
|
||||
<input type="file" name="files" id="upload-file" required multiple
|
||||
accept="image/*,video/*,audio/*"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
||||
<p class="text-gray-500 text-xs mt-1">Select one or more files to upload</p>
|
||||
</div>
|
||||
|
||||
<div id="single-name-field">
|
||||
<label class="block text-gray-400 text-sm mb-1">Name (optional, for single file)</label>
|
||||
<input type="text" name="display_name" id="upload-name" placeholder="e.g., my-background-video"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
<p class="text-gray-500 text-xs mt-1">A friendly name to reference this media in recipes</p>
|
||||
</div>
|
||||
|
||||
<div id="upload-progress" class="hidden">
|
||||
<div class="bg-gray-700 rounded-full h-2">
|
||||
<div id="progress-bar" class="bg-blue-600 h-2 rounded-full transition-all" style="width: 0%"></div>
|
||||
</div>
|
||||
<p id="progress-text" class="text-gray-400 text-sm mt-1">Uploading...</p>
|
||||
</div>
|
||||
|
||||
<div id="upload-result" class="hidden max-h-48 overflow-y-auto"></div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="document.getElementById('upload-modal').classList.add('hidden')"
|
||||
class="px-4 py-2 rounded border border-gray-600 hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="upload-btn"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if items %}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id="media-grid">
|
||||
{% for item in items %}
|
||||
@@ -115,5 +171,155 @@ function filterMedia() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show/hide name field based on file count
|
||||
document.getElementById('upload-file').addEventListener('change', function(e) {
|
||||
const nameField = document.getElementById('single-name-field');
|
||||
if (e.target.files.length > 1) {
|
||||
nameField.style.display = 'none';
|
||||
} else {
|
||||
nameField.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Handle upload form
|
||||
document.getElementById('upload-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const fileInput = document.getElementById('upload-file');
|
||||
const files = fileInput.files;
|
||||
const displayName = document.getElementById('upload-name').value;
|
||||
const progressDiv = document.getElementById('upload-progress');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressText = document.getElementById('progress-text');
|
||||
const resultDiv = document.getElementById('upload-result');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
|
||||
// Show progress
|
||||
progressDiv.classList.remove('hidden');
|
||||
resultDiv.classList.add('hidden');
|
||||
uploadBtn.disabled = true;
|
||||
|
||||
const results = [];
|
||||
const errors = [];
|
||||
|
||||
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const uploadId = crypto.randomUUID();
|
||||
const useChunked = file.size > CHUNK_SIZE * 2; // Use chunked for files > 2MB
|
||||
|
||||
progressText.textContent = `Uploading ${i + 1} of ${files.length}: ${file.name}`;
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (useChunked && totalChunks > 1) {
|
||||
// Chunked upload for large files
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const chunkForm = new FormData();
|
||||
chunkForm.append('chunk', chunk);
|
||||
chunkForm.append('upload_id', uploadId);
|
||||
chunkForm.append('chunk_index', chunkIndex);
|
||||
chunkForm.append('total_chunks', totalChunks);
|
||||
chunkForm.append('filename', file.name);
|
||||
if (files.length === 1 && displayName) {
|
||||
chunkForm.append('display_name', displayName);
|
||||
}
|
||||
|
||||
const chunkProgress = ((i + (chunkIndex + 1) / totalChunks) / files.length) * 100;
|
||||
progressBar.style.width = `${chunkProgress}%`;
|
||||
progressText.textContent = `Uploading ${i + 1} of ${files.length}: ${file.name} (${chunkIndex + 1}/${totalChunks} chunks)`;
|
||||
|
||||
const response = await fetch('/media/upload/chunk', {
|
||||
method: 'POST',
|
||||
body: chunkForm,
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Server error (${response.status}): ${text.substring(0, 100)}`);
|
||||
}
|
||||
|
||||
data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Chunk upload failed');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular upload for small files
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (files.length === 1 && displayName) {
|
||||
formData.append('display_name', displayName);
|
||||
}
|
||||
|
||||
progressBar.style.width = `${((i + 0.5) / files.length) * 100}%`;
|
||||
|
||||
const response = await fetch('/media/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Server error (${response.status}): ${text.substring(0, 100)}`);
|
||||
}
|
||||
|
||||
data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || 'Upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ filename: file.name, friendly_name: data.friendly_name, cid: data.cid });
|
||||
} catch (err) {
|
||||
errors.push({ filename: file.name, error: err.message });
|
||||
}
|
||||
|
||||
progressBar.style.width = `${((i + 1) / files.length) * 100}%`;
|
||||
}
|
||||
|
||||
progressText.textContent = 'Upload complete!';
|
||||
|
||||
// Show results
|
||||
let html = '';
|
||||
if (results.length > 0) {
|
||||
html += '<div class="bg-green-900 border border-green-700 rounded p-3 text-green-300 mb-2">';
|
||||
html += `<p class="font-medium">${results.length} file(s) uploaded successfully!</p>`;
|
||||
for (const r of results) {
|
||||
html += `<p class="text-sm mt-1">${r.filename} → <span class="font-mono">${r.friendly_name}</span></p>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
html += '<div class="bg-red-900 border border-red-700 rounded p-3 text-red-300">';
|
||||
html += `<p class="font-medium">${errors.length} file(s) failed:</p>`;
|
||||
for (const e of errors) {
|
||||
html += `<p class="text-sm mt-1">${e.filename}: ${e.error}</p>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = html;
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (results.length > 0) {
|
||||
// Reload page after 2 seconds
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = 'Upload';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/python.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/lisp.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/scheme.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -93,35 +94,23 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Dependencies -->
|
||||
{% if meta.dependencies %}
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700">
|
||||
<div class="border-b border-gray-700 px-4 py-2">
|
||||
<span class="text-gray-400 text-sm font-medium">Dependencies</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for dep in meta.dependencies %}
|
||||
<span class="bg-gray-700 text-gray-300 px-3 py-1 rounded">{{ dep }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if meta.requires_python %}
|
||||
<p class="text-gray-500 text-sm mt-3">Python {{ meta.requires_python }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Usage in Recipe -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700">
|
||||
<div class="border-b border-gray-700 px-4 py-2">
|
||||
<span class="text-gray-400 text-sm font-medium">Usage in Recipe</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<pre class="text-sm text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto"><code class="language-lisp">(effect {{ meta.name or 'effect' }} :cid "{{ effect.cid }}")</code></pre>
|
||||
{% if effect.base_name %}
|
||||
<pre class="text-sm text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto"><code class="language-lisp">({{ effect.base_name }} ...)</code></pre>
|
||||
<p class="text-gray-500 text-xs mt-2">
|
||||
Reference this effect in your recipe S-expression.
|
||||
Use the friendly name to reference this effect.
|
||||
</p>
|
||||
{% else %}
|
||||
<pre class="text-sm text-gray-300 bg-gray-900 rounded p-3 overflow-x-auto"><code class="language-lisp">(effect :cid "{{ effect.cid }}")</code></pre>
|
||||
<p class="text-gray-500 text-xs mt-2">
|
||||
Reference this effect by CID in your recipe.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,17 +119,17 @@
|
||||
<div class="lg:col-span-2">
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700">
|
||||
<div class="border-b border-gray-700 px-4 py-2 flex items-center justify-between">
|
||||
<span class="text-gray-400 text-sm font-medium">Source Code</span>
|
||||
<span class="text-gray-400 text-sm font-medium">Source Code (S-expression)</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="/effects/{{ effect.cid }}/source"
|
||||
class="text-gray-400 hover:text-white text-sm"
|
||||
download="{{ meta.name or 'effect' }}.py">
|
||||
download="{{ meta.name or 'effect' }}.sexp">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<pre class="text-sm overflow-x-auto rounded bg-gray-900"><code class="language-python" id="source-code">Loading...</code></pre>
|
||||
<pre class="text-sm overflow-x-auto rounded bg-gray-900"><code class="language-lisp" id="source-code">Loading...</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,15 +6,59 @@
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Effects</h1>
|
||||
<label class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium cursor-pointer">
|
||||
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Upload Effect
|
||||
<input type="file" accept=".py" class="hidden" id="effect-upload" />
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div id="upload-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-gray-800 rounded-lg p-6 w-full max-w-md border border-gray-700">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">Upload Effect</h2>
|
||||
<button onclick="document.getElementById('upload-modal').classList.add('hidden')"
|
||||
class="text-gray-400 hover:text-white">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="upload-form" enctype="multipart/form-data" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Effect File (.sexp)</label>
|
||||
<input type="file" name="file" id="upload-file" required
|
||||
accept=".sexp,.lisp"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Friendly Name (optional)</label>
|
||||
<input type="text" name="display_name" id="upload-name" placeholder="e.g., color-shift"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-white">
|
||||
<p class="text-gray-500 text-xs mt-1">A name to reference this effect in recipes</p>
|
||||
</div>
|
||||
|
||||
<div id="upload-result" class="hidden"></div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="document.getElementById('upload-modal').classList.add('hidden')"
|
||||
class="px-4 py-2 rounded border border-gray-600 hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="upload-btn"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-400 mb-8">
|
||||
Effects are Python scripts that process video frames or whole videos.
|
||||
Each effect is stored in IPFS and can be referenced by CID in recipes.
|
||||
Effects are S-expression files that define video processing operations.
|
||||
Each effect is stored in IPFS and can be referenced by name in recipes.
|
||||
</p>
|
||||
|
||||
{% if effects %}
|
||||
@@ -49,17 +93,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if meta.dependencies %}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{% for dep in meta.dependencies[:3] %}
|
||||
<span class="bg-gray-700 text-gray-300 px-2 py-0.5 rounded text-xs">{{ dep }}</span>
|
||||
{% endfor %}
|
||||
{% if meta.dependencies | length > 3 %}
|
||||
<span class="text-gray-500 text-xs">+{{ meta.dependencies | length - 3 }} more</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-3 text-xs">
|
||||
{% if effect.friendly_name %}
|
||||
<span class="text-blue-400 font-medium">{{ effect.friendly_name }}</span>
|
||||
@@ -83,67 +116,85 @@
|
||||
|
||||
{% else %}
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
|
||||
</svg>
|
||||
<p class="text-gray-500 mb-4">No effects uploaded yet.</p>
|
||||
<p class="text-gray-600 text-sm mb-6">
|
||||
Effects are Python files with @effect metadata in a docstring.
|
||||
Effects are S-expression files with metadata in comment headers.
|
||||
</p>
|
||||
<label class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium cursor-pointer inline-block">
|
||||
<button onclick="document.getElementById('upload-modal').classList.remove('hidden')"
|
||||
class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium">
|
||||
Upload Your First Effect
|
||||
<input type="file" accept=".py" class="hidden" id="effect-upload-empty" />
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="upload-result" class="fixed bottom-4 right-4 max-w-sm"></div>
|
||||
|
||||
<script>
|
||||
function handleEffectUpload(input) {
|
||||
const file = input.files[0];
|
||||
// Handle upload form
|
||||
document.getElementById('upload-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.target;
|
||||
const fileInput = document.getElementById('upload-file');
|
||||
const displayName = document.getElementById('upload-name').value;
|
||||
const resultDiv = document.getElementById('upload-result');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (displayName) {
|
||||
formData.append('display_name', displayName);
|
||||
}
|
||||
|
||||
fetch('/effects/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
const resultDiv = document.getElementById('upload-result');
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = 'Uploading...';
|
||||
resultDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/effects/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-green-900 border border-green-700 rounded p-3 text-green-300">
|
||||
<p class="font-medium">Effect uploaded!</p>
|
||||
<p class="text-sm mt-1">${data.name} <span class="font-mono">${data.friendly_name}</span></p>
|
||||
</div>
|
||||
`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-red-900 border border-red-700 rounded p-3 text-red-300">
|
||||
<p class="font-medium">Upload failed</p>
|
||||
<p class="text-sm mt-1">${data.detail || 'Unknown error'}</p>
|
||||
</div>
|
||||
`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = 'Upload';
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-green-900 border border-green-700 rounded-lg p-4">
|
||||
<p class="text-green-300 font-medium">Effect uploaded!</p>
|
||||
<p class="text-green-400 text-sm mt-1">${data.name} v${data.version}</p>
|
||||
<p class="text-gray-400 text-xs mt-2 font-mono">${data.cid}</p>
|
||||
<div class="bg-red-900 border border-red-700 rounded p-3 text-red-300">
|
||||
<p class="font-medium">Upload failed</p>
|
||||
<p class="text-sm mt-1">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
})
|
||||
.catch(error => {
|
||||
const resultDiv = document.getElementById('upload-result');
|
||||
resultDiv.innerHTML = `
|
||||
<div class="bg-red-900 border border-red-700 rounded-lg p-4">
|
||||
<p class="text-red-300 font-medium">Upload failed</p>
|
||||
<p class="text-red-400 text-sm mt-1">${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
document.getElementById('effect-upload')?.addEventListener('change', function() {
|
||||
handleEffectUpload(this);
|
||||
});
|
||||
document.getElementById('effect-upload-empty')?.addEventListener('change', function() {
|
||||
handleEffectUpload(this);
|
||||
resultDiv.classList.remove('hidden');
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = 'Upload';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -50,6 +50,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if recipe.type == 'streaming' %}
|
||||
<!-- Streaming Recipe Info -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6 p-4">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<span class="bg-purple-900 text-purple-300 px-2 py-1 rounded text-sm">Streaming Recipe</span>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm">
|
||||
This recipe uses frame-by-frame streaming rendering. The pipeline is defined as an S-expression that generates frames dynamically.
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- DAG Visualization -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 mb-6">
|
||||
<div class="border-b border-gray-700 px-4 py-2 flex items-center justify-between">
|
||||
@@ -99,11 +110,16 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- YAML Source -->
|
||||
<h2 class="text-lg font-semibold mb-4">Source</h2>
|
||||
<!-- Source Code -->
|
||||
<h2 class="text-lg font-semibold mb-4">Recipe (S-expression)</h2>
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-700">
|
||||
<pre class="text-sm text-gray-300 overflow-x-auto whitespace-pre-wrap">{{ recipe.yaml }}</pre>
|
||||
{% if recipe.sexp %}
|
||||
<pre class="text-sm font-mono text-gray-300 overflow-x-auto whitespace-pre-wrap">{{ recipe.sexp }}</pre>
|
||||
{% else %}
|
||||
<p class="text-gray-500">No source available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
{% 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 %}
|
||||
<div class="flex-grow"></div>
|
||||
<button hx-post="/runs/{{ run.run_id }}/publish"
|
||||
hx-target="#share-result"
|
||||
@@ -50,7 +53,11 @@
|
||||
<div class="bg-gray-800 rounded-lg p-4">
|
||||
<div class="text-gray-500 text-sm">Steps</div>
|
||||
<div class="text-white font-medium">
|
||||
{{ run.executed or 0 }} / {{ run.total_steps or (plan.steps|length if plan and plan.steps else '?') }}
|
||||
{% 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 %}
|
||||
|
||||
Reference in New Issue
Block a user