editor: reconnect to running generation after UI reload + Temp files browser
Reconnect-after-reload: editor-queued prompts are now stamped with a stable client_id and a funpack run-identity payload in ComfyUI's queue extra_data. On boot the editor checks /api/active, re-attaches to any still-running run (loading its project, blocking Generate, exposing Interrupt, recording the result), and loops over queued montage runs until the queue drains. Run identity lives in ComfyUI's ephemeral queue, not a persistent cache. Temp files browser (Settings ▸ Temp files): media-bin-style grid over ComfyUI's temp dir for scene previews and other transient outputs. Open in a new tab (same-origin /view) or save to disk. No in-app player by design. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0baf5de91f
commit
1340070aed
@@ -497,8 +497,16 @@ class ComfyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
async def queue_prompt(graph: dict, client_id: Optional[str] = None) -> dict:
|
||||
# Stable client_id stamped on every editor-queued prompt so a reloaded UI can tell its
|
||||
# own in-flight generation apart from unrelated ComfyUI jobs (see active_generation).
|
||||
EDITOR_CLIENT_ID = "funpack-movie-editor"
|
||||
|
||||
|
||||
async def queue_prompt(graph: dict, client_id: Optional[str] = None,
|
||||
extra_data: Optional[dict] = None) -> dict:
|
||||
payload = {"prompt": graph, "client_id": client_id or uuid.uuid4().hex}
|
||||
if extra_data:
|
||||
payload["extra_data"] = dict(extra_data)
|
||||
async with await _session() as s:
|
||||
async with s.post(_url("/prompt"), json=payload) as r:
|
||||
data = await r.json()
|
||||
@@ -588,6 +596,51 @@ async def is_running(prompt_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _queue_item_extra(item: list) -> dict:
|
||||
"""extra_data dict from a /queue tuple [number, prompt_id, prompt, extra_data, ...]."""
|
||||
extra = item[3] if len(item) > 3 else None
|
||||
return extra if isinstance(extra, dict) else {}
|
||||
|
||||
|
||||
def _select_active(queue_state: dict) -> dict:
|
||||
"""Pick the editor's own in-flight generation out of a /queue snapshot.
|
||||
|
||||
Filters by EDITOR_CLIENT_ID so unrelated ComfyUI jobs are ignored, and reads back the
|
||||
``funpack`` payload (project id + target scenes) stamped at queue time so a reloaded
|
||||
client can re-attach and record the result onto the right scenes."""
|
||||
running, pending = [], []
|
||||
for item in queue_state.get("queue_running", []) or []:
|
||||
if _queue_item_extra(item).get("client_id") == EDITOR_CLIENT_ID:
|
||||
running.append(item)
|
||||
for item in queue_state.get("queue_pending", []) or []:
|
||||
if _queue_item_extra(item).get("client_id") == EDITOR_CLIENT_ID:
|
||||
pending.append(item)
|
||||
out = {"running": bool(running), "prompt_id": None, "pid": None,
|
||||
"scene_ids": [], "only_scene": None, "pending": len(pending)}
|
||||
if running:
|
||||
item = running[0]
|
||||
out["prompt_id"] = item[1] if len(item) > 1 else None
|
||||
fp = _queue_item_extra(item).get("funpack") or {}
|
||||
if isinstance(fp, dict):
|
||||
out["pid"] = fp.get("pid")
|
||||
out["scene_ids"] = fp.get("scene_ids") or []
|
||||
out["only_scene"] = fp.get("only_scene")
|
||||
return out
|
||||
|
||||
|
||||
async def active_generation() -> dict:
|
||||
"""The editor's own in-flight generation, recovered from ComfyUI's queue.
|
||||
|
||||
Survives a UI reload: the queue lives in the (still-running) ComfyUI process, so the
|
||||
reloaded client can re-block Generate, expose Interrupt, and record the result when it
|
||||
finishes. See _select_active for the filtering."""
|
||||
async with await _session() as s:
|
||||
async with s.get(_url("/queue")) as r:
|
||||
r.raise_for_status()
|
||||
state = await r.json()
|
||||
return _select_active(state)
|
||||
|
||||
|
||||
_object_info_cache = {"data": None}
|
||||
|
||||
|
||||
|
||||
@@ -139,9 +139,31 @@
|
||||
j("POST", API(`/projects/${id}/generate`), { only_scene: onlyScene || null, scene_ids: sceneIds || null, reset_session: !!resetSession, node_overrides: nodeOverrides || null }),
|
||||
status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)),
|
||||
progress: () => j("GET", API("/progress")),
|
||||
// The editor's own in-flight generation, recovered from ComfyUI's queue (survives a UI reload).
|
||||
active: () => j("GET", API("/active")),
|
||||
ratingLabels: () => j("GET", API("/rating-labels")),
|
||||
log: (limit) => j("GET", API("/log" + (limit ? `?limit=${limit}` : ""))),
|
||||
interrupt: () => j("POST", API("/interrupt")),
|
||||
|
||||
// ComfyUI temp folder browser (scene previews & other transient outputs, wiped on restart).
|
||||
listTemp: () => j("GET", API("/temp")),
|
||||
// Served by ComfyUI's own same-origin /view endpoint — no editor route needed.
|
||||
tempFileUrl: (f) => "/view?" + new URLSearchParams({
|
||||
filename: f.filename, subfolder: f.subfolder || "", type: "temp", t: String(f.mtime || Date.now()),
|
||||
}).toString(),
|
||||
async downloadTempFile(f) {
|
||||
const res = await fetch(this.tempFileUrl(f), { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Could not fetch ${f.filename} (HTTP ${res.status})`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = f.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
renderFinal: (id, clips) => j("POST", API(`/projects/${id}/render`), { clips }),
|
||||
renderFinalStatus: (id, jobId) => j("GET", API(`/projects/${id}/render/${encodeURIComponent(jobId)}`)),
|
||||
exportClip: (id, clip) => j("POST", API(`/projects/${id}/export-clip`), { clip }),
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
<script src="sampler_panel.js"></script>
|
||||
<script src="engine_settings.js"></script>
|
||||
<script src="editor_settings.js"></script>
|
||||
<script src="temp_browser.js"></script>
|
||||
<script src="autocomplete.js"></script>
|
||||
<script src="slot_picker.js"></script>
|
||||
<script src="welcome.js"></script>
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
{ label: "Editor settings…", action: () => window.EditorSettingsModal.open() },
|
||||
{ label: "Engine settings…", disabled: !hasProject() || (!studioOn && !chainOn),
|
||||
action: () => window.EngineSettingsModal.open() },
|
||||
{ label: "Temp files…", action: () => window.TempBrowserModal.open() },
|
||||
{ sep: true },
|
||||
{ label: "Models…", action: () => window.ModelsModal.open() },
|
||||
{ label: "Import ComfyUI Workflow…", disabled: !hasProject(),
|
||||
|
||||
@@ -2802,6 +2802,70 @@
|
||||
try { await API.interrupt(); } catch (_) {}
|
||||
}
|
||||
|
||||
// Re-attach to a generation that was already running when the UI (re)loaded. The job
|
||||
// lives in ComfyUI's still-running queue; bridge.active_generation hands back its
|
||||
// prompt id + the project/scenes it targets. We restore the "running" gen state so
|
||||
// Generate stays blocked and Interrupt is offered — exactly like a live run — and,
|
||||
// when the matching project is loaded, record the result onto its scenes on completion.
|
||||
async function resumeRunningGeneration(act) {
|
||||
if (!act || !act.running || !act.prompt_id) return false;
|
||||
_interrupted = false;
|
||||
// A reloaded montage can have further runs still queued behind the running one. Re-attach
|
||||
// to each in turn so Generate stays blocked (and outputs get recorded) until the queue
|
||||
// drains — not just for the one prompt that happened to be running at reload.
|
||||
let cur = act;
|
||||
let lastPromptId = null;
|
||||
while (cur && cur.running && cur.prompt_id && cur.prompt_id !== lastPromptId && !_interrupted) {
|
||||
lastPromptId = cur.prompt_id;
|
||||
pollStart = Date.now();
|
||||
const sceneIds = (cur.scene_ids && cur.scene_ids.length)
|
||||
? cur.scene_ids
|
||||
: (cur.only_scene ? [cur.only_scene] : []);
|
||||
const more = cur.pending > 0 ? ` · ${cur.pending} run(s) queued` : "";
|
||||
set({ gen: { state: "running", promptId: cur.prompt_id, media: [], msg: `Generation running (reconnected after reload)…${more}` } });
|
||||
if (state.project && cur.pid && state.project.id === cur.pid && sceneIds.length) {
|
||||
// Full re-attach: poll history and place the output on the run's scenes when done.
|
||||
_markGenInFlight(sceneIds);
|
||||
try { await _pollPromise(cur.prompt_id, sceneIds, "Generation running (reconnected)"); }
|
||||
finally { _clearGenInFlight(sceneIds); }
|
||||
} else {
|
||||
// No matching project loaded (or single-scene run on another project): we can't map
|
||||
// the output, but still surface Interrupt and clear busy when it stops.
|
||||
await _monitorActive(cur.prompt_id);
|
||||
}
|
||||
if (_interrupted) break;
|
||||
// Another queued run may now be executing — re-attach to it.
|
||||
try { cur = await API.active(); } catch (_) { cur = null; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Lightweight queue watcher used when we can't fully re-attach (see above). Polls
|
||||
// /active until the prompt leaves the queue or the user interrupts.
|
||||
function _monitorActive(promptId) {
|
||||
return new Promise((resolve) => {
|
||||
_clearGenTimers();
|
||||
pollTimer = setInterval(async () => {
|
||||
if (_interrupted) {
|
||||
_clearGenTimers();
|
||||
set({ gen: { state: "idle", promptId, media: [], msg: "Interrupted." } });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const a = await API.active();
|
||||
if (!a || !a.running || a.prompt_id !== promptId) {
|
||||
_clearGenTimers();
|
||||
set({ gen: { state: "done", promptId, media: [], msg: "Generation finished. Open Settings ▸ Temp files to view or save the output, then regenerate to place it." } });
|
||||
resolve();
|
||||
} else {
|
||||
updateGenProgress({ msg: `Generation running (reconnected) ${_elapsed()}` });
|
||||
}
|
||||
} catch (_) { /* transient — keep watching */ }
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function _elapsed() {
|
||||
const s = Math.floor((Date.now() - pollStart) / 1000);
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
@@ -4031,8 +4095,24 @@
|
||||
await loadModels();
|
||||
window.addEventListener("funpack-models-changed", loadModels);
|
||||
await refreshProjectList();
|
||||
// Re-attach to a generation already running in ComfyUI (UI was reloaded mid-run).
|
||||
// Load its project first so Generate is blocked, Interrupt is shown, and the result
|
||||
// lands on the right scenes — then skip the welcome page, there's work in flight.
|
||||
let resumed = false;
|
||||
try {
|
||||
const act = await API.active();
|
||||
if (act && act.running && act.prompt_id) {
|
||||
if (act.pid && (state.projects || []).some((p) => p.id === act.pid)
|
||||
&& (!state.project || state.project.id !== act.pid)) {
|
||||
await loadProject(act.pid);
|
||||
}
|
||||
notify();
|
||||
resumeRunningGeneration(act); // fire-and-forget poll loop
|
||||
resumed = true;
|
||||
}
|
||||
} catch (_) { /* queue unreachable — boot normally */ }
|
||||
notify();
|
||||
if (window.WelcomePage) window.WelcomePage.open();
|
||||
if (!resumed && window.WelcomePage) window.WelcomePage.open();
|
||||
}
|
||||
|
||||
window.Store = {
|
||||
|
||||
@@ -1151,7 +1151,18 @@ body.tl-spacing { cursor: grabbing; user-select: none; }
|
||||
.engine-modal-summary { font-size: 12px; color: var(--muted); font-family: var(--font-mono); margin-bottom: 12px; padding: 8px 10px; background: var(--ink-2); border-radius: 8px; border: 1px solid var(--line); }
|
||||
.modal-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); background: linear-gradient(#1b1813, #161410); }
|
||||
.modal-title { font-family: var(--font-display); font-weight: 700; font-size: 17px; }
|
||||
.modal-head-right { margin-left: auto; display: flex; gap: 8px; }
|
||||
.modal-head-right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* Temp files browser */
|
||||
.tmp-count { white-space: nowrap; }
|
||||
.tmp-body { margin-top: 12px; }
|
||||
.tmp-grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); }
|
||||
.tmp-card { cursor: default; padding-bottom: 6px; }
|
||||
.tmp-card .media-thumb { height: 76px; }
|
||||
.tmp-card .media-name { font-size: 11px; word-break: break-all; }
|
||||
.tmp-meta { padding: 0 8px; }
|
||||
.tmp-card-acts { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 8px 0; }
|
||||
.tmp-card-acts .btn { flex: 1; white-space: nowrap; }
|
||||
.modal-content { padding: 16px; overflow: auto; }
|
||||
|
||||
.composer { background: var(--ink-0); border: 1px solid var(--line); border-radius: 10px; padding: 14px; margin-bottom: 18px; }
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Temp files browser: a lightweight media-bin view over ComfyUI's temp directory
|
||||
// (scene previews & other transient outputs that are wiped on restart). Opened from the
|
||||
// Settings menu. Each item can be opened in a new tab or saved to disk — no in-app player.
|
||||
(function () {
|
||||
const { el, clear } = window.dom;
|
||||
const API = window.MovieEditorAPI;
|
||||
|
||||
let overlay = null;
|
||||
let files = [];
|
||||
|
||||
function close() { if (overlay) { overlay.remove(); overlay = null; } }
|
||||
|
||||
function _fmtSize(n) {
|
||||
if (!n && n !== 0) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function _appendThumb(thumb, f) {
|
||||
const url = API.tempFileUrl(f);
|
||||
if (f.kind === "image") {
|
||||
const img = el("img");
|
||||
img.src = url; img.loading = "lazy";
|
||||
thumb.append(img);
|
||||
return;
|
||||
}
|
||||
if (f.kind === "video") {
|
||||
const ph = el("span", "media-icon media-vid-ph", "▶");
|
||||
thumb.append(ph);
|
||||
const vid = document.createElement("video");
|
||||
vid.className = "media-vid-thumb";
|
||||
vid.muted = true; vid.preload = "metadata"; vid.playsInline = true;
|
||||
vid.src = url;
|
||||
vid.onloadeddata = () => {
|
||||
if (!thumb.isConnected) return;
|
||||
ph.remove();
|
||||
if (!thumb.querySelector("video")) thumb.append(vid);
|
||||
};
|
||||
return;
|
||||
}
|
||||
thumb.append(el("span", "media-icon media-aud-ph", "♪"));
|
||||
}
|
||||
|
||||
function _card(f) {
|
||||
const card = el("div", "media-card tmp-card");
|
||||
const thumb = el("div", "media-thumb");
|
||||
_appendThumb(thumb, f);
|
||||
card.append(thumb);
|
||||
|
||||
const nameRow = el("div", "media-name-row");
|
||||
const nameEl = el("div", "media-name", f.filename);
|
||||
nameEl.title = f.subfolder ? `${f.subfolder}/${f.filename}` : f.filename;
|
||||
nameRow.append(nameEl);
|
||||
card.append(nameRow);
|
||||
|
||||
const meta = el("div", "pj-meta tmp-meta", _fmtSize(f.size));
|
||||
card.append(meta);
|
||||
|
||||
const acts = el("div", "tmp-card-acts");
|
||||
if (f.kind === "video" || f.kind === "image" || f.kind === "audio") {
|
||||
const open = el("button", "btn ghost tiny", f.kind === "video" ? "↗ Open video" : "↗ Open");
|
||||
open.title = "Open in a new browser tab";
|
||||
open.onclick = () => window.open(API.tempFileUrl(f), "_blank");
|
||||
acts.append(open);
|
||||
}
|
||||
const save = el("button", "btn ghost tiny", "⤓ Save");
|
||||
save.title = "Download to your computer";
|
||||
save.onclick = async () => {
|
||||
save.disabled = true;
|
||||
try { await API.downloadTempFile(f); }
|
||||
catch (e) { alert("Save failed: " + (e.message || e)); }
|
||||
finally { save.disabled = false; }
|
||||
};
|
||||
acts.append(save);
|
||||
card.append(acts);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderBody(body) {
|
||||
clear(body);
|
||||
if (!files.length) {
|
||||
body.append(el("div", "pj-meta media-grid-empty", "No temp media right now. Scene previews and other transient outputs appear here while ComfyUI is running."));
|
||||
return;
|
||||
}
|
||||
const grid = el("div", "media-grid tmp-grid");
|
||||
files.forEach((f) => grid.append(_card(f)));
|
||||
body.append(grid);
|
||||
}
|
||||
|
||||
async function refresh(body, countEl) {
|
||||
body.append(el("div", "pj-meta", "Loading…"));
|
||||
try {
|
||||
files = (await API.listTemp()).files || [];
|
||||
} catch (e) {
|
||||
clear(body);
|
||||
body.append(el("div", "pj-meta media-grid-empty", "Could not list temp files: " + (e.message || e)));
|
||||
return;
|
||||
}
|
||||
if (countEl) countEl.textContent = files.length ? `${files.length} file(s)` : "";
|
||||
clear(body);
|
||||
renderBody(body);
|
||||
}
|
||||
|
||||
function open() {
|
||||
close();
|
||||
overlay = el("div", "modal-overlay");
|
||||
const box = el("div", "modal");
|
||||
const head = el("div", "modal-head");
|
||||
head.append(el("div", "modal-title", "Temp files"));
|
||||
const hr = el("div", "modal-head-right");
|
||||
const countEl = el("span", "pj-meta tmp-count");
|
||||
const refreshBtn = el("button", "btn ghost tiny", "⟳ Refresh");
|
||||
const x = el("button", "btn ghost tiny", "✕"); x.onclick = close;
|
||||
hr.append(countEl, refreshBtn, x);
|
||||
head.append(hr); box.append(head);
|
||||
|
||||
const content = el("div", "modal-content");
|
||||
content.append(el("div", "es-hint",
|
||||
"Transient ComfyUI outputs — scene previews and the like. These are wiped when ComfyUI restarts, so save anything worth keeping."));
|
||||
const body = el("div", "tmp-body");
|
||||
content.append(body);
|
||||
box.append(content);
|
||||
overlay.append(box);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||||
document.body.append(overlay);
|
||||
|
||||
refreshBtn.onclick = () => refresh(body, countEl);
|
||||
refresh(body, countEl);
|
||||
}
|
||||
|
||||
window.TempBrowserModal = { open, close };
|
||||
})();
|
||||
+74
-1
@@ -105,6 +105,54 @@ def _resolve_comfy_media_path(filename: str, subfolder: str = "", type_: str = "
|
||||
return os.path.join(base, subfolder or "", filename)
|
||||
|
||||
|
||||
_TEMP_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
|
||||
_TEMP_VIDEO_EXT = {".mp4", ".webm", ".mov", ".mkv", ".avi", ".gif"}
|
||||
_TEMP_AUDIO_EXT = {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus"}
|
||||
|
||||
|
||||
def _temp_kind(ext: str) -> Optional[str]:
|
||||
ext = ext.lower()
|
||||
if ext in _TEMP_VIDEO_EXT:
|
||||
return "video"
|
||||
if ext in _TEMP_IMAGE_EXT:
|
||||
return "image"
|
||||
if ext in _TEMP_AUDIO_EXT:
|
||||
return "audio"
|
||||
return None
|
||||
|
||||
|
||||
def _list_temp_media() -> list[dict]:
|
||||
"""Media files (image/video/audio) sitting in ComfyUI's temp directory — scene
|
||||
previews and other transient outputs that are wiped on restart. Newest first."""
|
||||
import os
|
||||
try:
|
||||
import folder_paths
|
||||
base = folder_paths.get_temp_directory()
|
||||
except Exception:
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for root, _dirs, files in os.walk(base):
|
||||
for name in files:
|
||||
kind = _temp_kind(os.path.splitext(name)[1])
|
||||
if not kind:
|
||||
continue
|
||||
full = os.path.join(root, name)
|
||||
try:
|
||||
st = os.stat(full)
|
||||
except OSError:
|
||||
continue
|
||||
subfolder = os.path.relpath(root, base)
|
||||
out.append({
|
||||
"filename": name,
|
||||
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, "/"),
|
||||
"kind": kind,
|
||||
"size": st.st_size,
|
||||
"mtime": st.st_mtime,
|
||||
})
|
||||
out.sort(key=lambda i: i.get("mtime", 0), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_clip_src_path(clip: dict) -> str:
|
||||
"""Absolute path to a render or media-bin file referenced by an export/import clip spec."""
|
||||
import os
|
||||
@@ -1538,8 +1586,17 @@ if web is not None and PromptServer is not None:
|
||||
applied_ovr = _apply_node_overrides(graph, body.get("node_overrides"))
|
||||
if applied_ovr:
|
||||
report.setdefault("node_overrides", []).extend(applied_ovr)
|
||||
# Stamp the queued prompt with our client_id + run identity so a reloaded UI can
|
||||
# re-attach to this generation (block Generate, expose Interrupt, record the result).
|
||||
fp_meta = {
|
||||
"pid": p.id,
|
||||
"scene_ids": list(scene_ids) if scene_ids else None,
|
||||
"only_scene": body.get("only_scene") or None,
|
||||
}
|
||||
try:
|
||||
result = await bridge.queue_prompt(graph)
|
||||
result = await bridge.queue_prompt(
|
||||
graph, client_id=bridge.EDITOR_CLIENT_ID, extra_data={"funpack": fp_meta},
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response({"detail": f"Failed to queue with ComfyUI: {e}"}, status=502)
|
||||
prompt_id = result.get("prompt_id")
|
||||
@@ -1621,6 +1678,22 @@ if web is not None and PromptServer is not None:
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response({"detail": str(e)}, status=502)
|
||||
|
||||
@routes.get(UI_PREFIX + "/api/active")
|
||||
async def _active(_req):
|
||||
# The editor's own in-flight generation, recovered from ComfyUI's queue so a
|
||||
# reloaded UI can re-attach (block Generate, expose Interrupt). Never raises —
|
||||
# an unreachable queue just means "nothing we can re-attach to".
|
||||
try:
|
||||
return web.json_response(await bridge.active_generation())
|
||||
except Exception as e: # noqa: BLE001
|
||||
return web.json_response(
|
||||
{"running": False, "prompt_id": None, "pid": None,
|
||||
"scene_ids": [], "only_scene": None, "pending": 0, "error": str(e)})
|
||||
|
||||
@routes.get(UI_PREFIX + "/api/temp")
|
||||
async def _temp_list(_req):
|
||||
return web.json_response({"files": _list_temp_media()})
|
||||
|
||||
@routes.get(UI_PREFIX + "/api/projects/{pid}/result")
|
||||
async def _result(req):
|
||||
import os
|
||||
|
||||
@@ -20,6 +20,53 @@ def test_ensure_funpack_path_adds_repo_root():
|
||||
assert root in sys.path
|
||||
|
||||
|
||||
def _q_item(prompt_id, client_id, funpack=None):
|
||||
extra = {"client_id": client_id}
|
||||
if funpack is not None:
|
||||
extra["funpack"] = funpack
|
||||
return [1, prompt_id, {"graph": True}, extra, ["9"]]
|
||||
|
||||
|
||||
def test_select_active_picks_editor_running_job_with_metadata():
|
||||
state = {
|
||||
"queue_running": [_q_item("p1", bridge.EDITOR_CLIENT_ID,
|
||||
{"pid": "proj-7", "scene_ids": ["a", "b"], "only_scene": None})],
|
||||
"queue_pending": [_q_item("p2", bridge.EDITOR_CLIENT_ID, {"pid": "proj-7"})],
|
||||
}
|
||||
out = bridge._select_active(state)
|
||||
assert out["running"] is True
|
||||
assert out["prompt_id"] == "p1"
|
||||
assert out["pid"] == "proj-7"
|
||||
assert out["scene_ids"] == ["a", "b"]
|
||||
assert out["pending"] == 1
|
||||
|
||||
|
||||
def test_select_active_ignores_foreign_client_jobs():
|
||||
state = {
|
||||
"queue_running": [_q_item("other", "some-comfy-tab")],
|
||||
"queue_pending": [_q_item("other2", "some-comfy-tab")],
|
||||
}
|
||||
out = bridge._select_active(state)
|
||||
assert out["running"] is False
|
||||
assert out["prompt_id"] is None
|
||||
assert out["pending"] == 0
|
||||
|
||||
|
||||
def test_select_active_empty_queue():
|
||||
out = bridge._select_active({})
|
||||
assert out == {"running": False, "prompt_id": None, "pid": None,
|
||||
"scene_ids": [], "only_scene": None, "pending": 0}
|
||||
|
||||
|
||||
def test_select_active_running_without_funpack_meta():
|
||||
state = {"queue_running": [_q_item("p1", bridge.EDITOR_CLIENT_ID)]}
|
||||
out = bridge._select_active(state)
|
||||
assert out["running"] is True
|
||||
assert out["prompt_id"] == "p1"
|
||||
assert out["pid"] is None
|
||||
assert out["scene_ids"] == []
|
||||
|
||||
|
||||
def test_rating_labels_hide_internal_editor_values(monkeypatch):
|
||||
def _fake_attr(_mod, name):
|
||||
table = {
|
||||
|
||||
@@ -28,6 +28,38 @@ from movie_editor.server import (
|
||||
)
|
||||
|
||||
CONTINUE = "__funpack_continue__"
|
||||
|
||||
|
||||
def test_temp_kind_classifies_media_and_skips_other():
|
||||
assert srv._temp_kind(".mp4") == "video"
|
||||
assert srv._temp_kind(".PNG") == "image"
|
||||
assert srv._temp_kind(".wav") == "audio"
|
||||
assert srv._temp_kind(".txt") is None
|
||||
assert srv._temp_kind(".json") is None
|
||||
|
||||
|
||||
def test_list_temp_media_lists_media_newest_first(tmp_path, monkeypatch):
|
||||
(tmp_path / "old.png").write_bytes(b"x")
|
||||
(tmp_path / "new.mp4").write_bytes(b"yy")
|
||||
(tmp_path / "notes.txt").write_text("skip me")
|
||||
sub = tmp_path / "previews"
|
||||
sub.mkdir()
|
||||
(sub / "clip.webm").write_bytes(b"zzz")
|
||||
import os
|
||||
os.utime(tmp_path / "old.png", (1000, 1000))
|
||||
os.utime(tmp_path / "new.mp4", (2000, 2000))
|
||||
os.utime(sub / "clip.webm", (1500, 1500))
|
||||
|
||||
fake = type("FP", (), {"get_temp_directory": staticmethod(lambda: str(tmp_path))})
|
||||
monkeypatch.setitem(__import__("sys").modules, "folder_paths", fake)
|
||||
|
||||
files = srv._list_temp_media()
|
||||
names = [f["filename"] for f in files]
|
||||
assert "notes.txt" not in names
|
||||
assert names == ["new.mp4", "clip.webm", "old.png"] # newest mtime first
|
||||
clip = next(f for f in files if f["filename"] == "clip.webm")
|
||||
assert clip["subfolder"] == "previews"
|
||||
assert clip["kind"] == "video"
|
||||
FRESH = "__funpack_fresh_prompt__"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user