From 299ad7d9436654c8124550cd49b7796a989cb974 Mon Sep 17 00:00:00 2001
From: zwitschi
Date: Wed, 29 Apr 2026 20:04:10 +0200
Subject: [PATCH] feat: add video job cancellation functionality and error
tracking in generated videos
Co-authored-by: Copilot
---
backend/app/db.py | 3 +
backend/app/routers/generate.py | 41 ++++++++--
backend/app/services/video_worker.py | 9 ++-
docs/5-building-block-view.md | 45 +++++++----
docs/6-runtime-view.md | 29 ++++---
docs/8.1-openrouter.md | 17 ++--
frontend/app/main.py | 9 +++
frontend/app/static/app.js | 76 +++++++++++++++--
frontend/app/static/style.css | 4 +
frontend/app/templates/admin.html | 2 +-
frontend/app/templates/gallery.html | 94 ++++++++++++++++++----
frontend/app/templates/generate_video.html | 7 ++
frontend/app/templates/video_detail.html | 7 ++
13 files changed, 282 insertions(+), 61 deletions(-)
diff --git a/backend/app/db.py b/backend/app/db.py
index b83fec9..0256f81 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -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)
diff --git a/backend/app/routers/generate.py b/backend/app/routers/generate.py
index 59af0b0..c6c6594 100644
--- a/backend/app/routers/generate.py
+++ b/backend/app/routers/generate.py
@@ -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}
diff --git a/backend/app/services/video_worker.py b/backend/app/services/video_worker.py
index 5435d64..cf9be4c 100644
--- a/backend/app/services/video_worker.py
+++ b/backend/app/services/video_worker.py
@@ -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)
diff --git a/docs/5-building-block-view.md b/docs/5-building-block-view.md
index a4364fe..960f4cf 100644
--- a/docs/5-building-block-view.md
+++ b/docs/5-building-block-view.md
@@ -65,29 +65,42 @@ 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 |
+| 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) |
-| POST | `/generate/image` | ✓ | Text-to-image (DALL-E via `/images/generations` or FLUX/GPT-5 Image Mini via `/chat/completions` with `modalities`) |
-| 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` |
+| 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) |
+| POST | `/generate/image` | ✓ | Text-to-image (DALL-E via `/images/generations` or FLUX/GPT-5 Image Mini via `/chat/completions` with `modalities`) |
+| 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`)
diff --git a/docs/6-runtime-view.md b/docs/6-runtime-view.md
index 342ee23..8c8fff9 100644
--- a/docs/6-runtime-view.md
+++ b/docs/6-runtime-view.md
@@ -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 `
+
+
{% elif result.video_url %}
+
+
{% elif video.status == 'failed' %}