Files
FillipandClaude Opus 4.6 8e16057d2d Initial commit: FL PixelGen node pack for ComfyUI
Pixel-space diffusion text-to-image generation with REPA LoRA training.
6 nodes: Model Loader, Text Encoder Loader, Generate, Training Config, Train, LoRA Loader.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:21:27 -08:00

949 lines
25 KiB
JavaScript

import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// ---------------------------------------------------------------------------
// Styles — identical to AceStep Training widget visual
// ---------------------------------------------------------------------------
const TRAINING_WIDGET_STYLES = `
.pixelgen-training-widget {
--primary: #06b6d4;
--primary-glow: rgba(6, 182, 212, 0.4);
--secondary: #8b5cf6;
--success: #22c55e;
--danger: #ef4444;
--warning: #f59e0b;
--bg-dark: #0f0f12;
--bg-card: #18181b;
--bg-elevated: #1f1f23;
--border: #27272a;
--border-hover: #3f3f46;
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
background: var(--bg-card);
border-radius: 12px;
border: 1px solid var(--border);
overflow: hidden;
position: relative;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--text-primary);
box-sizing: border-box;
height: 100%;
min-height: 300px;
display: flex;
flex-direction: column;
}
.pixelgen-training-widget * {
box-sizing: border-box;
}
/* Header */
.pixelgen-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.pixelgen-title {
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.pixelgen-badge {
padding: 2px 8px;
background: var(--primary);
color: white;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
}
.pixelgen-badge.idle {
background: var(--text-muted);
}
.pixelgen-badge.training {
background: var(--success);
animation: pixelgen-pulse 2s infinite;
}
@keyframes pixelgen-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* Content */
.pixelgen-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 10px;
gap: 8px;
overflow: hidden;
}
/* Stats — compact inline row */
.pixelgen-stats {
display: flex;
gap: 12px;
justify-content: center;
align-items: baseline;
flex-shrink: 0;
}
.pixelgen-stat {
display: flex;
align-items: baseline;
gap: 4px;
}
.pixelgen-stat-label {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
}
.pixelgen-stat-value {
font-size: 11px;
font-weight: 600;
color: var(--primary);
font-variant-numeric: tabular-nums;
}
/* Progress Bar */
.pixelgen-progress-section {
background: var(--bg-elevated);
border-radius: 6px;
padding: 8px 10px;
flex-shrink: 0;
}
.pixelgen-progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.pixelgen-progress-label {
font-size: 9px;
color: var(--text-secondary);
}
.pixelgen-progress-value {
font-size: 9px;
color: var(--text-primary);
font-weight: 500;
}
.pixelgen-progress-bar {
height: 4px;
background: var(--bg-dark);
border-radius: 2px;
overflow: hidden;
}
.pixelgen-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--secondary));
border-radius: 2px;
transition: width 0.3s ease;
width: 0%;
}
/* Loss Chart */
.pixelgen-chart-section {
background: var(--bg-elevated);
border-radius: 6px;
padding: 8px 10px;
flex: 1;
min-height: 80px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pixelgen-chart-header {
font-size: 9px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.pixelgen-chart-canvas {
width: 100%;
height: 100%;
display: block;
}
/* Status */
.pixelgen-status {
background: var(--bg-elevated);
border-radius: 6px;
padding: 6px 10px;
font-size: 10px;
color: var(--text-secondary);
text-align: center;
border-left: 3px solid var(--primary);
flex-shrink: 0;
}
.pixelgen-status.error {
border-left-color: var(--danger);
color: var(--danger);
}
.pixelgen-status.success {
border-left-color: var(--success);
color: var(--success);
}
/* Validation Carousel */
.pixelgen-preview-section {
background: var(--bg-elevated);
border-radius: 6px;
padding: 8px 10px;
flex: 1;
min-height: 80px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pixelgen-preview-header {
font-size: 9px;
color: var(--text-secondary);
margin-bottom: 6px;
flex-shrink: 0;
}
.pixelgen-preview-carousel {
display: flex;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
flex: 1;
align-items: center;
padding-bottom: 4px;
scrollbar-width: thin;
scrollbar-color: var(--border-hover) transparent;
}
.pixelgen-preview-empty {
width: 100%;
text-align: center;
font-size: 10px;
color: var(--text-muted);
}
.pixelgen-preview-carousel::-webkit-scrollbar {
height: 4px;
}
.pixelgen-preview-carousel::-webkit-scrollbar-track {
background: transparent;
}
.pixelgen-preview-carousel::-webkit-scrollbar-thumb {
background: var(--border-hover);
border-radius: 2px;
}
.pixelgen-preview-tile {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.pixelgen-preview-tile img {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border);
cursor: pointer;
transition: border-color 0.2s;
}
.pixelgen-preview-tile img:hover {
border-color: var(--primary);
}
.pixelgen-preview-tile .tile-label {
font-size: 9px;
color: var(--text-muted);
text-align: center;
white-space: nowrap;
}
/* Lightbox overlay */
.pixelgen-lightbox {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: pixelgen-fade-in 0.2s ease-out;
}
@keyframes pixelgen-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.pixelgen-lightbox img {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
}
.pixelgen-lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 48px; height: 48px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
border: 2px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 22px;
transition: all 0.2s ease;
}
.pixelgen-lightbox-nav:hover {
background: rgba(6, 182, 212, 0.6);
border-color: #06b6d4;
}
.pixelgen-lightbox-nav.left { left: 20px; }
.pixelgen-lightbox-nav.right { right: 20px; }
.pixelgen-lightbox-close {
position: absolute;
top: 16px; right: 16px;
width: 40px; height: 40px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
cursor: pointer;
color: white;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.pixelgen-lightbox-close:hover {
background: rgba(239, 68, 68, 0.6);
}
.pixelgen-lightbox-label {
position: absolute;
bottom: 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
`;
// ---------------------------------------------------------------------------
// TrainingWidget — 1:1 visual match with AceStep Training widget
// ---------------------------------------------------------------------------
class TrainingWidget {
constructor(options) {
this.node = options.node;
this.container = options.container;
this.element = document.createElement("div");
this.element.className = "pixelgen-training-widget";
// UI elements
this.badgeEl = null;
this.epochValueEl = null;
this.stepValueEl = null;
this.lossValueEl = null;
this.progressFillEl = null;
this.progressLabelEl = null;
this.statusEl = null;
this.canvasEl = null;
// State
this.lossHistory = [];
this.isTraining = false;
// Resize handling
this.resizeObserver = null;
this.resizeTimeout = null;
this.injectStyles();
this.createUI();
this.container.appendChild(this.element);
}
injectStyles() {
const styleId = "pixelgen-training-styles";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = TRAINING_WIDGET_STYLES;
document.head.appendChild(style);
}
}
createUI() {
this.element.innerHTML = `
<div class="pixelgen-header">
<div class="pixelgen-title">
<span>PixelGen Training</span>
<span class="pixelgen-badge idle">Idle</span>
</div>
</div>
<div class="pixelgen-content">
<div class="pixelgen-stats">
<div class="pixelgen-stat">
<span class="pixelgen-stat-label">Epoch</span>
<span class="pixelgen-stat-value" data-stat="epoch">0/0</span>
</div>
<div class="pixelgen-stat">
<span class="pixelgen-stat-label">Step</span>
<span class="pixelgen-stat-value" data-stat="step">0</span>
</div>
<div class="pixelgen-stat">
<span class="pixelgen-stat-label">Loss</span>
<span class="pixelgen-stat-value" data-stat="loss">-</span>
</div>
</div>
<div class="pixelgen-progress-section">
<div class="pixelgen-progress-header">
<span class="pixelgen-progress-label">Training Progress</span>
<span class="pixelgen-progress-value" data-progress-label>0%</span>
</div>
<div class="pixelgen-progress-bar">
<div class="pixelgen-progress-fill" data-progress-fill></div>
</div>
</div>
<div class="pixelgen-chart-section">
<div class="pixelgen-chart-header">Loss History</div>
<canvas class="pixelgen-chart-canvas"></canvas>
</div>
<div class="pixelgen-status">
Ready to train
</div>
<div class="pixelgen-preview-section">
<div class="pixelgen-preview-header">Validation Samples</div>
<div class="pixelgen-preview-carousel">
<div class="pixelgen-preview-empty">Samples will appear at each checkpoint</div>
</div>
</div>
</div>
`;
// Get references
this.badgeEl = this.element.querySelector(".pixelgen-badge");
this.epochValueEl = this.element.querySelector('[data-stat="epoch"]');
this.stepValueEl = this.element.querySelector('[data-stat="step"]');
this.lossValueEl = this.element.querySelector('[data-stat="loss"]');
this.progressFillEl = this.element.querySelector("[data-progress-fill]");
this.progressLabelEl = this.element.querySelector("[data-progress-label]");
this.statusEl = this.element.querySelector(".pixelgen-status");
this.canvasEl = this.element.querySelector(".pixelgen-chart-canvas");
// Set up ResizeObserver for canvas scaling - watch container, not canvas
const chartSection = this.element.querySelector(".pixelgen-chart-section");
if (this.canvasEl && chartSection) {
this.resizeObserver = new ResizeObserver(() => {
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
this.resizeTimeout = window.setTimeout(() => {
this.drawChart();
}, 16); // ~60fps max
});
this.resizeObserver.observe(chartSection);
}
// Draw initial chart
this.drawChart();
}
updateProgress(epoch, totalEpochs, loss, step) {
this.isTraining = true;
// Update badge
if (this.badgeEl) {
this.badgeEl.textContent = "Training";
this.badgeEl.className = "pixelgen-badge training";
}
// Update stats
if (this.epochValueEl) {
this.epochValueEl.textContent = `${epoch}/${totalEpochs}`;
}
if (this.stepValueEl) {
this.stepValueEl.textContent = step.toString();
}
if (this.lossValueEl) {
this.lossValueEl.textContent = loss.toFixed(6);
}
// Update progress bar
const progress = (epoch / totalEpochs) * 100;
if (this.progressFillEl) {
this.progressFillEl.style.width = `${progress}%`;
}
if (this.progressLabelEl) {
this.progressLabelEl.textContent = `${progress.toFixed(1)}%`;
}
}
updateLossHistory(history) {
this.lossHistory = history;
this.drawChart();
}
updateStatus(message, type = "normal") {
if (this.statusEl) {
this.statusEl.textContent = message;
this.statusEl.className = "pixelgen-status";
if (type !== "normal") {
this.statusEl.classList.add(type);
}
}
}
onTrainingComplete(finalPath) {
this.isTraining = false;
if (this.badgeEl) {
this.badgeEl.textContent = "Complete";
this.badgeEl.className = "pixelgen-badge";
this.badgeEl.style.background = "#22c55e";
}
this.updateStatus(`Training complete! Saved to: ${finalPath}`, "success");
}
addValidationImage(imageBase64, checkpointPath) {
const carousel = this.element.querySelector(".pixelgen-preview-carousel");
if (!carousel) return;
// Clear placeholder on first image
const empty = carousel.querySelector(".pixelgen-preview-empty");
if (empty) empty.remove();
// Extract a short label from the checkpoint path
const parts = checkpointPath.replace(/\\/g, "/").split("/");
const label = parts.find((p) => p.startsWith("checkpoint-epoch-")) || parts[parts.length - 1];
const shortLabel = label.replace("checkpoint-epoch-", "E");
// Create tile
const tile = document.createElement("div");
tile.className = "pixelgen-preview-tile";
const img = document.createElement("img");
img.src = imageBase64;
img.title = checkpointPath;
img.addEventListener("click", () => {
const tiles = carousel.querySelectorAll(".pixelgen-preview-tile");
const images = Array.from(tiles).map((t) => ({
src: t.querySelector("img").src,
label: t.querySelector(".tile-label")?.textContent || "",
}));
const index = Array.from(tiles).indexOf(tile);
this.openLightbox(images, index >= 0 ? index : 0);
});
const labelEl = document.createElement("div");
labelEl.className = "tile-label";
labelEl.textContent = shortLabel;
tile.appendChild(img);
tile.appendChild(labelEl);
carousel.appendChild(tile);
// Auto-scroll to the newest tile
carousel.scrollLeft = carousel.scrollWidth;
}
openLightbox(images, startIndex) {
if (this._lightbox) this.closeLightbox();
if (!images.length) return;
let currentIndex = startIndex;
const overlay = document.createElement("div");
overlay.className = "pixelgen-lightbox";
const mainImg = document.createElement("img");
const label = document.createElement("div");
label.className = "pixelgen-lightbox-label";
const update = () => {
mainImg.src = images[currentIndex].src;
label.textContent = `${images[currentIndex].label} (${currentIndex + 1} / ${images.length})`;
};
// Nav arrows
const leftBtn = document.createElement("button");
leftBtn.className = "pixelgen-lightbox-nav left";
leftBtn.innerHTML = "&#8249;";
leftBtn.onclick = (e) => { e.stopPropagation(); currentIndex = (currentIndex - 1 + images.length) % images.length; update(); };
const rightBtn = document.createElement("button");
rightBtn.className = "pixelgen-lightbox-nav right";
rightBtn.innerHTML = "&#8250;";
rightBtn.onclick = (e) => { e.stopPropagation(); currentIndex = (currentIndex + 1) % images.length; update(); };
// Close button
const closeBtn = document.createElement("button");
closeBtn.className = "pixelgen-lightbox-close";
closeBtn.innerHTML = "&#10005;";
closeBtn.onclick = () => this.closeLightbox();
// Click backdrop to close
overlay.onclick = (e) => { if (e.target === overlay) this.closeLightbox(); };
// Keyboard navigation
this._lightboxKeyHandler = (e) => {
if (e.key === "Escape") this.closeLightbox();
if (e.key === "ArrowLeft") { currentIndex = (currentIndex - 1 + images.length) % images.length; update(); }
if (e.key === "ArrowRight") { currentIndex = (currentIndex + 1) % images.length; update(); }
};
document.addEventListener("keydown", this._lightboxKeyHandler);
overlay.append(mainImg, leftBtn, rightBtn, closeBtn, label);
document.body.appendChild(overlay);
this._lightbox = overlay;
update();
}
closeLightbox() {
if (!this._lightbox) return;
if (this._lightboxKeyHandler) {
document.removeEventListener("keydown", this._lightboxKeyHandler);
this._lightboxKeyHandler = null;
}
this._lightbox.style.animation = "pixelgen-fade-in 0.15s ease-in reverse";
const el = this._lightbox;
this._lightbox = null;
setTimeout(() => {
if (el.parentNode) el.parentNode.removeChild(el);
}, 150);
}
reset() {
this.lossHistory = [];
this.isTraining = false;
if (this.badgeEl) {
this.badgeEl.textContent = "Training";
this.badgeEl.className = "pixelgen-badge training";
}
if (this.epochValueEl) this.epochValueEl.textContent = "0/0";
if (this.stepValueEl) this.stepValueEl.textContent = "0";
if (this.lossValueEl) this.lossValueEl.textContent = "-";
if (this.progressFillEl) this.progressFillEl.style.width = "0%";
if (this.progressLabelEl) this.progressLabelEl.textContent = "0%";
this.updateStatus("Starting training...");
this.drawChart();
// Clear preview carousel and restore placeholder
const carousel = this.element.querySelector(".pixelgen-preview-carousel");
if (carousel) {
carousel.innerHTML = '<div class="pixelgen-preview-empty">Samples will appear at each checkpoint</div>';
}
}
drawChart() {
if (!this.canvasEl) return;
const canvas = this.canvasEl;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Get actual size
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
const w = rect.width;
const h = rect.height;
// Clear
ctx.fillStyle = "#0f0f12";
ctx.fillRect(0, 0, w, h);
// No data message
if (this.lossHistory.length < 2) {
ctx.fillStyle = "#71717a";
ctx.font = "11px Inter, sans-serif";
ctx.textAlign = "center";
ctx.fillText("Waiting for training data...", w / 2, h / 2);
return;
}
// Calculate scales
const padding = { top: 20, right: 20, bottom: 25, left: 50 };
const chartW = w - padding.left - padding.right;
const chartH = h - padding.top - padding.bottom;
const maxStep = Math.max(...this.lossHistory.map((d) => d.step));
const minStep = Math.min(...this.lossHistory.map((d) => d.step));
const maxLoss = Math.max(...this.lossHistory.map((d) => d.loss));
const minLoss = Math.min(...this.lossHistory.map((d) => d.loss));
const lossRange = maxLoss - minLoss || 1;
// Draw grid
ctx.strokeStyle = "#27272a";
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const y = padding.top + (chartH / 4) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(w - padding.right, y);
ctx.stroke();
// Y-axis labels
const lossVal = maxLoss - (lossRange / 4) * i;
ctx.fillStyle = "#71717a";
ctx.font = "9px Inter, sans-serif";
ctx.textAlign = "right";
ctx.fillText(lossVal.toFixed(4), padding.left - 5, y + 3);
}
// Draw loss line
ctx.strokeStyle = "#06b6d4";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
this.lossHistory.forEach((point, i) => {
const x = padding.left + ((point.step - minStep) / (maxStep - minStep || 1)) * chartW;
const y = padding.top + chartH - ((point.loss - minLoss) / lossRange) * chartH;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
// Draw gradient fill
const gradient = ctx.createLinearGradient(0, padding.top, 0, h - padding.bottom);
gradient.addColorStop(0, "rgba(6, 182, 212, 0.3)");
gradient.addColorStop(1, "rgba(6, 182, 212, 0)");
ctx.fillStyle = gradient;
ctx.beginPath();
this.lossHistory.forEach((point, i) => {
const x = padding.left + ((point.step - minStep) / (maxStep - minStep || 1)) * chartW;
const y = padding.top + chartH - ((point.loss - minLoss) / lossRange) * chartH;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
// Close the path
const lastPoint = this.lossHistory[this.lossHistory.length - 1];
const lastX = padding.left + ((lastPoint.step - minStep) / (maxStep - minStep || 1)) * chartW;
ctx.lineTo(lastX, padding.top + chartH);
ctx.lineTo(padding.left, padding.top + chartH);
ctx.closePath();
ctx.fill();
// Current loss label
ctx.fillStyle = "#fafafa";
ctx.font = "bold 10px Inter, sans-serif";
ctx.textAlign = "left";
ctx.fillText(`Loss: ${lastPoint.loss.toFixed(6)}`, padding.left + 5, padding.top + 12);
}
dispose() {
this.closeLightbox();
// Clean up ResizeObserver
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = null;
}
if (this.element && this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
}
}
// ---------------------------------------------------------------------------
// ComfyUI Extension Registration
// ---------------------------------------------------------------------------
const widgetInstances = new Map();
function createTrainingWidget(node) {
const container = document.createElement("div");
container.id = `pixelgen-training-widget-${node.id}`;
container.style.width = "100%";
container.style.height = "100%";
container.style.minHeight = "280px";
const widget = node.addDOMWidget(
"training_ui",
"training-widget",
container,
{
getMinHeight: () => 400,
hideOnZoom: false,
serialize: false,
}
);
setTimeout(() => {
const trainingWidget = new TrainingWidget({
node,
container,
});
widgetInstances.set(node.id, trainingWidget);
}, 100);
widget.onRemove = () => {
const instance = widgetInstances.get(node.id);
if (instance) {
instance.dispose();
widgetInstances.delete(node.id);
}
};
return { widget };
}
app.registerExtension({
name: "ComfyUI.FL_PixelGen_Training",
nodeCreated(node) {
const comfyClass = (node.constructor && node.constructor.comfyClass) || "";
// FL_ theming for all PixelGen nodes
if (comfyClass.startsWith("FL_PixelGen")) {
node.color = "#16727c";
node.bgcolor = "#4F0074";
}
// Training widget only on Train node
if (comfyClass !== "FL_PixelGen_Train") {
return;
}
const [oldWidth, oldHeight] = node.size;
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 600)]);
createTrainingWidget(node);
},
});
// Listen for training progress events
api.addEventListener("pixelgen.training.progress", (event) => {
const detail = event.detail;
if (!detail || !detail.node) return;
const nodeId = parseInt(detail.node, 10);
const widget = widgetInstances.get(nodeId);
if (!widget) return;
switch (detail.type) {
case "progress":
widget.updateProgress(
detail.epoch ?? 0,
detail.total_epochs ?? 0,
detail.loss ?? 0,
detail.step ?? 0
);
if (detail.loss_history) {
widget.updateLossHistory(detail.loss_history);
}
break;
case "status":
widget.updateStatus(detail.message ?? "");
break;
case "checkpoint":
if (detail.checkpoint_path) {
widget.updateStatus(`Checkpoint saved: ${detail.checkpoint_path}`, "success");
}
if (detail.validation_image && detail.checkpoint_path) {
widget.addValidationImage(detail.validation_image, detail.checkpoint_path);
}
break;
case "complete":
if (detail.final_path) {
widget.onTrainingComplete(detail.final_path);
}
break;
}
});
// Listen for node execution results
api.addEventListener("executed", (event) => {
const detail = event.detail;
if (!detail || !detail.node || !detail.output) return;
const nodeId = parseInt(detail.node, 10);
const widget = widgetInstances.get(nodeId);
if (!widget) return;
const finalPath = detail.output && detail.output.lora_path;
if (finalPath && finalPath.length > 0) {
widget.onTrainingComplete(finalPath[0]);
}
});
// Listen for execution start to reset the widget
api.addEventListener("executing", (event) => {
const detail = event.detail;
if (!detail || !detail.node) return;
const nodeId = parseInt(detail.node, 10);
const widget = widgetInstances.get(nodeId);
if (widget) {
widget.reset();
}
});