Bind mp4-mode result download to its own session via rec id
Browse filesrecording_finalized now carries the fork's rec dir name over the ws;
/download_last requires ?rec=<dir> (validated, must exist under the
record dir). Drop the <record_dir>/.last cross-process marker — it was
a server-wide global that could serve one user's output to another
(and was readable by anyone via a bare GET). Client no longer falls
back to "latest recording" on finalize timeout; it reports failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- app.py +6 -8
- static/index.html +12 -23
- xvideo/serving/serve_joyomni_streaming.py +8 -9
- xvideo/serving/zerogpu_engine.py +1 -7
app.py
CHANGED
|
@@ -521,16 +521,14 @@ async def health():
|
|
| 521 |
|
| 522 |
|
| 523 |
@app.get("/download_last")
|
| 524 |
-
async def download_last():
|
| 525 |
-
# Port of the local server's endpoint; the finalized dir comes from the fork
|
| 526 |
-
# via the <record_dir>/.last marker (forks cannot set main-process state).
|
| 527 |
if _RECORD_DIR is None:
|
| 528 |
return JSONResponse({"error": "Recording is not enabled."}, status_code=404)
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
segments = sorted(base.glob("output_*.mp4"))
|
| 535 |
if not segments:
|
| 536 |
return JSONResponse({"error": "Recording file has not been generated yet. Try again later."}, status_code=404)
|
|
|
|
| 521 |
|
| 522 |
|
| 523 |
@app.get("/download_last")
|
| 524 |
+
async def download_last(rec: str = ""):
|
|
|
|
|
|
|
| 525 |
if _RECORD_DIR is None:
|
| 526 |
return JSONResponse({"error": "Recording is not enabled."}, status_code=404)
|
| 527 |
+
if not re.fullmatch(r"\d+_\d+", rec):
|
| 528 |
+
return JSONResponse({"error": "Missing or invalid rec id."}, status_code=400)
|
| 529 |
+
base = Path(_RECORD_DIR) / rec
|
| 530 |
+
if not base.is_dir():
|
| 531 |
+
return JSONResponse({"error": "No such recording."}, status_code=404)
|
| 532 |
segments = sorted(base.glob("output_*.mp4"))
|
| 533 |
if not segments:
|
| 534 |
return JSONResponse({"error": "Recording file has not been generated yet. Try again later."}, status_code=404)
|
static/index.html
CHANGED
|
@@ -2432,13 +2432,17 @@ function handleVideoEnded() {
|
|
| 2432 |
updateMetrics();
|
| 2433 |
if (ws && ws.readyState === WebSocket.OPEN && sessionGranted) {
|
| 2434 |
finalizeIsForResult = true;
|
|
|
|
| 2435 |
updateUploadProgress();
|
| 2436 |
if (finalizeWatchdog) clearTimeout(finalizeWatchdog);
|
| 2437 |
finalizeWatchdog = setTimeout(() => {
|
| 2438 |
finalizeWatchdog = null;
|
| 2439 |
if (!finalizeIsForResult) return;
|
| 2440 |
finalizeIsForResult = false;
|
| 2441 |
-
|
|
|
|
|
|
|
|
|
|
| 2442 |
}, 30000);
|
| 2443 |
try { ws.send(JSON.stringify({ type: "finalize_recording" })); }
|
| 2444 |
catch (err) {}
|
|
@@ -2488,7 +2492,7 @@ function setSourceSeg(src) {
|
|
| 2488 |
if (segM) segM.addEventListener("click", () => { document.getElementById("videoFile").click(); });
|
| 2489 |
}
|
| 2490 |
const downloadBubble = document.getElementById("downloadBubble");
|
| 2491 |
-
let
|
| 2492 |
function showDownloadBubble(show) {
|
| 2493 |
if (!downloadBubble) return;
|
| 2494 |
if (show && !(SERVER_DEFAULTS && SERVER_DEFAULTS.record_enabled)) return;
|
|
@@ -2558,6 +2562,7 @@ function stopResultSync() {
|
|
| 2558 |
function clearResultVideo() {
|
| 2559 |
stopResultSync();
|
| 2560 |
finalizeIsForResult = false;
|
|
|
|
| 2561 |
resultEpoch += 1;
|
| 2562 |
if (typeof showDownloadBubble === "function") showDownloadBubble(false);
|
| 2563 |
if (outputResult) {
|
|
@@ -2584,29 +2589,13 @@ function triggerBrowserDownload(url, filename) {
|
|
| 2584 |
a.click();
|
| 2585 |
a.remove();
|
| 2586 |
}
|
| 2587 |
-
|
| 2588 |
-
if (resultBlobUrl)
|
| 2589 |
-
triggerBrowserDownload(resultBlobUrl, "joyomni_output.mp4");
|
| 2590 |
-
return;
|
| 2591 |
-
}
|
| 2592 |
-
try {
|
| 2593 |
-
const resp = await fetch("/download_last", { cache: "no-store" });
|
| 2594 |
-
if (!resp.ok) {
|
| 2595 |
-
let detail = "";
|
| 2596 |
-
try { const j = await resp.json(); detail = j.error || ""; } catch (e) {}
|
| 2597 |
-
return;
|
| 2598 |
-
}
|
| 2599 |
-
const blob = await resp.blob();
|
| 2600 |
-
const url = URL.createObjectURL(blob);
|
| 2601 |
-
triggerBrowserDownload(url, "joyomni_output.mp4");
|
| 2602 |
-
setTimeout(() => { try { URL.revokeObjectURL(url); } catch (e) {} }, 60000);
|
| 2603 |
-
} catch (err) {
|
| 2604 |
-
}
|
| 2605 |
}
|
| 2606 |
async function fetchAndShowResult() {
|
| 2607 |
const myEpoch = resultEpoch;
|
| 2608 |
try {
|
| 2609 |
-
const resp = await fetch("/download_last", { cache: "no-store" });
|
| 2610 |
if (myEpoch !== resultEpoch) return;
|
| 2611 |
if (!resp.ok) {
|
| 2612 |
let detail = "";
|
|
@@ -2628,8 +2617,8 @@ async function fetchAndShowResult() {
|
|
| 2628 |
}
|
| 2629 |
function onRecordingFinalized(msg) {
|
| 2630 |
if (finalizeWatchdog) { clearTimeout(finalizeWatchdog); finalizeWatchdog = null; }
|
| 2631 |
-
|
| 2632 |
-
if (
|
| 2633 |
finalizeIsForResult = false;
|
| 2634 |
stopResultSync();
|
| 2635 |
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
|
|
|
|
| 2432 |
updateMetrics();
|
| 2433 |
if (ws && ws.readyState === WebSocket.OPEN && sessionGranted) {
|
| 2434 |
finalizeIsForResult = true;
|
| 2435 |
+
lastRecId = null;
|
| 2436 |
updateUploadProgress();
|
| 2437 |
if (finalizeWatchdog) clearTimeout(finalizeWatchdog);
|
| 2438 |
finalizeWatchdog = setTimeout(() => {
|
| 2439 |
finalizeWatchdog = null;
|
| 2440 |
if (!finalizeIsForResult) return;
|
| 2441 |
finalizeIsForResult = false;
|
| 2442 |
+
stopResultSync();
|
| 2443 |
+
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
|
| 2444 |
+
showOutputIdle(true);
|
| 2445 |
+
setSendBusy(false, "");
|
| 2446 |
}, 30000);
|
| 2447 |
try { ws.send(JSON.stringify({ type: "finalize_recording" })); }
|
| 2448 |
catch (err) {}
|
|
|
|
| 2492 |
if (segM) segM.addEventListener("click", () => { document.getElementById("videoFile").click(); });
|
| 2493 |
}
|
| 2494 |
const downloadBubble = document.getElementById("downloadBubble");
|
| 2495 |
+
let lastRecId = null;
|
| 2496 |
function showDownloadBubble(show) {
|
| 2497 |
if (!downloadBubble) return;
|
| 2498 |
if (show && !(SERVER_DEFAULTS && SERVER_DEFAULTS.record_enabled)) return;
|
|
|
|
| 2562 |
function clearResultVideo() {
|
| 2563 |
stopResultSync();
|
| 2564 |
finalizeIsForResult = false;
|
| 2565 |
+
lastRecId = null;
|
| 2566 |
resultEpoch += 1;
|
| 2567 |
if (typeof showDownloadBubble === "function") showDownloadBubble(false);
|
| 2568 |
if (outputResult) {
|
|
|
|
| 2589 |
a.click();
|
| 2590 |
a.remove();
|
| 2591 |
}
|
| 2592 |
+
function fetchAndSaveRecording() {
|
| 2593 |
+
if (resultBlobUrl) triggerBrowserDownload(resultBlobUrl, "joyomni_output.mp4");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2594 |
}
|
| 2595 |
async function fetchAndShowResult() {
|
| 2596 |
const myEpoch = resultEpoch;
|
| 2597 |
try {
|
| 2598 |
+
const resp = await fetch("/download_last?rec=" + encodeURIComponent(lastRecId), { cache: "no-store" });
|
| 2599 |
if (myEpoch !== resultEpoch) return;
|
| 2600 |
if (!resp.ok) {
|
| 2601 |
let detail = "";
|
|
|
|
| 2617 |
}
|
| 2618 |
function onRecordingFinalized(msg) {
|
| 2619 |
if (finalizeWatchdog) { clearTimeout(finalizeWatchdog); finalizeWatchdog = null; }
|
| 2620 |
+
lastRecId = msg.rec || null;
|
| 2621 |
+
if (!lastRecId) {
|
| 2622 |
finalizeIsForResult = false;
|
| 2623 |
stopResultSync();
|
| 2624 |
if (outputWaitOverlay) outputWaitOverlay.style.display = "none";
|
xvideo/serving/serve_joyomni_streaming.py
CHANGED
|
@@ -8,6 +8,7 @@ import json
|
|
| 8 |
import math
|
| 9 |
import os
|
| 10 |
import queue
|
|
|
|
| 11 |
import sys
|
| 12 |
import tempfile
|
| 13 |
import threading
|
|
@@ -619,8 +620,6 @@ def create_app(args: argparse.Namespace) -> FastAPI:
|
|
| 619 |
|
| 620 |
app.state.session_gate = SessionGate()
|
| 621 |
|
| 622 |
-
app.state.last_recording_dir = None
|
| 623 |
-
|
| 624 |
@app.get("/")
|
| 625 |
def index() -> HTMLResponse:
|
| 626 |
server_defaults = {
|
|
@@ -706,13 +705,14 @@ def create_app(args: argparse.Namespace) -> FastAPI:
|
|
| 706 |
return JSONResponse({"ok": True, "elapsed": time.time() - started})
|
| 707 |
|
| 708 |
@app.get("/download_last")
|
| 709 |
-
async def download_last() -> Response:
|
| 710 |
if args.record_dir is None:
|
| 711 |
return JSONResponse({"error": "Recording is not enabled (--record-dir is unset)."}, status_code=404)
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
|
|
|
| 716 |
segments = sorted(base.glob("output_*.mp4"))
|
| 717 |
if not segments:
|
| 718 |
return JSONResponse({"error": "Recording file has not been generated yet. Try again later."}, status_code=404)
|
|
@@ -1522,11 +1522,10 @@ def create_app(args: argparse.Namespace) -> FastAPI:
|
|
| 1522 |
await _stop_output_task()
|
| 1523 |
finalized = rec_base
|
| 1524 |
await asyncio.to_thread(_stop_recorders)
|
| 1525 |
-
if finalized is not None:
|
| 1526 |
-
app.state.last_recording_dir = str(finalized)
|
| 1527 |
await _send_json({
|
| 1528 |
"type": "recording_finalized",
|
| 1529 |
"ok": finalized is not None,
|
|
|
|
| 1530 |
"message": None if finalized is not None
|
| 1531 |
else "No downloadable result yet. Send an edit first.",
|
| 1532 |
})
|
|
|
|
| 8 |
import math
|
| 9 |
import os
|
| 10 |
import queue
|
| 11 |
+
import re
|
| 12 |
import sys
|
| 13 |
import tempfile
|
| 14 |
import threading
|
|
|
|
| 620 |
|
| 621 |
app.state.session_gate = SessionGate()
|
| 622 |
|
|
|
|
|
|
|
| 623 |
@app.get("/")
|
| 624 |
def index() -> HTMLResponse:
|
| 625 |
server_defaults = {
|
|
|
|
| 705 |
return JSONResponse({"ok": True, "elapsed": time.time() - started})
|
| 706 |
|
| 707 |
@app.get("/download_last")
|
| 708 |
+
async def download_last(rec: str = "") -> Response:
|
| 709 |
if args.record_dir is None:
|
| 710 |
return JSONResponse({"error": "Recording is not enabled (--record-dir is unset)."}, status_code=404)
|
| 711 |
+
if not re.fullmatch(r"\d+_\d+", rec):
|
| 712 |
+
return JSONResponse({"error": "Missing or invalid rec id."}, status_code=400)
|
| 713 |
+
base = Path(args.record_dir) / rec
|
| 714 |
+
if not base.is_dir():
|
| 715 |
+
return JSONResponse({"error": "No such recording."}, status_code=404)
|
| 716 |
segments = sorted(base.glob("output_*.mp4"))
|
| 717 |
if not segments:
|
| 718 |
return JSONResponse({"error": "Recording file has not been generated yet. Try again later."}, status_code=404)
|
|
|
|
| 1522 |
await _stop_output_task()
|
| 1523 |
finalized = rec_base
|
| 1524 |
await asyncio.to_thread(_stop_recorders)
|
|
|
|
|
|
|
| 1525 |
await _send_json({
|
| 1526 |
"type": "recording_finalized",
|
| 1527 |
"ok": finalized is not None,
|
| 1528 |
+
"rec": finalized.name if finalized is not None else None,
|
| 1529 |
"message": None if finalized is not None
|
| 1530 |
else "No downloadable result yet. Send an edit first.",
|
| 1531 |
})
|
xvideo/serving/zerogpu_engine.py
CHANGED
|
@@ -521,14 +521,8 @@ async def _session_loop(runtime, args, websocket: _QueueWebSocket) -> None:
|
|
| 521 |
await _stop_output()
|
| 522 |
finalized = rec_base
|
| 523 |
await asyncio.to_thread(_stop_recorders)
|
| 524 |
-
if finalized is not None:
|
| 525 |
-
# Cross-process handoff: /download_last runs in the main web
|
| 526 |
-
# process, which cannot see this fork's memory.
|
| 527 |
-
try:
|
| 528 |
-
(Path(args.record_dir) / ".last").write_text(str(finalized), encoding="utf-8")
|
| 529 |
-
except Exception: # noqa: BLE001
|
| 530 |
-
pass
|
| 531 |
await _send_json({"type": "recording_finalized", "ok": finalized is not None,
|
|
|
|
| 532 |
"message": None if finalized is not None
|
| 533 |
else "No downloadable result yet. Send an edit first."})
|
| 534 |
elif msg_type == "frame_meta":
|
|
|
|
| 521 |
await _stop_output()
|
| 522 |
finalized = rec_base
|
| 523 |
await asyncio.to_thread(_stop_recorders)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
await _send_json({"type": "recording_finalized", "ok": finalized is not None,
|
| 525 |
+
"rec": finalized.name if finalized is not None else None,
|
| 526 |
"message": None if finalized is not None
|
| 527 |
else "No downloadable result yet. Send an edit first."})
|
| 528 |
elif msg_type == "frame_meta":
|