Spaces:
Running on Zero
fix(backend): compute video path inside @spaces.GPU + filesystem fallback
Browse filesexecutor.history_result wasn't surviving ZeroGPU's GPU-context wrap
reliably — sampler completed and files were written to disk, but the
parent process saw an empty history_result, so the OutputEvent's
video_path was "" and the gr.Video output stayed blank.
Two changes:
1. _execute_workflow now reads history_result and returns the video path
inline (a plain str pickles cleanly across the boundary).
2. If that still returns "", _newest_recent_video scans the output dir
for the newest mp4/webm/mov modified in the last 60 s — files are on
shared disk so they're visible from the parent regardless of how
ZeroGPU isolates state.
Also adds a "[backend] workflow done; video_path=..." log line so the
next time this hiccups we see where the chain breaks.
- backend.py +63 -19
|
@@ -71,14 +71,35 @@ _GPU = spaces.GPU(duration=300) if (spaces is not None and _on_spaces()) else _i
|
|
| 71 |
|
| 72 |
|
| 73 |
@_GPU
|
| 74 |
-
def _execute_workflow(executor: Any, workflow: dict, output_ids: list[str]) ->
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
executor.execute(
|
| 76 |
workflow,
|
| 77 |
prompt_id="ltx23-aio",
|
| 78 |
extra_data={"client_id": "ltx23-aio"},
|
| 79 |
execute_outputs=output_ids,
|
| 80 |
)
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
class _StubServer:
|
|
@@ -340,10 +361,20 @@ class ComfyUILibraryBackend:
|
|
| 340 |
# _execute_workflow is module-level and decorated with
|
| 341 |
# @spaces.GPU(duration=300) on Spaces — that's what makes the
|
| 342 |
# heavy compute run on a borrowed H200. Off-Spaces it's a
|
| 343 |
-
# plain call.
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
_push(OutputEvent(video_path=video_path))
|
| 348 |
except Exception as exc:
|
| 349 |
tb_text = tb_mod.format_exc()
|
|
@@ -412,16 +443,29 @@ def _free_memory() -> None:
|
|
| 412 |
pass
|
| 413 |
|
| 414 |
|
| 415 |
-
def
|
| 416 |
-
"""
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
@_GPU
|
| 74 |
+
def _execute_workflow(executor: Any, workflow: dict, output_ids: list[str]) -> str:
|
| 75 |
+
"""Run the workflow on GPU and return the path of the first video output.
|
| 76 |
+
|
| 77 |
+
Returns just the video path (a plain string, picklable across the
|
| 78 |
+
@spaces.GPU subprocess boundary). Returning the full history_result dict
|
| 79 |
+
was unreliable on Spaces — under ZeroGPU's GPU-context wrapping, the
|
| 80 |
+
parent process didn't see the executor's mutated state, so video_path
|
| 81 |
+
came back empty even when the file was on disk.
|
| 82 |
+
"""
|
| 83 |
executor.execute(
|
| 84 |
workflow,
|
| 85 |
prompt_id="ltx23-aio",
|
| 86 |
extra_data={"client_id": "ltx23-aio"},
|
| 87 |
execute_outputs=output_ids,
|
| 88 |
)
|
| 89 |
+
hist = getattr(executor, "history_result", {}) or {}
|
| 90 |
+
outs = hist.get("outputs") or {}
|
| 91 |
+
for output in outs.values():
|
| 92 |
+
if not isinstance(output, dict):
|
| 93 |
+
continue
|
| 94 |
+
for value in output.values():
|
| 95 |
+
if not isinstance(value, list):
|
| 96 |
+
continue
|
| 97 |
+
for item in value:
|
| 98 |
+
if isinstance(item, dict):
|
| 99 |
+
fn = item.get("filename") or ""
|
| 100 |
+
if fn.endswith((".mp4", ".webm", ".mov")):
|
| 101 |
+
return item.get("fullpath") or fn
|
| 102 |
+
return ""
|
| 103 |
|
| 104 |
|
| 105 |
class _StubServer:
|
|
|
|
| 361 |
# _execute_workflow is module-level and decorated with
|
| 362 |
# @spaces.GPU(duration=300) on Spaces — that's what makes the
|
| 363 |
# heavy compute run on a borrowed H200. Off-Spaces it's a
|
| 364 |
+
# plain call. Returns the video path directly (computed
|
| 365 |
+
# inside the GPU context so the executor's history is fresh).
|
| 366 |
+
video_path = _execute_workflow(self._executor, workflow, output_ids)
|
| 367 |
+
# Fallback: if history_result didn't surface a path (rare on
|
| 368 |
+
# Spaces — happens when ZeroGPU's subprocess boundary drops
|
| 369 |
+
# mutated state), scan the output dir for the newest mp4
|
| 370 |
+
# written within the last 60 s.
|
| 371 |
+
if not video_path:
|
| 372 |
+
video_path = _newest_recent_video(self._comfy_dir / "output") or ""
|
| 373 |
+
print(
|
| 374 |
+
f"[backend] workflow done; video_path={video_path!r}",
|
| 375 |
+
file=sys.stderr,
|
| 376 |
+
flush=True,
|
| 377 |
+
)
|
| 378 |
_push(OutputEvent(video_path=video_path))
|
| 379 |
except Exception as exc:
|
| 380 |
tb_text = tb_mod.format_exc()
|
|
|
|
| 443 |
pass
|
| 444 |
|
| 445 |
|
| 446 |
+
def _newest_recent_video(output_root: pathlib.Path, within_seconds: float = 60.0) -> str | None:
|
| 447 |
+
"""Filesystem fallback: return the newest .mp4/.webm/.mov under *output_root*
|
| 448 |
+
that was modified within the last *within_seconds* seconds.
|
| 449 |
+
|
| 450 |
+
Used when the executor's history_result didn't surface a path — typically
|
| 451 |
+
happens when ZeroGPU's subprocess boundary drops the mutation. The disk
|
| 452 |
+
is shared, so the file is there even when the in-memory state isn't.
|
| 453 |
+
"""
|
| 454 |
+
import time
|
| 455 |
+
|
| 456 |
+
if not output_root.exists():
|
| 457 |
+
return None
|
| 458 |
+
cutoff = time.time() - within_seconds
|
| 459 |
+
candidates: list[tuple[float, pathlib.Path]] = []
|
| 460 |
+
for ext in (".mp4", ".webm", ".mov"):
|
| 461 |
+
for p in output_root.rglob(f"*{ext}"):
|
| 462 |
+
try:
|
| 463 |
+
mtime = p.stat().st_mtime
|
| 464 |
+
except OSError:
|
| 465 |
+
continue
|
| 466 |
+
if mtime >= cutoff:
|
| 467 |
+
candidates.append((mtime, p))
|
| 468 |
+
if not candidates:
|
| 469 |
+
return None
|
| 470 |
+
candidates.sort(reverse=True)
|
| 471 |
+
return str(candidates[0][1])
|