Add video resolution and duration options to video generation forms; implement video status polling in frontend

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 19:11:21 +02:00
parent 98d59de2d1
commit 17ae8d9477
4 changed files with 183 additions and 7 deletions
+2 -1
View File
@@ -174,7 +174,8 @@ def generate_video():
mode = request.form.get("mode", "text") mode = request.form.get("mode", "text")
token = session["access_token"] token = session["access_token"]
duration_raw = request.form.get("duration_seconds", "") 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 resolution = request.form.get("resolution", "").strip() or None
if mode == "image": if mode == "image":
resp = _api("POST", "/generate/video/from-image", token=token, json={ resp = _api("POST", "/generate/video/from-image", token=token, json={
+44
View File
@@ -37,4 +37,48 @@ document.addEventListener("DOMContentLoaded", () => {
if (panel) panel.classList.add("active"); 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: <strong>" + data.status + "</strong>";
}
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 =
'<div class="alert alert-error">Generation failed: ' +
(data.error || "Unknown error") +
"</div>";
}
} catch (e) {
console.error("Video polling error:", e);
}
}, 5000);
}
}); });
+65 -5
View File
@@ -46,6 +46,30 @@ endblock %} {% block content %}
<option value="1:1">1:1 (square)</option> <option value="1:1">1:1 (square)</option>
</select> </select>
<label for="res-t">Resolution</label>
<select id="res-t" name="resolution">
<option value="">Auto (default)</option>
<option value="480p">480p</option>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
</select>
<label for="duration-t"
>Duration: <span id="duration-t-val">5</span>s</label
>
<input
type="range"
id="duration-t"
name="duration_seconds"
min="5"
max="60"
step="1"
value="5"
oninput="
document.getElementById('duration-t-val').textContent = this.value
"
/>
<button type="submit">Generate video</button> <button type="submit">Generate video</button>
</form> </form>
</div> </div>
@@ -93,6 +117,30 @@ endblock %} {% block content %}
<option value="1:1">1:1 (square)</option> <option value="1:1">1:1 (square)</option>
</select> </select>
<label for="res-i">Resolution</label>
<select id="res-i" name="resolution">
<option value="">Auto (default)</option>
<option value="480p">480p</option>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
</select>
<label for="duration-i"
>Duration: <span id="duration-i-val">5</span>s</label
>
<input
type="range"
id="duration-i"
name="duration_seconds"
min="5"
max="60"
step="1"
value="5"
oninput="
document.getElementById('duration-i-val').textContent = this.value
"
/>
<button type="submit">Generate video from image</button> <button type="submit">Generate video from image</button>
</form> </form>
</div> </div>
@@ -103,17 +151,29 @@ endblock %} {% block content %}
{% endif %} {% if result %} {% endif %} {% if result %}
<div class="result"> <div class="result">
<h2>Video job</h2> <h2>Video job</h2>
<p>Status: <strong>{{ result.status }}</strong></p> <p>Job ID: <code>{{ result.id }}</code></p>
{% if result.get('video_url') %} {% if result.status in ('queued', 'processing') and result.polling_url %}
<div id="video-poll-status" data-polling-url="{{ result.polling_url }}">
<p>
<span id="poll-status-text"
>Status: <strong>{{ result.status }}</strong></span
>
&mdash; checking for updates every 5 s&hellip;
</p>
<div id="poll-video-container"></div>
</div>
{% elif result.video_url %}
<video <video
src="{{ result.video_url }}" src="{{ result.video_url }}"
controls controls
class="generated-video" class="generated-video"
></video> ></video>
{% elif result.status == 'failed' %}
<div class="alert alert-error">
Generation failed: {{ result.error or 'Unknown error' }}
</div>
{% else %} {% else %}
<p class="text-muted mt-1" style="font-size: 0.875rem"> <p>Status: <strong>{{ result.status }}</strong></p>
Video is being processed. Check back later.
</p>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
+72 -1
View File
@@ -226,7 +226,8 @@ def test_generate_video_text_mode(client):
with patch("frontend.app.main.httpx.request", return_value=mock): with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.post("/generate/video", data={ resp = client.post("/generate/video", data={
"mode": "text", "model": "openai/sora-2-pro", "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 resp.status_code == 200
assert b"queued" in resp.data assert b"queued" in resp.data
@@ -347,3 +348,73 @@ def test_profile_update_failure(client):
"/users/profile", data={"email": "bad", "password": ""}) "/users/profile", data={"email": "bad", "password": ""})
# redirects regardless, flash message shown on next GET # redirects regardless, flash message shown on next GET
assert resp.status_code == 302 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