Remove character image analysis flow
This commit is contained in:
+2
-24
@@ -11350,31 +11350,9 @@ const APPENDED_WIDGET_DEFAULTS = [
|
||||
["segment_lengths", ""],
|
||||
];
|
||||
|
||||
app.registerExtension({
|
||||
app.registerExtension({
|
||||
name: "LTXDirectorCS",
|
||||
async setup() {
|
||||
// On Run, ask Ollama to release the analysis model from VRAM so it doesn't
|
||||
// compete with LTX generation. Fully tolerant: failures are swallowed.
|
||||
if (app._ltxDirectorUnloadHookInstalled) return;
|
||||
app._ltxDirectorUnloadHookInstalled = true;
|
||||
const origQueuePrompt = app.queuePrompt;
|
||||
app.queuePrompt = async function (...args) {
|
||||
try {
|
||||
const nodes = app.graph?._nodes || [];
|
||||
const director = nodes.find(n => n && (n.comfyClass === "LTXDirectorCS" || n.type === "LTXDirectorCS"));
|
||||
if (director) {
|
||||
try {
|
||||
await api.fetchApi("/ltx_director/unload_ollama", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ provider: "ollama" }),
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {}
|
||||
return origQueuePrompt.apply(this, args);
|
||||
};
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "LTXDirectorCS") {
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
+2
-302
@@ -1,6 +1,5 @@
|
||||
const { app } = window.comfyAPI.app;
|
||||
const { api } = window.comfyAPI.api;
|
||||
const DEFAULT_ANALYZE_PROMPT = "Describe the character's physical appearance in two concise sentences. Specify their hair color/style, face details, and their clothing type/color. Keep the entire response very brief.";
|
||||
|
||||
function findWidget(node, name) {
|
||||
return (node.widgets || []).find((widget) => widget.name === name);
|
||||
@@ -58,139 +57,6 @@ function resolveCandidateImageUrls(originNode) {
|
||||
return urls;
|
||||
}
|
||||
|
||||
async function blobToOptimizedDataUrl(blob, maxDim = 1024, quality = 0.82) {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Failed to decode image blob"));
|
||||
img.src = objectUrl;
|
||||
});
|
||||
|
||||
const width = image.naturalWidth || image.width || 0;
|
||||
const height = image.naturalHeight || image.height || 0;
|
||||
const scale = width > 0 && height > 0 ? Math.min(1, maxDim / Math.max(width, height)) : 1;
|
||||
const targetWidth = Math.max(1, Math.round(width * scale)) || width || 1;
|
||||
const targetHeight = Math.max(1, Math.round(height * scale)) || height || 1;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("Could not acquire canvas context");
|
||||
}
|
||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
const optimizedBlob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((result) => {
|
||||
if (result) resolve(result);
|
||||
else reject(new Error("Canvas toBlob failed"));
|
||||
}, "image/jpeg", quality);
|
||||
});
|
||||
|
||||
const dataUrl = await new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.readAsDataURL(optimizedBlob);
|
||||
});
|
||||
|
||||
return {
|
||||
dataUrl,
|
||||
originalWidth: width,
|
||||
originalHeight: height,
|
||||
outputWidth: targetWidth,
|
||||
outputHeight: targetHeight,
|
||||
outputBytes: optimizedBlob.size || 0,
|
||||
};
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async function imageInputToDataUrl(node, inputName) {
|
||||
const originNode = getOriginNodeForInput(node, inputName);
|
||||
const candidateUrls = resolveCandidateImageUrls(originNode);
|
||||
if (!candidateUrls.length) {
|
||||
return {
|
||||
dataUrl: null,
|
||||
debug: {
|
||||
inputName,
|
||||
originNodeId: originNode?.id ?? null,
|
||||
originNodeType: originNode?.type ?? null,
|
||||
candidateUrls: [],
|
||||
selectedUrl: null,
|
||||
mimeType: null,
|
||||
blobBytes: 0,
|
||||
dataUrlLength: 0,
|
||||
originalWidth: 0,
|
||||
originalHeight: 0,
|
||||
outputWidth: 0,
|
||||
outputHeight: 0,
|
||||
outputBytes: 0,
|
||||
error: "No candidate image URLs found",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const imageUrl of candidateUrls) {
|
||||
try {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
if (!blob.type.startsWith("image/")) {
|
||||
throw new Error(`Unexpected blob type: ${blob.type || "unknown"}`);
|
||||
}
|
||||
const optimized = await blobToOptimizedDataUrl(blob);
|
||||
const dataUrl = optimized.dataUrl;
|
||||
return {
|
||||
dataUrl,
|
||||
debug: {
|
||||
inputName,
|
||||
originNodeId: originNode?.id ?? null,
|
||||
originNodeType: originNode?.type ?? null,
|
||||
candidateUrls,
|
||||
selectedUrl: imageUrl,
|
||||
mimeType: blob.type || null,
|
||||
blobBytes: blob.size || 0,
|
||||
dataUrlLength: typeof dataUrl === "string" ? dataUrl.length : 0,
|
||||
originalWidth: optimized.originalWidth,
|
||||
originalHeight: optimized.originalHeight,
|
||||
outputWidth: optimized.outputWidth,
|
||||
outputHeight: optimized.outputHeight,
|
||||
outputBytes: optimized.outputBytes,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[MSRCharacter] Failed candidate image source", imageUrl, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: null,
|
||||
debug: {
|
||||
inputName,
|
||||
originNodeId: originNode?.id ?? null,
|
||||
originNodeType: originNode?.type ?? null,
|
||||
candidateUrls,
|
||||
selectedUrl: null,
|
||||
mimeType: null,
|
||||
blobBytes: 0,
|
||||
dataUrlLength: 0,
|
||||
originalWidth: 0,
|
||||
originalHeight: 0,
|
||||
outputWidth: 0,
|
||||
outputHeight: 0,
|
||||
outputBytes: 0,
|
||||
error: "All candidate image URLs failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setWidgetValue(node, widget, value) {
|
||||
if (!widget) return;
|
||||
const previous = widget.value;
|
||||
@@ -220,9 +86,6 @@ function setWidgetValue(node, widget, value) {
|
||||
function syncFormFromWidgets(node) {
|
||||
const description = findWidget(node, "description")?.value || "";
|
||||
const alias = findWidget(node, "alias")?.value || "";
|
||||
const provider = findWidget(node, "analyze_provider")?.value || "ollama";
|
||||
const model = findWidget(node, "analyze_model")?.value || "";
|
||||
const baseUrl = findWidget(node, "analyze_base_url")?.value || "";
|
||||
|
||||
if (node._msrAliasInput && node._msrAliasInput.value !== alias) {
|
||||
node._msrAliasInput.value = alias;
|
||||
@@ -235,8 +98,7 @@ function syncFormFromWidgets(node) {
|
||||
alias
|
||||
? `Alias: <span style="color:#e8e8e8">@${String(alias).replace(/^@/, "")}</span>`
|
||||
: "Alias: <span style=\"color:#666\">none</span>",
|
||||
`<div style="margin-top:4px;color:#888">Analyze: ${provider}${model ? ` / ${model}` : ""}${baseUrl ? ` / ${baseUrl}` : ""}</div>`,
|
||||
`<div style="margin-top:4px;color:#666">Description is stored in the field below.</div>`,
|
||||
`<div style="margin-top:4px;color:#666">Description is stored in the field below and appended to the director prompt as a tagged character reference.</div>`,
|
||||
].join("");
|
||||
}
|
||||
}
|
||||
@@ -323,70 +185,6 @@ function buildCharacterUi(node) {
|
||||
gap: "6px",
|
||||
});
|
||||
|
||||
const actionsRow = document.createElement("div");
|
||||
Object.assign(actionsRow.style, {
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
});
|
||||
|
||||
const analyzeButton = document.createElement("button");
|
||||
analyzeButton.type = "button";
|
||||
analyzeButton.textContent = "Analyze";
|
||||
Object.assign(analyzeButton.style, {
|
||||
alignSelf: "flex-start",
|
||||
background: "#2b4f38",
|
||||
color: "#f3f3f3",
|
||||
border: "1px solid #496d56",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 10px",
|
||||
fontSize: "11px",
|
||||
cursor: "pointer",
|
||||
});
|
||||
analyzeButton.addEventListener("click", () => {
|
||||
const widgetButton = (node.widgets || []).find((widget) => widget.msrAnalyze);
|
||||
if (!widgetButton) {
|
||||
alert("Analyze button widget is missing on this node.");
|
||||
return;
|
||||
}
|
||||
analyzeCharacterNode(node, widgetButton);
|
||||
});
|
||||
|
||||
const settingsButton = document.createElement("button");
|
||||
settingsButton.type = "button";
|
||||
settingsButton.textContent = "Analyze Settings";
|
||||
Object.assign(settingsButton.style, {
|
||||
alignSelf: "flex-start",
|
||||
background: "#252525",
|
||||
color: "#ddd",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "6px",
|
||||
padding: "6px 10px",
|
||||
fontSize: "11px",
|
||||
cursor: "pointer",
|
||||
});
|
||||
settingsButton.addEventListener("click", () => {
|
||||
const providerWidget = findWidget(node, "analyze_provider");
|
||||
const baseUrlWidget = findWidget(node, "analyze_base_url");
|
||||
const modelWidget = findWidget(node, "analyze_model");
|
||||
const promptWidget = findWidget(node, "analyze_prompt");
|
||||
const provider = window.prompt("Analyze provider: ollama, lmstudio, custom, off", providerWidget?.value || "ollama");
|
||||
if (provider == null) return;
|
||||
const baseUrl = window.prompt("Analyze base URL (blank = default)", baseUrlWidget?.value || "");
|
||||
if (baseUrl == null) return;
|
||||
const model = window.prompt("Analyze model (blank = provider default)", modelWidget?.value || "");
|
||||
if (model == null) return;
|
||||
const prompt = window.prompt("Analyze prompt (blank = default)", promptWidget?.value || DEFAULT_ANALYZE_PROMPT);
|
||||
if (prompt == null) return;
|
||||
setWidgetValue(node, providerWidget, provider.trim() || "ollama");
|
||||
setWidgetValue(node, baseUrlWidget, baseUrl.trim());
|
||||
setWidgetValue(node, modelWidget, model.trim());
|
||||
setWidgetValue(node, promptWidget, prompt.trim());
|
||||
syncFormFromWidgets(node);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
});
|
||||
|
||||
const aliasLabel = document.createElement("label");
|
||||
aliasLabel.textContent = "Alias";
|
||||
Object.assign(aliasLabel.style, {
|
||||
@@ -439,13 +237,10 @@ function buildCharacterUi(node) {
|
||||
syncFormFromWidgets(node);
|
||||
});
|
||||
|
||||
actionsRow.appendChild(analyzeButton);
|
||||
actionsRow.appendChild(settingsButton);
|
||||
form.appendChild(aliasLabel);
|
||||
form.appendChild(aliasInput);
|
||||
form.appendChild(descLabel);
|
||||
form.appendChild(descInput);
|
||||
form.appendChild(actionsRow);
|
||||
|
||||
container.appendChild(previewRow);
|
||||
container.appendChild(meta);
|
||||
@@ -456,79 +251,9 @@ function buildCharacterUi(node) {
|
||||
node._msrMeta = meta;
|
||||
node._msrAliasInput = aliasInput;
|
||||
node._msrDescriptionInput = descInput;
|
||||
node._msrAnalyzeButton = analyzeButton;
|
||||
return container;
|
||||
}
|
||||
|
||||
async function analyzeCharacterNode(node, buttonWidget) {
|
||||
const descriptionWidget = findWidget(node, "description");
|
||||
if (!descriptionWidget) return;
|
||||
const providerWidget = findWidget(node, "analyze_provider");
|
||||
const baseUrlWidget = findWidget(node, "analyze_base_url");
|
||||
const modelWidget = findWidget(node, "analyze_model");
|
||||
const promptWidget = findWidget(node, "analyze_prompt");
|
||||
|
||||
buttonWidget.label = "Analyzing...";
|
||||
if (node._msrAnalyzeButton) {
|
||||
node._msrAnalyzeButton.textContent = "Analyzing...";
|
||||
node._msrAnalyzeButton.disabled = true;
|
||||
}
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
|
||||
try {
|
||||
const imageResults = await Promise.all([
|
||||
imageInputToDataUrl(node, "image_1"),
|
||||
imageInputToDataUrl(node, "image_2"),
|
||||
]);
|
||||
const imageDebug = imageResults.map((result) => result?.debug || null).filter(Boolean);
|
||||
const images = (
|
||||
imageResults.map((result) => result?.dataUrl || null)
|
||||
).filter(Boolean);
|
||||
|
||||
if (!images.length) {
|
||||
throw new Error("Connect at least one image input before analyzing.");
|
||||
}
|
||||
|
||||
const response = await api.fetchApi("/ltx_director/analyze_character", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
provider: providerWidget?.value || "ollama",
|
||||
base_url: baseUrlWidget?.value || "",
|
||||
model: modelWidget?.value || "",
|
||||
prompt: promptWidget?.value || "",
|
||||
image_b64: images,
|
||||
image_debug: imageDebug,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.status !== "success") {
|
||||
console.warn("[MSRCharacter] analyze debug", {
|
||||
sentImages: imageDebug,
|
||||
response: result,
|
||||
});
|
||||
throw new Error(result.message || "Unknown analysis error");
|
||||
}
|
||||
|
||||
const description = result.description || "";
|
||||
setWidgetValue(node, descriptionWidget, description);
|
||||
if (node._msrDescriptionInput) {
|
||||
node._msrDescriptionInput.value = description;
|
||||
}
|
||||
syncFormFromWidgets(node);
|
||||
refreshCharacterPreview(node);
|
||||
} catch (error) {
|
||||
console.error("[MSRCharacter] analyze failed", error);
|
||||
alert(`MSR Character analyze failed: ${error.message || error}`);
|
||||
} finally {
|
||||
buttonWidget.label = "Analyze with Ollama";
|
||||
if (node._msrAnalyzeButton) {
|
||||
node._msrAnalyzeButton.textContent = "Analyze";
|
||||
node._msrAnalyzeButton.disabled = false;
|
||||
}
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
function hideNodeWidget(widget) {
|
||||
if (!widget) return;
|
||||
widget.hidden = true;
|
||||
@@ -547,30 +272,6 @@ app.registerExtension({
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (originalOnNodeCreated) originalOnNodeCreated.apply(this, arguments);
|
||||
|
||||
if (!(this.widgets || []).find((widget) => widget.msrAnalyze)) {
|
||||
this.addWidget("button", "Analyze with Ollama", null, () => {
|
||||
analyzeCharacterNode(this, (this.widgets || []).find((widget) => widget.msrAnalyze));
|
||||
}, { serialize: false });
|
||||
const buttonWidget = this.widgets[this.widgets.length - 1];
|
||||
buttonWidget.msrAnalyze = true;
|
||||
buttonWidget.label = "Analyze with Ollama";
|
||||
}
|
||||
|
||||
if (!findWidget(this, "analyze_provider")) {
|
||||
this.addWidget("combo", "analyze_provider", "ollama", null, {
|
||||
values: ["ollama", "lmstudio", "custom", "off"],
|
||||
});
|
||||
}
|
||||
if (!findWidget(this, "analyze_base_url")) {
|
||||
this.addWidget("text", "analyze_base_url", "");
|
||||
}
|
||||
if (!findWidget(this, "analyze_model")) {
|
||||
this.addWidget("text", "analyze_model", "");
|
||||
}
|
||||
if (!findWidget(this, "analyze_prompt")) {
|
||||
this.addWidget("text", "analyze_prompt", DEFAULT_ANALYZE_PROMPT);
|
||||
}
|
||||
|
||||
if (!this._msrPreviewWidget) {
|
||||
const previewContainer = buildCharacterUi(this);
|
||||
this._msrPreviewWidget = this.addDOMWidget("msr_character_ui", "msr_character_ui", previewContainer, {
|
||||
@@ -597,10 +298,9 @@ app.registerExtension({
|
||||
return result;
|
||||
};
|
||||
|
||||
["alias", "description", "analyze_provider", "analyze_base_url", "analyze_model", "analyze_prompt"].forEach((widgetName) => {
|
||||
["alias", "description"].forEach((widgetName) => {
|
||||
hideNodeWidget(findWidget(this, widgetName));
|
||||
});
|
||||
hideNodeWidget((this.widgets || []).find((widget) => widget.msrAnalyze));
|
||||
|
||||
this.size[0] = Math.max(this.size[0] || 0, 330);
|
||||
syncFormFromWidgets(this);
|
||||
|
||||
Reference in New Issue
Block a user