diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py
index 44872434..1003ee0b 100644
--- a/movie_editor/backend/bridge.py
+++ b/movie_editor/backend/bridge.py
@@ -53,6 +53,65 @@ def parse_timeline_verbatim(prompt: str) -> dict:
return split_timeline_verbatim(str(prompt or ""))
+# ── ComfyUI log capture ───────────────────────────────────────────────────────
+# Tee stdout/stderr into a ring buffer so the editor can show ComfyUI's real backend
+# log (writes still pass through to the terminal). Captures print() and logging.
+import collections as _collections
+import threading as _threading
+
+_LOG = _collections.deque(maxlen=5000)
+_LOG_LOCK = _threading.Lock()
+_log_installed = False
+
+
+class _Tee:
+ def __init__(self, orig):
+ self._orig = orig
+ self._buf = ""
+
+ def write(self, s):
+ try:
+ self._orig.write(s)
+ except Exception:
+ pass
+ try:
+ with _LOG_LOCK:
+ self._buf += s
+ while "\n" in self._buf:
+ line, self._buf = self._buf.split("\n", 1)
+ _LOG.append(line)
+ except Exception:
+ pass
+ return len(s) if isinstance(s, str) else 0
+
+ def flush(self):
+ try:
+ self._orig.flush()
+ except Exception:
+ pass
+
+ def __getattr__(self, name):
+ return getattr(self._orig, name)
+
+
+def install_log_capture():
+ global _log_installed
+ if _log_installed:
+ return
+ import sys
+ try:
+ sys.stdout = _Tee(sys.stdout)
+ sys.stderr = _Tee(sys.stderr)
+ _log_installed = True
+ except Exception:
+ pass
+
+
+def recent_log(limit: int = 500) -> list:
+ with _LOG_LOCK:
+ return list(_LOG)[-int(limit):]
+
+
def rating_labels() -> dict:
"""FunPack Studio V2 rating labels (for the Scene rating dropdown)."""
try:
diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js
index 1a4d6444..b231657f 100644
--- a/movie_editor/frontend/api.js
+++ b/movie_editor/frontend/api.js
@@ -80,6 +80,7 @@
status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)),
progress: () => j("GET", API("/progress")),
ratingLabels: () => j("GET", API("/rating-labels")),
+ log: (limit) => j("GET", API("/log" + (limit ? `?limit=${limit}` : ""))),
interrupt: () => j("POST", API("/interrupt")),
renderFinal: (id, clips) => j("POST", API(`/projects/${id}/render`), { clips }),
resultUrl: (id, m) =>
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index 0937c134..962c1c15 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -89,6 +89,7 @@
+
diff --git a/movie_editor/frontend/log.js b/movie_editor/frontend/log.js
new file mode 100644
index 00000000..63d46918
--- /dev/null
+++ b/movie_editor/frontend/log.js
@@ -0,0 +1,45 @@
+// Menubar "Log" button + a collapsible panel showing ComfyUI's real backend log
+// (tee'd stdout/stderr from the server). Polls only while open.
+(function () {
+ const API = window.MovieEditorAPI;
+ const right = document.querySelector(".menubar-right");
+ if (!right) return;
+
+ const btn = document.createElement("button");
+ btn.className = "log-btn"; btn.textContent = "Log"; btn.title = "Show the ComfyUI backend log";
+ right.insertBefore(btn, right.firstChild);
+
+ const panel = document.createElement("div");
+ panel.className = "log-panel"; panel.hidden = true;
+ const head = document.createElement("div"); head.className = "log-head";
+ const title = document.createElement("span"); title.textContent = "ComfyUI log";
+ const actions = document.createElement("div"); actions.className = "log-actions";
+ head.append(title, actions);
+ const bodyEl = document.createElement("pre"); bodyEl.className = "log-body";
+ panel.append(head, bodyEl);
+ document.body.append(panel);
+
+ let timer = null, autoscroll = true;
+
+ async function refresh() {
+ try {
+ const r = await API.log(800);
+ bodyEl.textContent = (r.lines || []).join("\n");
+ if (autoscroll) bodyEl.scrollTop = bodyEl.scrollHeight;
+ } catch (e) {
+ bodyEl.textContent = "(log unavailable: " + e.message + ")";
+ }
+ }
+ bodyEl.addEventListener("scroll", () => {
+ autoscroll = bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 40;
+ });
+
+ function open() { panel.hidden = false; btn.classList.add("active"); autoscroll = true; refresh(); timer = setInterval(refresh, 1500); }
+ function close() { panel.hidden = true; btn.classList.remove("active"); clearInterval(timer); timer = null; }
+ btn.onclick = () => (panel.hidden ? open() : close());
+
+ const copy = document.createElement("button"); copy.className = "btn ghost tiny"; copy.textContent = "Copy";
+ copy.onclick = () => { try { navigator.clipboard.writeText(bodyEl.textContent); copy.textContent = "Copied"; setTimeout(() => (copy.textContent = "Copy"), 1200); } catch (_) {} };
+ const x = document.createElement("button"); x.className = "btn ghost tiny"; x.textContent = "✕"; x.onclick = close;
+ actions.append(copy, x);
+})();
diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css
index e71ae418..1ba5f046 100644
--- a/movie_editor/frontend/styles.css
+++ b/movie_editor/frontend/styles.css
@@ -108,6 +108,20 @@ button, input, select, textarea { font-family: inherit; color: var(--text); }
.save-chip { border-color: transparent; color: var(--faint); }
.save-chip.dirty { color: var(--amber); }
+/* backend log viewer */
+.log-btn { background: var(--ink-2); border: 1px solid var(--line); border-radius: 20px; color: var(--muted);
+ font-family: var(--font-mono); font-size: 11px; letter-spacing: .5px; padding: 3px 11px; cursor: pointer; }
+.log-btn:hover { color: var(--amber-2); background: var(--ink-3); }
+.log-btn.active { color: var(--amber-2); border-color: color-mix(in srgb, var(--amber) 45%, var(--line)); }
+.log-panel { position: fixed; z-index: 9000; right: 14px; top: 48px; width: min(720px, 88vw); height: min(420px, 60vh);
+ background: #0c0b09f2; border: 1px solid var(--line); border-radius: 10px; display: flex; flex-direction: column;
+ box-shadow: 0 16px 40px -12px #000; overflow: hidden; }
+.log-head { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; border-bottom: 1px solid var(--line);
+ font-family: var(--font-mono); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--amber); }
+.log-actions { display: flex; gap: 6px; }
+.log-body { flex: 1; margin: 0; padding: 8px 10px; overflow: auto; white-space: pre-wrap; word-break: break-word;
+ font-family: var(--font-mono); font-size: 11px; line-height: 1.45; color: var(--muted); background: #060606; }
+
/* ───────────────────────── WORKSPACE GRID ───────────────────────── */
.workspace { height: calc(100% - var(--menubar-h)); display: flex; flex-direction: column; }
.row-main { flex: 1; display: flex; min-height: 0; }
diff --git a/movie_editor/server.py b/movie_editor/server.py
index 6b071781..838f5307 100644
--- a/movie_editor/server.py
+++ b/movie_editor/server.py
@@ -188,6 +188,7 @@ def _serve_static(tail: str) -> "web.Response":
if web is not None and PromptServer is not None:
routes = PromptServer.instance.routes
config.ensure_dirs()
+ bridge.install_log_capture() # tee ComfyUI stdout/stderr for the Log viewer
# --- API: projects ---
@routes.get(UI_PREFIX + "/api/health")
@@ -522,6 +523,14 @@ if web is not None and PromptServer is not None:
async def _progress(_req):
return web.json_response(bridge.current_progress())
+ @routes.get(UI_PREFIX + "/api/log")
+ async def _log(req):
+ try:
+ limit = int(req.query.get("limit", "600"))
+ except (TypeError, ValueError):
+ limit = 600
+ return web.json_response({"lines": bridge.recent_log(limit)})
+
@routes.post(UI_PREFIX + "/api/interrupt")
async def _interrupt(_req):
try: