feat: add video job cancellation functionality and error tracking in generated videos
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -121,6 +121,9 @@ def _run_migrations(conn: duckdb.DuckDBPyConnection) -> None:
|
||||
conn.execute("""
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ async def list_generated_videos(
|
||||
user_id = current_user.get("id") or current_user.get("sub")
|
||||
conn = get_conn()
|
||||
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
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC""",
|
||||
@@ -337,7 +337,8 @@ async def list_generated_videos(
|
||||
"polling_url": r[4],
|
||||
"status": r[5],
|
||||
"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
|
||||
]
|
||||
@@ -352,7 +353,7 @@ async def get_generated_video(
|
||||
user_id = current_user.get("id") or current_user.get("sub")
|
||||
conn = get_conn()
|
||||
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
|
||||
WHERE id = ? AND user_id = ?""",
|
||||
[video_id, user_id],
|
||||
@@ -367,6 +368,36 @@ async def get_generated_video(
|
||||
"polling_url": row[4],
|
||||
"status": row[5],
|
||||
"video_url": row[6],
|
||||
"created_at": row[7].isoformat() if row[7] else None,
|
||||
"updated_at": row[8].isoformat() if row[8] else None,
|
||||
"error": row[7],
|
||||
"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}
|
||||
|
||||
@@ -60,8 +60,8 @@ async def process_queued_jobs(conn: duckdb.DuckDBPyConnection, lock: asyncio.Loc
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
async with lock:
|
||||
conn.execute(
|
||||
"UPDATE generated_videos SET status = 'failed', updated_at = ? WHERE id = ?",
|
||||
[now, db_id],
|
||||
"UPDATE generated_videos SET status = 'failed', error = ?, updated_at = ? WHERE id = ?",
|
||||
[str(exc), now, db_id],
|
||||
)
|
||||
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")
|
||||
video_url = (urls or [None])[0]
|
||||
error_msg = result.get("error")
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
async with lock:
|
||||
conn.execute(
|
||||
"""UPDATE generated_videos
|
||||
SET status = ?, video_url = ?, updated_at = ?
|
||||
SET status = ?, video_url = ?, error = ?, updated_at = ?
|
||||
WHERE id = ?""",
|
||||
[job_status, video_url, now, db_id],
|
||||
[job_status, video_url, error_msg, now, db_id],
|
||||
)
|
||||
updated += 1
|
||||
logger.info("Video job %s → %s", db_id, job_status)
|
||||
|
||||
@@ -66,17 +66,25 @@ Self-service profile management and admin user CRUD.
|
||||
Operational endpoints for application management.
|
||||
|
||||
| Method | Path | Auth required | Admin only | Description |
|
||||
| ------ | --------------------- | ------------- | ---------- | ------------------------------------- |
|
||||
| ------ | --------------------------- | ------------- | ---------- | ------------------------------------------ |
|
||||
| GET | `/admin/stats` | ✓ | ✓ | User counts by role, token activity |
|
||||
| GET | `/admin/health/db` | ✓ | ✓ | DuckDB connectivity check |
|
||||
| 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`)
|
||||
|
||||
Model listing and multi-modal generation via openrouter.ai.
|
||||
|
||||
| Method | Path | Auth required | Description |
|
||||
| ------ | ---------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------ | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| GET | `/ai/models` | ✓ | List available OpenRouter models |
|
||||
| POST | `/ai/chat` | ✓ | Multi-turn chat completion |
|
||||
| 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/from-image` | ✓ | Image-to-video — returns `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`)
|
||||
|
||||
|
||||
+20
-9
@@ -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
|
||||
2. Flask POSTs to `POST /generate/video` with JWT header
|
||||
3. Auth Service validates JWT
|
||||
4. Backend calls OpenRouter `POST /api/v1/videos` with model, prompt, aspect_ratio, resolution, duration_seconds
|
||||
5. OpenRouter returns `{"id": "...", "polling_url": "..."}` with `status: "queued"`
|
||||
6. FastAPI returns `VideoResponse` with `polling_url` to Flask
|
||||
7. Flask renders result page with polling UI
|
||||
8. Frontend JavaScript polls `GET /generate/video/status?polling_url=...` every 5 seconds
|
||||
9. When `status` becomes `"completed"`, the response includes `unsigned_urls` — the video is displayed in a `<video>` element
|
||||
10. If `status` becomes `"failed"`, an error message is shown
|
||||
4. Backend inserts a row into `generated_videos` with `status: "queued"` and returns the DB job ID
|
||||
5. Flask renders result page with polling UI
|
||||
6. Background worker (`video_worker.py`) picks up queued jobs every 15 seconds:
|
||||
- Calls OpenRouter `POST /api/v1/videos` with model, prompt, and parameters
|
||||
- Receives `{"id": "...", "polling_url": "..."}` and updates the DB row to `status: "processing"`
|
||||
- Polls the `polling_url` every 15 seconds until `status` is `"completed"` or `"failed"`
|
||||
- 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)
|
||||
|
||||
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
|
||||
3. Backend calls OpenRouter `POST /api/v1/videos` with `image_url`, prompt, and parameters
|
||||
4. Same polling flow as Scenario 4
|
||||
3. Same background worker flow as Scenario 4, with `generation_type: "image_to_video"`
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+11
-6
@@ -16,11 +16,16 @@ The router auto-detects the model type and routes accordingly. Image configurati
|
||||
|
||||
## 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`
|
||||
2. Response: `{"id": "job_id", "polling_url": "https://..."}` with `status: "queued"`
|
||||
3. Poll `GET polling_url` every 5 seconds until `status` is `"completed"` or `"failed"`
|
||||
4. Completed response includes `unsigned_urls: [str]` array with video download URLs
|
||||
1. User submits a video request via `POST /generate/video` (or `/generate/video/from-image`)
|
||||
2. Backend inserts a row into `generated_videos` with `status: "queued"` and returns immediately
|
||||
3. Background worker (`video_worker.py`) picks up queued jobs every 15 seconds:
|
||||
- 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).
|
||||
|
||||
@@ -478,6 +478,15 @@ def generate_video_db_status(video_id: str):
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/admin")
|
||||
|
||||
@@ -66,9 +66,70 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const videoId = pollDiv.dataset.videoId;
|
||||
const statusText = document.getElementById("poll-status-text");
|
||||
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 {
|
||||
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(
|
||||
"/generate/video/" + encodeURIComponent(videoId) + "/status",
|
||||
);
|
||||
@@ -80,7 +141,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
if (data.status === "completed") {
|
||||
clearInterval(interval);
|
||||
stopPolling();
|
||||
if (data.video_url) {
|
||||
if (videoContainer) {
|
||||
const vid = document.createElement("video");
|
||||
@@ -95,10 +156,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
} else if (data.status === "failed" || data.status === "cancelled") {
|
||||
clearInterval(interval);
|
||||
} else if (data.status === "failed") {
|
||||
stopPolling();
|
||||
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) {
|
||||
console.error("Video polling error:", e);
|
||||
|
||||
@@ -144,6 +144,10 @@ main {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
main:has(.admin-page) {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* ─── Alerts ───────────────────────────────────────────── */
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock
|
||||
%} {% block content %}
|
||||
<div class="card">
|
||||
<div class="card admin-page">
|
||||
<h1>Admin Dashboard</h1>
|
||||
|
||||
{% if stats %}
|
||||
|
||||
@@ -18,10 +18,11 @@ content %}
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{% for video in pending_videos %}
|
||||
<a
|
||||
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"
|
||||
<div
|
||||
class="block bg-gray-800 rounded-lg shadow-lg overflow-hidden hover:shadow-2xl transition-shadow duration-300 relative"
|
||||
data-pending-video-id="{{ video.id }}"
|
||||
>
|
||||
<a href="{{ url_for('video_detail', video_id=video.id) }}">
|
||||
<div class="p-4">
|
||||
<p class="font-bold text-lg truncate">{{ video.prompt }}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
@@ -35,6 +36,16 @@ content %}
|
||||
</p>
|
||||
</div>
|
||||
</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 %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,6 +241,59 @@ content %}
|
||||
}, 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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -165,6 +165,13 @@ AI{% endblock %} {% block content %}
|
||||
— checking for updates every 5 s…
|
||||
</p>
|
||||
<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>
|
||||
{% elif result.video_url %}
|
||||
<video
|
||||
|
||||
@@ -26,6 +26,13 @@ block content %}
|
||||
it's ready.
|
||||
</p>
|
||||
<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>
|
||||
{% elif video.status == 'failed' %}
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user