feat: add video job cancellation functionality and error tracking in generated videos

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-29 20:04:10 +02:00
parent 3d0a08a8ef
commit 299ad7d943
13 changed files with 282 additions and 61 deletions
+3
View File
@@ -121,6 +121,9 @@ def _run_migrations(conn: duckdb.DuckDBPyConnection) -> None:
conn.execute(""" conn.execute("""
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS generation_type VARCHAR DEFAULT 'text_to_video' ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS generation_type VARCHAR DEFAULT 'text_to_video'
""") """)
conn.execute("""
ALTER TABLE generated_videos ADD COLUMN IF NOT EXISTS error VARCHAR
""")
_seed_admin(conn) _seed_admin(conn)
+36 -5
View File
@@ -322,7 +322,7 @@ async def list_generated_videos(
user_id = current_user.get("id") or current_user.get("sub") user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn() conn = get_conn()
rows = conn.execute( rows = conn.execute(
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, created_at """SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at
FROM generated_videos FROM generated_videos
WHERE user_id = ? WHERE user_id = ?
ORDER BY created_at DESC""", ORDER BY created_at DESC""",
@@ -337,7 +337,8 @@ async def list_generated_videos(
"polling_url": r[4], "polling_url": r[4],
"status": r[5], "status": r[5],
"video_url": r[6], "video_url": r[6],
"created_at": r[7].isoformat() if r[7] else None, "error": r[7],
"created_at": r[8].isoformat() if r[8] else None,
} }
for r in rows for r in rows
] ]
@@ -352,7 +353,7 @@ async def get_generated_video(
user_id = current_user.get("id") or current_user.get("sub") user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn() conn = get_conn()
row = conn.execute( row = conn.execute(
"""SELECT id, job_id, model_id, prompt, polling_url, status, video_url, created_at, updated_at """SELECT id, job_id, model_id, prompt, polling_url, status, video_url, error, created_at, updated_at
FROM generated_videos FROM generated_videos
WHERE id = ? AND user_id = ?""", WHERE id = ? AND user_id = ?""",
[video_id, user_id], [video_id, user_id],
@@ -367,6 +368,36 @@ async def get_generated_video(
"polling_url": row[4], "polling_url": row[4],
"status": row[5], "status": row[5],
"video_url": row[6], "video_url": row[6],
"created_at": row[7].isoformat() if row[7] else None, "error": row[7],
"updated_at": row[8].isoformat() if row[8] else None, "created_at": row[8].isoformat() if row[8] else None,
"updated_at": row[9].isoformat() if row[9] else None,
} }
@router.post("/videos/{video_id}/cancel", status_code=200)
async def cancel_video_job(
video_id: str,
current_user: dict = Depends(get_current_user),
) -> dict[str, str]:
"""Mark a video job as 'cancelled' if it belongs to the current user and is not terminal."""
user_id = current_user.get("id") or current_user.get("sub")
conn = get_conn()
row = conn.execute(
"SELECT status FROM generated_videos WHERE id = ? AND user_id = ?",
[video_id, user_id],
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Video job not found")
job_status = row[0]
if job_status in ("completed", "failed", "cancelled"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot cancel job with status '{job_status}'",
)
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with get_write_lock():
conn.execute(
"UPDATE generated_videos SET status = 'cancelled', updated_at = ? WHERE id = ?",
[now, video_id],
)
return {"status": "ok", "job_id": video_id}
+5 -4
View File
@@ -60,8 +60,8 @@ async def process_queued_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Loc
now = datetime.now(timezone.utc).replace(tzinfo=None) now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock: async with lock:
conn.execute( conn.execute(
"UPDATE generated_videos SET status = 'failed', updated_at = ? WHERE id = ?", "UPDATE generated_videos SET status = 'failed', error = ?, updated_at = ? WHERE id = ?",
[now, db_id], [str(exc), now, db_id],
) )
continue continue
@@ -116,14 +116,15 @@ async def process_processing_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio
urls = result.get("unsigned_urls") or result.get("video_urls") urls = result.get("unsigned_urls") or result.get("video_urls")
video_url = (urls or [None])[0] video_url = (urls or [None])[0]
error_msg = result.get("error")
now = datetime.now(timezone.utc).replace(tzinfo=None) now = datetime.now(timezone.utc).replace(tzinfo=None)
async with lock: async with lock:
conn.execute( conn.execute(
"""UPDATE generated_videos """UPDATE generated_videos
SET status = ?, video_url = ?, updated_at = ? SET status = ?, video_url = ?, error = ?, updated_at = ?
WHERE id = ?""", WHERE id = ?""",
[job_status, video_url, now, db_id], [job_status, video_url, error_msg, now, db_id],
) )
updated += 1 updated += 1
logger.info("Video job %s%s", db_id, job_status) logger.info("Video job %s%s", db_id, job_status)
+17 -4
View File
@@ -66,17 +66,25 @@ Self-service profile management and admin user CRUD.
Operational endpoints for application management. Operational endpoints for application management.
| Method | Path | Auth required | Admin only | Description | | Method | Path | Auth required | Admin only | Description |
| ------ | --------------------- | ------------- | ---------- | ------------------------------------- | | ------ | --------------------------- | ------------- | ---------- | ------------------------------------------ |
| GET | `/admin/stats` | ✓ | ✓ | User counts by role, token activity | | GET | `/admin/stats` | ✓ | ✓ | User counts by role, token activity |
| GET | `/admin/health/db` | ✓ | ✓ | DuckDB connectivity check | | GET | `/admin/health/db` | ✓ | ✓ | DuckDB connectivity check |
| POST | `/admin/tokens/purge` | ✓ | ✓ | Remove expired/revoked refresh tokens | | POST | `/admin/tokens/purge` | ✓ | ✓ | Remove expired/revoked refresh tokens |
| GET | `/admin/videos` | ✓ | ✓ | List all video jobs with user emails |
| POST | `/admin/videos/{id}/cancel` | ✓ | ✓ | Cancel a queued/processing video job |
| POST | `/admin/videos/{id}/retry` | ✓ | ✓ | Retry a failed/cancelled video job |
| DELETE | `/admin/videos/{id}` | ✓ | ✓ | Permanently delete a video job |
| POST | `/admin/videos/purge` | ✓ | ✓ | Delete old completed/failed/cancelled jobs |
| POST | `/admin/videos/timed-out` | ✓ | ✓ | Mark stale processing jobs as failed |
| GET | `/admin/models` | ✓ | ✓ | List cached OpenRouter models |
| POST | `/admin/models/refresh` | ✓ | ✓ | Refresh model cache from OpenRouter |
### White Box AI Service (`/ai`, `/generate`) ### White Box AI Service (`/ai`, `/generate`)
Model listing and multi-modal generation via openrouter.ai. Model listing and multi-modal generation via openrouter.ai.
| Method | Path | Auth required | Description | | Method | Path | Auth required | Description |
| ------ | ---------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------- | | ------ | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
| GET | `/ai/models` | ✓ | List available OpenRouter models | | GET | `/ai/models` | ✓ | List available OpenRouter models |
| POST | `/ai/chat` | ✓ | Multi-turn chat completion | | POST | `/ai/chat` | ✓ | Multi-turn chat completion |
| POST | `/generate/text` | ✓ | Single-prompt text generation (optional system prompt) | | POST | `/generate/text` | ✓ | Single-prompt text generation (optional system prompt) |
@@ -84,10 +92,15 @@ Model listing and multi-modal generation via openrouter.ai.
| POST | `/generate/video` | ✓ | Text-to-video (Sora 2 Pro, Veo 3.1 Fast) — returns `polling_url` | | POST | `/generate/video` | ✓ | Text-to-video (Sora 2 Pro, Veo 3.1 Fast) — returns `polling_url` |
| POST | `/generate/video/from-image` | ✓ | Image-to-video — returns `polling_url` | | POST | `/generate/video/from-image` | ✓ | Image-to-video — returns `polling_url` |
| GET | `/generate/video/status` | ✓ | Poll video generation status via `polling_url` | | GET | `/generate/video/status` | ✓ | Poll video generation status via `polling_url` |
| GET | `/generate/images` | ✓ | List current user's generated images |
| GET | `/generate/images/{id}` | ✓ | Get a single generated image |
| GET | `/generate/videos` | ✓ | List current user's video jobs |
| GET | `/generate/videos/{id}` | ✓ | Get a single video job |
| POST | `/generate/videos/{id}/cancel` | ✓ | Cancel a queued/processing video job |
**Video generation flow:** The `/generate/video` and `/generate/video/from-image` endpoints submit a job to OpenRouter's `/api/v1/videos` endpoint and return immediately with `status: "queued"` and a `polling_url`. Clients poll `/generate/video/status?polling_url=...` every 5 seconds until `status` is `"completed"` (returns `unsigned_urls`) or `"failed"`. **Video generation flow:** The `/generate/video` and `/generate/video/from-image` endpoints queue a job in the local database and return immediately with `status: "queued"`. A background worker (`video_worker.py`) submits the job to OpenRouter's `/api/v1/videos` endpoint, receives a `polling_url`, and polls it periodically until the job reaches `"completed"` or `"failed"`. The frontend polls `GET /generate/video/{id}/status` every 5 seconds to show live status updates.
**Image generation routing:** The router auto-detects the model type — models containing `"flux"` or `"gpt-5-image-mini"` are routed to `/chat/completions` with `modalities: ["image"]`, while others (e.g. DALL-E 3) use the legacy `/images/generations` endpoint. **Image generation routing:** The router auto-detects the model type — models containing `"flux"` or `"gpt-5-image-mini"` are routed to `/chat/completions` with `modalities: ["image"]` (or `["image", "text"]` depending on cached output modalities), while others (e.g. DALL-E 3) use the legacy `/images/generations` endpoint.
### White Box DB Service (`db.py`) ### White Box DB Service (`db.py`)
+20 -9
View File
@@ -48,20 +48,31 @@ Describes concrete behavior and interactions of the system's building blocks in
1. User submits video generation form with prompt, model, aspect ratio, resolution, and duration 1. User submits video generation form with prompt, model, aspect ratio, resolution, and duration
2. Flask POSTs to `POST /generate/video` with JWT header 2. Flask POSTs to `POST /generate/video` with JWT header
3. Auth Service validates JWT 3. Auth Service validates JWT
4. Backend calls OpenRouter `POST /api/v1/videos` with model, prompt, aspect_ratio, resolution, duration_seconds 4. Backend inserts a row into `generated_videos` with `status: "queued"` and returns the DB job ID
5. OpenRouter returns `{"id": "...", "polling_url": "..."}` with `status: "queued"` 5. Flask renders result page with polling UI
6. FastAPI returns `VideoResponse` with `polling_url` to Flask 6. Background worker (`video_worker.py`) picks up queued jobs every 15 seconds:
7. Flask renders result page with polling UI - Calls OpenRouter `POST /api/v1/videos` with model, prompt, and parameters
8. Frontend JavaScript polls `GET /generate/video/status?polling_url=...` every 5 seconds - Receives `{"id": "...", "polling_url": "..."}` and updates the DB row to `status: "processing"`
9. When `status` becomes `"completed"`, the response includes `unsigned_urls` — the video is displayed in a `<video>` element - Polls the `polling_url` every 15 seconds until `status` is `"completed"` or `"failed"`
10. If `status` becomes `"failed"`, an error message is shown - Updates the DB row with the final status and video URL
7. Frontend JavaScript polls `GET /generate/video/{db_id}/status` every 5 seconds
8. When `status` becomes `"completed"`, the response includes `video_url` — the video is displayed in a `<video>` element
9. If `status` becomes `"failed"`, an error message is shown
10. User can click "Cancel Job" to mark the job as `"cancelled"` (stops local polling, does not stop the provider job)
## Scenario 4a: Video Generation (Image-to-Video) ## Scenario 4a: Video Generation (Image-to-Video)
1. User provides an image URL, motion prompt, model, aspect ratio, resolution, and duration 1. User provides an image URL, motion prompt, model, aspect ratio, resolution, and duration
2. Flask POSTs to `POST /generate/video/from-image` with JWT header 2. Flask POSTs to `POST /generate/video/from-image` with JWT header
3. Backend calls OpenRouter `POST /api/v1/videos` with `image_url`, prompt, and parameters 3. Same background worker flow as Scenario 4, with `generation_type: "image_to_video"`
4. Same polling flow as Scenario 4
## Scenario 4b: Video Job Cancellation
1. User clicks "Cancel Job" on the video detail page or gallery pending card
2. Frontend POSTs to `/generate/video/{id}/cancel`
3. Backend verifies the job belongs to the user and is not in a terminal state
4. Backend updates the DB row `status` to `"cancelled"`
5. Frontend stops polling and updates the UI to show "Job cancelled"
## Scenario 5: Token Refresh ## Scenario 5: Token Refresh
+11 -6
View File
@@ -16,11 +16,16 @@ The router auto-detects the model type and routes accordingly. Image configurati
## Video Generation ## Video Generation
Video generation uses OpenRouter's `/api/v1/videos` endpoint with a **submit-and-poll** pattern: Video generation uses OpenRouter's `/api/v1/videos` endpoint with a **submit-and-poll** pattern orchestrated by a background worker:
1. `POST /api/v1/videos` with `model`, `prompt`, `aspect_ratio`, `resolution`, `duration_seconds` 1. User submits a video request via `POST /generate/video` (or `/generate/video/from-image`)
2. Response: `{"id": "job_id", "polling_url": "https://..."}` with `status: "queued"` 2. Backend inserts a row into `generated_videos` with `status: "queued"` and returns immediately
3. Poll `GET polling_url` every 5 seconds until `status` is `"completed"` or `"failed"` 3. Background worker (`video_worker.py`) picks up queued jobs every 15 seconds:
4. Completed response includes `unsigned_urls: [str]` array with video download URLs - Calls `POST /api/v1/videos` with `model`, `prompt`, `aspect_ratio`, `resolution`, `duration`
- Receives `{"id": "job_id", "polling_url": "https://..."}` and updates DB to `status: "processing"`
- Polls `GET polling_url` every 15 seconds until `status` is `"completed"` or `"failed"`
- Updates DB with final status, `video_url`, and any `error` message
4. Frontend polls `GET /generate/video/{db_id}/status` every 5 seconds to show live updates
5. Completed response includes `video_url` — the video is displayed in a `<video>` element
Supported models: `openai/sora-2-pro`, `google/veo-3.1-fast`. Both text-to-video and image-to-video use the same `/api/v1/videos` endpoint (image-to-video includes `image_url` in the request body). Supported models: `openai/sora-2-pro`, `google/veo-3.1-fast`. Both text-to-video and image-to-video use the same `/api/v1/videos` endpoint (image-to-video includes `frame_images` with `first_frame` in the request body).
+9
View File
@@ -478,6 +478,15 @@ def generate_video_db_status(video_id: str):
return jsonify(resp.json()), resp.status_code return jsonify(resp.json()), resp.status_code
@app.post("/generate/video/<video_id>/cancel")
@login_required
def cancel_video_job(video_id: str):
"""Proxy cancel request to backend."""
resp = _api(
"POST", f"/generate/videos/{video_id}/cancel", token=session["access_token"])
return jsonify(resp.json()), resp.status_code
# ── Admin ───────────────────────────────────────────────────────────────── # ── Admin ─────────────────────────────────────────────────────────────────
@app.get("/admin") @app.get("/admin")
+71 -5
View File
@@ -66,9 +66,70 @@ document.addEventListener("DOMContentLoaded", () => {
const videoId = pollDiv.dataset.videoId; const videoId = pollDiv.dataset.videoId;
const statusText = document.getElementById("poll-status-text"); const statusText = document.getElementById("poll-status-text");
const videoContainer = document.getElementById("poll-video-container"); const videoContainer = document.getElementById("poll-video-container");
const cancelBtn = document.getElementById("cancel-video-btn");
const cancelMsg = document.getElementById("cancel-msg");
const MAX_POLLS = 120; // ~10 minutes at 5s interval
let pollCount = 0;
let interval = null;
const interval = setInterval(async () => { const stopPolling = () => {
if (interval) {
clearInterval(interval);
interval = null;
}
};
if (cancelBtn) {
cancelBtn.addEventListener("click", async () => {
cancelBtn.disabled = true;
cancelBtn.textContent = "Cancelling…";
try { try {
const resp = await fetch(
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
{ method: "POST" },
);
if (resp.ok) {
stopPolling();
cancelBtn.classList.add("hidden");
if (cancelMsg) {
cancelMsg.textContent = "Job cancelled.";
cancelMsg.classList.remove("hidden", "text-red-500");
cancelMsg.classList.add("text-gray-300");
}
if (statusText) {
statusText.innerHTML = "Status: <strong>cancelled</strong>";
}
} else {
const data = await resp.json().catch(() => ({}));
cancelBtn.disabled = false;
cancelBtn.textContent = "Cancel Job";
if (cancelMsg) {
cancelMsg.textContent = data.detail || "Cancel failed.";
cancelMsg.classList.remove("hidden");
cancelMsg.classList.add("text-red-500");
}
}
} catch (e) {
cancelBtn.disabled = false;
cancelBtn.textContent = "Cancel Job";
if (cancelMsg) {
cancelMsg.textContent = "Network error.";
cancelMsg.classList.remove("hidden");
cancelMsg.classList.add("text-red-500");
}
}
});
}
interval = setInterval(async () => {
try {
pollCount++;
if (pollCount > MAX_POLLS) {
stopPolling();
pollDiv.innerHTML =
'<div class="alert alert-warning">Polling timed out. Please refresh the page to check status.</div>';
return;
}
const resp = await fetch( const resp = await fetch(
"/generate/video/" + encodeURIComponent(videoId) + "/status", "/generate/video/" + encodeURIComponent(videoId) + "/status",
); );
@@ -80,7 +141,7 @@ document.addEventListener("DOMContentLoaded", () => {
} }
if (data.status === "completed") { if (data.status === "completed") {
clearInterval(interval); stopPolling();
if (data.video_url) { if (data.video_url) {
if (videoContainer) { if (videoContainer) {
const vid = document.createElement("video"); const vid = document.createElement("video");
@@ -95,10 +156,15 @@ document.addEventListener("DOMContentLoaded", () => {
window.location.reload(); window.location.reload();
} }
} }
} else if (data.status === "failed" || data.status === "cancelled") { } else if (data.status === "failed") {
clearInterval(interval); stopPolling();
pollDiv.innerHTML = pollDiv.innerHTML =
'<div class="alert alert-error">Generation failed or was cancelled.</div>'; '<div class="alert alert-error">Generation failed.</div>';
} else if (data.status === "cancelled") {
stopPolling();
if (cancelBtn) cancelBtn.classList.add("hidden");
pollDiv.innerHTML =
'<div class="alert alert-info">Job was cancelled.</div>';
} }
} catch (e) { } catch (e) {
console.error("Video polling error:", e); console.error("Video polling error:", e);
+4
View File
@@ -144,6 +144,10 @@ main {
padding: 0 1rem; padding: 0 1rem;
} }
main:has(.admin-page) {
max-width: 1200px;
}
/* ─── Alerts ───────────────────────────────────────────── */ /* ─── Alerts ───────────────────────────────────────────── */
.alert { .alert {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
+1 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock {% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock
%} {% block content %} %} {% block content %}
<div class="card"> <div class="card admin-page">
<h1>Admin Dashboard</h1> <h1>Admin Dashboard</h1>
{% if stats %} {% if stats %}
+67 -3
View File
@@ -18,10 +18,11 @@ content %}
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
> >
{% for video in pending_videos %} {% for video in pending_videos %}
<a <div
href="{{ url_for('video_detail', video_id=video.id) }}" class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300 relative"
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300" data-pending-video-id="{{ video.id }}"
> >
<a href="{{ url_for('video_detail', video_id=video.id) }}">
<div class="p-4"> <div class="p-4">
<p class="font-bold text-lg truncate">{{ video.prompt }}</p> <p class="font-bold text-lg truncate">{{ video.prompt }}</p>
<p class="text-sm text-gray-400"> <p class="text-sm text-gray-400">
@@ -35,6 +36,16 @@ content %}
</p> </p>
</div> </div>
</a> </a>
<div class="px-4 pb-4">
<button
class="cancel-pending-btn px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs"
data-video-id="{{ video.id }}"
>
Cancel
</button>
<span class="cancel-pending-msg text-xs ml-2 hidden"></span>
</div>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -230,6 +241,59 @@ content %}
}, 1500); }, 1500);
} }
}); });
// Cancel pending video buttons
document.querySelectorAll(".cancel-pending-btn").forEach((btn) => {
btn.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const videoId = btn.dataset.videoId;
const msgEl = btn.parentElement.querySelector(".cancel-pending-msg");
btn.disabled = true;
btn.textContent = "Cancelling…";
try {
const resp = await fetch(
"/generate/video/" + encodeURIComponent(videoId) + "/cancel",
{ method: "POST" },
);
if (resp.ok) {
btn.classList.add("hidden");
if (msgEl) {
msgEl.textContent = "Cancelled";
msgEl.classList.remove("hidden", "text-red-500");
msgEl.classList.add("text-gray-300");
}
const card = document.querySelector(
'[data-pending-video-id="' + videoId + '"]',
);
if (card) {
const statusSpan = card.querySelector(".text-yellow-400");
if (statusSpan) {
statusSpan.textContent = "cancelled";
statusSpan.classList.remove("text-yellow-400");
statusSpan.classList.add("text-gray-400");
}
}
} else {
const data = await resp.json().catch(() => ({}));
btn.disabled = false;
btn.textContent = "Cancel";
if (msgEl) {
msgEl.textContent = data.detail || "Failed";
msgEl.classList.remove("hidden");
msgEl.classList.add("text-red-500");
}
}
} catch (err) {
btn.disabled = false;
btn.textContent = "Cancel";
if (msgEl) {
msgEl.textContent = "Error";
msgEl.classList.remove("hidden");
msgEl.classList.add("text-red-500");
}
}
});
});
}); });
</script> </script>
{% endblock %} {% endblock %}
@@ -165,6 +165,13 @@ AI{% endblock %} {% block content %}
&mdash; checking for updates every 5 s&hellip; &mdash; checking for updates every 5 s&hellip;
</p> </p>
<div id="poll-video-container"></div> <div id="poll-video-container"></div>
<button
id="cancel-video-btn"
class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
>
Cancel Job
</button>
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
</div> </div>
{% elif result.video_url %} {% elif result.video_url %}
<video <video
+7
View File
@@ -26,6 +26,13 @@ block content %}
it's ready. it's ready.
</p> </p>
<div class="spinner mt-4"></div> <div class="spinner mt-4"></div>
<button
id="cancel-video-btn"
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm"
>
Cancel Job
</button>
<p id="cancel-msg" class="text-sm mt-2 hidden"></p>
</div> </div>
{% elif video.status == 'failed' %} {% elif video.status == 'failed' %}
<div <div