Two pieces of the "port v4's exact UI onto v5's backend" plan: - core/contract.py: new optional ModuleSpec.category field (closed set: continuity, guidance, conditioning, sampling, post, system), validated in core/schema.py, threaded through core/registry.py's FIELDS and to_manifest(). Presentation grouping, independent of STAGE (execution order) -- a module can be stage="sampling" and category="guidance" at once. Only the 4 existing modules that actually render a settings panel (alg, sharpen, appearance, full_control) got a category assigned; the other 12 have none to categorize (no SETTINGS block) and stay uncategorised, which is valid. - v4's frontend (movie_editor/frontend/*, ~29k lines, 61 files) copied verbatim into app/legacy/. core/routes.py's "/" route now serves legacy/index.html instead of the composer entry point; a <base> tag in that file resolves its own relative script/link tags against /funpack/app/legacy/ regardless of what URL it's served at, so every file moved as a unit with zero per-file path edits. Verified live: all 61 assets load 200 OK, the real v4 onboarding screen renders pixel-for- pixel (it's the actual DOM/CSS, not a reimplementation), and several v4 API calls (/api/media, /api/projects, /api/git/status) already succeed against v5's real backend by coincidence of shared route naming -- the rest 404/405 as expected, since api.js's ~90 functions haven't been rewired to v5's actual routes yet (next piece of this plan). The old composer entry point (app/index.html, app/composer/**) stays in the tree, unreferenced, until a later cleanup pass once nothing imports it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
196 lines
6.9 KiB
JavaScript
196 lines
6.9 KiB
JavaScript
// Menubar "Log" button + a collapsible panel showing ComfyUI's real backend log
|
|
// (tee'd stdout/stderr from the server). Polls only while open. Freezes during selection to preserve user interaction.
|
|
(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, lastText = "", isFrozen = false, isManuallyPaused = false, pendingText = null;
|
|
// Set when a poll fails. Held so the panel keeps the last lines the server managed to send
|
|
// instead of replacing them with the error that means the server is gone.
|
|
let disconnected = false;
|
|
|
|
// Helper to check if a node is inside bodyEl (handles text nodes too).
|
|
function nodeInBody(node) {
|
|
return node === bodyEl || bodyEl.contains(node);
|
|
}
|
|
|
|
// Helper to check if user has a non-collapsed selection inside bodyEl.
|
|
function selectionInBody() {
|
|
const sel = window.getSelection();
|
|
if (sel.isCollapsed) return false;
|
|
return nodeInBody(sel.anchorNode) || nodeInBody(sel.focusNode);
|
|
}
|
|
|
|
async function refresh() {
|
|
if (isManuallyPaused) return;
|
|
|
|
try {
|
|
const r = await API.log(800);
|
|
const newText = (r.lines || []).join("\n");
|
|
|
|
// Skip DOM write if text hasn't changed — unless we are coming back from a
|
|
// disconnect, where the body still carries the "connection lost" note to clear.
|
|
if (newText === lastText && !disconnected) return;
|
|
if (disconnected) {
|
|
disconnected = false;
|
|
updateConnectionIndicator();
|
|
}
|
|
|
|
// If user is selecting, freeze updates and keep polling. textContent assignment
|
|
// destroys the text node tree, which nukes the selection mid-copy, so we defer the
|
|
// write until the selection is released or moves out of the log.
|
|
if (selectionInBody()) {
|
|
isFrozen = true;
|
|
pendingText = newText;
|
|
updateFreezeIndicator();
|
|
return;
|
|
}
|
|
|
|
// Update is safe to apply.
|
|
lastText = newText;
|
|
render(newText);
|
|
if (isFrozen) {
|
|
isFrozen = false;
|
|
pendingText = null;
|
|
updateFreezeIndicator();
|
|
}
|
|
} catch (e) {
|
|
// KEEP what we already have. The log going unavailable almost always means the backend
|
|
// just died — which is the one moment its last lines matter most, and replacing them
|
|
// with the error destroyed the evidence (and what Copy would have put on the
|
|
// clipboard). Say it is disconnected; do not wipe it.
|
|
if (!disconnected) {
|
|
disconnected = true;
|
|
render(lastText, "\n\n— connection lost (" + (e && e.message ? e.message : "backend "
|
|
+ "unreachable") + "). The lines above are the last the server sent. —");
|
|
updateConnectionIndicator();
|
|
}
|
|
}
|
|
}
|
|
|
|
// The body is the last good log plus an optional trailing note. `lastText` stays the log
|
|
// ALONE, so Copy hands over the log rather than the log plus our commentary.
|
|
function render(text, note) {
|
|
bodyEl.textContent = text + (note || "");
|
|
if (autoscroll) bodyEl.scrollTop = bodyEl.scrollHeight;
|
|
}
|
|
|
|
function updateConnectionIndicator() {
|
|
const existing = actions.querySelector(".log-disconnected");
|
|
if (disconnected) {
|
|
if (!existing) {
|
|
const chip = document.createElement("span");
|
|
chip.className = "log-freeze-indicator log-disconnected";
|
|
chip.textContent = "disconnected — log held";
|
|
actions.insertBefore(chip, actions.firstChild);
|
|
}
|
|
} else if (existing) {
|
|
existing.remove();
|
|
}
|
|
}
|
|
|
|
bodyEl.addEventListener("scroll", () => {
|
|
autoscroll = bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 40;
|
|
});
|
|
|
|
function updateFreezeIndicator() {
|
|
const existingIndicator = actions.querySelector(".log-freeze-indicator");
|
|
if (isFrozen) {
|
|
if (!existingIndicator) {
|
|
const indicator = document.createElement("span");
|
|
indicator.className = "log-freeze-indicator";
|
|
indicator.textContent = "paused — selection held";
|
|
actions.insertBefore(indicator, actions.firstChild);
|
|
}
|
|
} else {
|
|
if (existingIndicator) existingIndicator.remove();
|
|
}
|
|
}
|
|
|
|
function open() {
|
|
panel.hidden = false;
|
|
btn.classList.add("active");
|
|
autoscroll = true;
|
|
isFrozen = false;
|
|
isManuallyPaused = false;
|
|
pendingText = null;
|
|
updatePauseButton();
|
|
updateFreezeIndicator();
|
|
updateConnectionIndicator();
|
|
refresh();
|
|
timer = setInterval(refresh, 1500);
|
|
}
|
|
|
|
function close() {
|
|
panel.hidden = true;
|
|
btn.classList.remove("active");
|
|
clearInterval(timer);
|
|
timer = null;
|
|
isFrozen = false;
|
|
isManuallyPaused = false;
|
|
pendingText = null;
|
|
updateFreezeIndicator();
|
|
}
|
|
|
|
btn.onclick = () => (panel.hidden ? open() : close());
|
|
|
|
// Helper to get the currently selected text within bodyEl.
|
|
function getSelectedText() {
|
|
const sel = window.getSelection();
|
|
if (sel.isCollapsed) return null;
|
|
if (!nodeInBody(sel.anchorNode) && !nodeInBody(sel.focusNode)) return null;
|
|
return sel.toString();
|
|
}
|
|
|
|
const copy = document.createElement("button");
|
|
copy.className = "btn ghost tiny";
|
|
copy.textContent = "Copy";
|
|
copy.onclick = () => {
|
|
try {
|
|
const selectedText = getSelectedText();
|
|
const textToCopy = selectedText || lastText;
|
|
navigator.clipboard.writeText(textToCopy);
|
|
copy.textContent = selectedText ? "Copied selection" : "Copied";
|
|
setTimeout(() => (copy.textContent = "Copy"), 1200);
|
|
} catch (_) {}
|
|
};
|
|
|
|
const pauseBtn = document.createElement("button");
|
|
pauseBtn.className = "btn ghost tiny";
|
|
pauseBtn.textContent = "⏸ Pause";
|
|
pauseBtn.onclick = () => {
|
|
isManuallyPaused = !isManuallyPaused;
|
|
updatePauseButton();
|
|
// Refetch rather than flushing pendingText: nothing was polled while paused, so the
|
|
// only text held is whatever a selection froze earlier and it may be minutes stale.
|
|
if (!isManuallyPaused) { pendingText = null; refresh(); }
|
|
};
|
|
|
|
function updatePauseButton() {
|
|
pauseBtn.textContent = isManuallyPaused ? "▶ Resume" : "⏸ Pause";
|
|
pauseBtn.classList.toggle("on", isManuallyPaused);
|
|
}
|
|
|
|
const x = document.createElement("button");
|
|
x.className = "btn ghost tiny";
|
|
x.textContent = "✕";
|
|
x.onclick = close;
|
|
|
|
actions.append(copy, pauseBtn, x);
|
|
})();
|