diff --git a/frontend/app/main.py b/frontend/app/main.py index f72ac9d..5017329 100644 --- a/frontend/app/main.py +++ b/frontend/app/main.py @@ -174,7 +174,8 @@ def generate_video(): mode = request.form.get("mode", "text") token = session["access_token"] duration_raw = request.form.get("duration_seconds", "") - duration = int(duration_raw) if duration_raw.strip().isdigit() else None + duration = int( + duration_raw) if duration_raw.strip().isdigit() else None resolution = request.form.get("resolution", "").strip() or None if mode == "image": resp = _api("POST", "/generate/video/from-image", token=token, json={ diff --git a/frontend/app/static/app.js b/frontend/app/static/app.js index 3c5996f..9d9d8e5 100644 --- a/frontend/app/static/app.js +++ b/frontend/app/static/app.js @@ -37,4 +37,48 @@ document.addEventListener("DOMContentLoaded", () => { if (panel) panel.classList.add("active"); }); }); + + // ── Video status polling ─────────────────────────────── + const pollDiv = document.getElementById("video-poll-status"); + if (pollDiv) { + const pollingUrl = pollDiv.dataset.pollingUrl; + const statusText = document.getElementById("poll-status-text"); + const videoContainer = document.getElementById("poll-video-container"); + + const interval = setInterval(async () => { + try { + const resp = await fetch( + "/generate/video/status?polling_url=" + + encodeURIComponent(pollingUrl), + ); + if (!resp.ok) return; + const data = await resp.json(); + + if (statusText) { + statusText.innerHTML = "Status: " + data.status + ""; + } + + if (data.status === "completed") { + clearInterval(interval); + if (data.video_url && videoContainer) { + const vid = document.createElement("video"); + vid.src = data.video_url; + vid.controls = true; + vid.className = "generated-video"; + videoContainer.appendChild(vid); + const msg = pollDiv.querySelector("p"); + if (msg) msg.textContent = "Video ready!"; + } + } else if (data.status === "failed") { + clearInterval(interval); + pollDiv.innerHTML = + '
Generation failed: ' + + (data.error || "Unknown error") + + "
"; + } + } catch (e) { + console.error("Video polling error:", e); + } + }, 5000); + } }); diff --git a/frontend/app/templates/generate_video.html b/frontend/app/templates/generate_video.html index 7c5e7a8..2c54bec 100644 --- a/frontend/app/templates/generate_video.html +++ b/frontend/app/templates/generate_video.html @@ -46,6 +46,30 @@ endblock %} {% block content %} + + + + + + @@ -93,6 +117,30 @@ endblock %} {% block content %} + + + + + + @@ -103,17 +151,29 @@ endblock %} {% block content %} {% endif %} {% if result %}

Video job

-

Status: {{ result.status }}

- {% if result.get('video_url') %} +

Job ID: {{ result.id }}

+ {% if result.status in ('queued', 'processing') and result.polling_url %} +
+

+ Status: {{ result.status }} + — checking for updates every 5 s… +

+
+
+ {% elif result.video_url %} + {% elif result.status == 'failed' %} +
+ Generation failed: {{ result.error or 'Unknown error' }} +
{% else %} -

- Video is being processed. Check back later. -

+

Status: {{ result.status }}

{% endif %}
{% endif %} diff --git a/frontend/tests/test_frontend.py b/frontend/tests/test_frontend.py index b090745..f98cd54 100644 --- a/frontend/tests/test_frontend.py +++ b/frontend/tests/test_frontend.py @@ -226,7 +226,8 @@ def test_generate_video_text_mode(client): with patch("frontend.app.main.httpx.request", return_value=mock): resp = client.post("/generate/video", data={ "mode": "text", "model": "openai/sora-2-pro", - "prompt": "A sunset", "aspect_ratio": "16:9" + "prompt": "A sunset", "aspect_ratio": "16:9", + "duration_seconds": "10", "resolution": "720p", }) assert resp.status_code == 200 assert b"queued" in resp.data @@ -347,3 +348,73 @@ def test_profile_update_failure(client): "/users/profile", data={"email": "bad", "password": ""}) # redirects regardless, flash message shown on next GET assert resp.status_code == 302 + + +# --------------------------------------------------------------------------- +# GET /generate/video/status (polling proxy) +# --------------------------------------------------------------------------- + +def test_video_status_proxy_completed(client): + _set_auth(client) + mock = _mock_response(200, { + "id": "v1", "model": "", "status": "completed", + "video_url": "https://example.com/video.mp4", + "unsigned_urls": ["https://example.com/video.mp4"], + }) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.get( + "/generate/video/status", + query_string={ + "polling_url": "https://openrouter.ai/api/v1/videos/v1"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["status"] == "completed" + assert data["video_url"] == "https://example.com/video.mp4" + + +def test_video_status_proxy_processing(client): + _set_auth(client) + mock = _mock_response( + 200, {"id": "v1", "model": "", "status": "processing"}) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.get( + "/generate/video/status", + query_string={ + "polling_url": "https://openrouter.ai/api/v1/videos/v1"}, + ) + assert resp.status_code == 200 + assert resp.get_json()["status"] == "processing" + + +def test_video_status_proxy_requires_login(client): + resp = client.get( + "/generate/video/status", + query_string={"polling_url": "https://openrouter.ai/api/v1/videos/v1"}, + ) + assert resp.status_code == 302 + assert "/login" in resp.headers["Location"] + + +def test_video_status_proxy_missing_url(client): + _set_auth(client) + resp = client.get("/generate/video/status") + assert resp.status_code == 400 + assert b"polling_url" in resp.data + + +def test_video_generate_renders_polling_ui(client): + """When response has polling_url, template shows polling div.""" + _set_auth(client) + mock = _mock_response(200, { + "id": "v1", "model": "openai/sora-2-pro", "status": "queued", + "polling_url": "https://openrouter.ai/api/v1/videos/v1", + }) + with patch("frontend.app.main.httpx.request", return_value=mock): + resp = client.post("/generate/video", data={ + "mode": "text", "model": "openai/sora-2-pro", + "prompt": "A sunset", "aspect_ratio": "16:9", + }) + assert resp.status_code == 200 + assert b"video-poll-status" in resp.data + assert b"openrouter.ai/api/v1/videos/v1" in resp.data