Compare commits
2 Commits
3b807c0f75
...
17ae8d9477
| Author | SHA1 | Date | |
|---|---|---|---|
| 17ae8d9477 | |||
| 98d59de2d1 |
@@ -74,6 +74,7 @@ class VideoRequest(BaseModel):
|
|||||||
prompt: str
|
prompt: str
|
||||||
duration_seconds: int | None = None
|
duration_seconds: int | None = None
|
||||||
aspect_ratio: str = "16:9"
|
aspect_ratio: str = "16:9"
|
||||||
|
resolution: str | None = None # e.g. "480p", "720p", "1080p"
|
||||||
|
|
||||||
|
|
||||||
class VideoFromImageRequest(BaseModel):
|
class VideoFromImageRequest(BaseModel):
|
||||||
@@ -82,6 +83,7 @@ class VideoFromImageRequest(BaseModel):
|
|||||||
prompt: str
|
prompt: str
|
||||||
duration_seconds: int | None = None
|
duration_seconds: int | None = None
|
||||||
aspect_ratio: str = "16:9"
|
aspect_ratio: str = "16:9"
|
||||||
|
resolution: str | None = None # e.g. "480p", "720p", "1080p"
|
||||||
|
|
||||||
|
|
||||||
class VideoResponse(BaseModel):
|
class VideoResponse(BaseModel):
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ async def generate_video(
|
|||||||
prompt=body.prompt,
|
prompt=body.prompt,
|
||||||
duration_seconds=body.duration_seconds,
|
duration_seconds=body.duration_seconds,
|
||||||
aspect_ratio=body.aspect_ratio,
|
aspect_ratio=body.aspect_ratio,
|
||||||
|
resolution=body.resolution,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -131,6 +132,7 @@ async def generate_video_from_image(
|
|||||||
prompt=body.prompt,
|
prompt=body.prompt,
|
||||||
duration_seconds=body.duration_seconds,
|
duration_seconds=body.duration_seconds,
|
||||||
aspect_ratio=body.aspect_ratio,
|
aspect_ratio=body.aspect_ratio,
|
||||||
|
resolution=body.resolution,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ async def generate_video(
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
duration_seconds: int | None = None,
|
duration_seconds: int | None = None,
|
||||||
aspect_ratio: str = "16:9",
|
aspect_ratio: str = "16:9",
|
||||||
|
resolution: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Request text-to-video generation via OpenRouter."""
|
"""Request text-to-video generation via OpenRouter."""
|
||||||
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
||||||
@@ -91,6 +92,8 @@ async def generate_video(
|
|||||||
}
|
}
|
||||||
if duration_seconds is not None:
|
if duration_seconds is not None:
|
||||||
payload["duration_seconds"] = duration_seconds
|
payload["duration_seconds"] = duration_seconds
|
||||||
|
if resolution is not None:
|
||||||
|
payload["resolution"] = resolution
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
resp = client.build_request(
|
resp = client.build_request(
|
||||||
"POST", f"{base_url}/videos", headers=_headers(), json=payload
|
"POST", f"{base_url}/videos", headers=_headers(), json=payload
|
||||||
@@ -106,6 +109,7 @@ async def generate_video_from_image(
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
duration_seconds: int | None = None,
|
duration_seconds: int | None = None,
|
||||||
aspect_ratio: str = "16:9",
|
aspect_ratio: str = "16:9",
|
||||||
|
resolution: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Request image-to-video generation via OpenRouter."""
|
"""Request image-to-video generation via OpenRouter."""
|
||||||
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
base_url = os.getenv("OPENROUTER_BASE_URL", OPENROUTER_BASE_URL)
|
||||||
@@ -117,6 +121,8 @@ async def generate_video_from_image(
|
|||||||
}
|
}
|
||||||
if duration_seconds is not None:
|
if duration_seconds is not None:
|
||||||
payload["duration_seconds"] = duration_seconds
|
payload["duration_seconds"] = duration_seconds
|
||||||
|
if resolution is not None:
|
||||||
|
payload["resolution"] = resolution
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
resp = client.build_request(
|
resp = client.build_request(
|
||||||
"POST", f"{base_url}/videos", headers=_headers(), json=payload
|
"POST", f"{base_url}/videos", headers=_headers(), json=payload
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import httpx
|
|||||||
from flask import (
|
from flask import (
|
||||||
Flask,
|
Flask,
|
||||||
flash,
|
flash,
|
||||||
|
jsonify,
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
request,
|
request,
|
||||||
@@ -172,18 +173,26 @@ def generate_video():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
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 = int(
|
||||||
|
duration_raw) if duration_raw.strip().isdigit() else 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={
|
||||||
"model": request.form.get("model", "").strip(),
|
"model": request.form.get("model", "").strip(),
|
||||||
"image_url": request.form.get("image_url", "").strip(),
|
"image_url": request.form.get("image_url", "").strip(),
|
||||||
"prompt": request.form.get("prompt", "").strip(),
|
"prompt": request.form.get("prompt", "").strip(),
|
||||||
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"resolution": resolution,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
resp = _api("POST", "/generate/video", token=token, json={
|
resp = _api("POST", "/generate/video", token=token, json={
|
||||||
"model": request.form.get("model", "").strip(),
|
"model": request.form.get("model", "").strip(),
|
||||||
"prompt": request.form.get("prompt", "").strip(),
|
"prompt": request.form.get("prompt", "").strip(),
|
||||||
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
"aspect_ratio": request.form.get("aspect_ratio", "16:9"),
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"resolution": resolution,
|
||||||
})
|
})
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
@@ -192,6 +201,21 @@ def generate_video():
|
|||||||
return render_template("generate_video.html", result=result, error=error)
|
return render_template("generate_video.html", result=result, error=error)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/generate/video/status")
|
||||||
|
@login_required
|
||||||
|
def generate_video_status():
|
||||||
|
"""Proxy video status polling to the backend."""
|
||||||
|
polling_url = request.args.get("polling_url", "")
|
||||||
|
if not polling_url:
|
||||||
|
return jsonify({"error": "polling_url required"}), 400
|
||||||
|
resp = _api(
|
||||||
|
"GET", "/generate/video/status",
|
||||||
|
token=session["access_token"],
|
||||||
|
params={"polling_url": polling_url},
|
||||||
|
)
|
||||||
|
return jsonify(resp.json()), resp.status_code
|
||||||
|
|
||||||
|
|
||||||
# ── Admin ─────────────────────────────────────────────────────────────────
|
# ── Admin ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/admin")
|
@app.get("/admin")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
>
|
||||||
|
— checking for updates every 5 s…
|
||||||
|
</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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user