Presets, combos and Add to saves took more to keep sensible than they gave, so a save now only becomes a new workflow card and only the canvas makes one. The panel above the bar moves to the Saves page, a part for each canvas, open or not, with each save's pictures and its runs: the whole group in order, or one save alone. Running moves to snapshot_run.js for the bar and the page; what is clicked goes in in click order, and closed canvases run too. A run taken off ComfyUI's queue is now forgotten at once, so "runs to go" does not stay wrong for a day. A first save on an unsaved tab now reaches ComfyUI's draft of it, so a reload right after keeps the tab's saves on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
13 KiB
JavaScript
264 lines
13 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
import { showDetail } from './ui_detail.js';
|
|
import { showEditModal } from './ui_model_editor.js';
|
|
import { setWidgetValuePath } from './model_widget_path.js';
|
|
import { loadModels, applyModelToCanvas, stopMediaInContainer } from './ui_grid.js';
|
|
import { createDOM, renderSidebar, loadFolders } from './ui_sidebar.js';
|
|
import { closeWorkspace, hideAllPanels } from './ui_browser_navigation.js';
|
|
import { triggerDirectModelScan } from './scan_runner.js';
|
|
import { openScanPage, renderScanPage, leaveScanPage } from './ui_scan_page.js';
|
|
import { loadGalleryImages, refreshGalleryImages, showGeneratedGallery, showGallerySelectMode } from './ui_gallery.js';
|
|
import { showGalleryViewer } from './ui_gallery_viewer.js';
|
|
import { showNotebooks, openCombo, openComboByFilename, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
|
|
import { renderNotebookEditor } from './ui_notebook_editor.js';
|
|
import { sendNotebookToCanvas } from './notebook_canvas.js';
|
|
import { showRecipes, refreshRecipes } from './ui_recipe_catalog.js';
|
|
import { showSaves } from './ui_saves_list.js';
|
|
import { renderRecipeList, handleSaveRecipe, openRecipeByFilename } from './ui_recipes.js';
|
|
import { leavePromptPage, openPromptStudio } from './ui_prompt_composer.js';
|
|
import { closeUpdateGuide } from './ui_update_guide.js';
|
|
import { showImageWorkbench } from './ui_gallery_detail.js';
|
|
import { openDoctorPage } from './ui_doctor.js';
|
|
import { showModelSources } from './ui_model_sources.js';
|
|
import { initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory, diagnoseNode, openCurrentNode, openLoraInsertionPicker } from './ui_node_assistant.js';
|
|
import { _openGalleryReplacer, findModelComboWidget } from './ui_node_model_picker.js';
|
|
import { promptBoxes } from './prompt_boxes.js';
|
|
import { followsSelection, pageForNode } from './shell_open_rules.js';
|
|
import { renderAudioStudio, stopAudioStudioPlayback } from './ui_audio_studio.js';
|
|
import { renderAudioGallery, stopGalleryAudio } from './ui_audio_gallery.js';
|
|
import { renderScriptPage } from './ui_script_page.js';
|
|
import { castScriptCharacter } from './ui_script_director.js';
|
|
import { getActiveAudioFilter, setActiveAudioFilter, syncAudioSidebarSelection } from './ui_audio_sidebar.js';
|
|
import { voiceGroupKey } from './audio_engines.js';
|
|
import { getActiveDomain } from './ui_domain_switcher.js';
|
|
import { startPage } from './ui_shell_nav.js';
|
|
import { followComfyLanguage } from './interface_settings.js';
|
|
|
|
// What a selected node holds, for the page opening the browser shows (shell_open_rules.js).
|
|
const NODE_KINDS = {
|
|
takesPrompt: node => promptBoxes(node).length > 0,
|
|
hasModel: node => Boolean(findModelComboWidget(node)),
|
|
};
|
|
const selectedCanvasNode = () => Object.values(app.canvas?.selected_nodes || {})[0] || null;
|
|
|
|
export class AnomalousBrowser {
|
|
constructor() {
|
|
this.modal = null;
|
|
this.sidebar = null;
|
|
this.grid = null;
|
|
this.detailPanel = null;
|
|
this.currentType = 'loras';
|
|
this.currentPathIdx = 0;
|
|
this.currentSubfolder = '/';
|
|
this.foldersData = null;
|
|
this.collapsedFolders = new Set(); // folders folded in the folder column (type + path), this session
|
|
// Video covers play on hover unless you chose "always": fewer videos decoding at once.
|
|
this.energySaving = localStorage.getItem('anomalous_energy_saving') !== 'false';
|
|
this.cardThumbnailMode = localStorage.getItem('anomalous_card_thumbnail_mode') === 'original'
|
|
? 'original'
|
|
: 'balanced';
|
|
this.entryMode = 'floating';
|
|
this.createDOM();
|
|
}
|
|
|
|
/**
|
|
* Opens the browser. `followSelection`: opened by the user (button, shortcut, menu), so a
|
|
* node selected on the canvas picks the page: a text node opens the Prompts page on it, any
|
|
* other node Current node (Settings -> Opening and window can turn this off). `node`: that
|
|
* page for this node whatever the setting; `page`: this rail page (or 'assistant').
|
|
*/
|
|
show({ followSelection = false, node = null, page: wanted = null } = {}) {
|
|
followComfyLanguage();
|
|
if (this._idleReleaseTimer) {
|
|
clearTimeout(this._idleReleaseTimer);
|
|
this._idleReleaseTimer = null;
|
|
}
|
|
const followed = followSelection && followsSelection() ? selectedCanvasNode() : null;
|
|
const page = wanted || pageForNode(node || followed, NODE_KINDS);
|
|
this.setTriggerVisible(false);
|
|
this.modal.classList.add('visible');
|
|
if (page === 'assistant') {
|
|
this.openCurrentNode();
|
|
return;
|
|
}
|
|
if (page) {
|
|
this.goTo(page);
|
|
return;
|
|
}
|
|
if (!this.currentShellPage()) {
|
|
this.goTo(startPage());
|
|
return;
|
|
}
|
|
// Opened again: stay on the page, refresh what may have changed meanwhile.
|
|
if (getActiveDomain() === 'audio') {
|
|
const audioTabs = { 'audio-gallery': 'gallery', script: 'script' };
|
|
this.switchAudioTab(audioTabs[this.currentShellPage()] || 'presets');
|
|
} else if (!this.foldersData) {
|
|
this.loadFolders();
|
|
} else {
|
|
this.loadModels();
|
|
}
|
|
if (this.currentShellPage() === 'gallery') void this.refreshGalleryImages();
|
|
if (this.currentShellPage() === 'prompts') this.goTo('prompts'); // its studio stopped when the browser closed
|
|
if (this.currentShellPage() === 'scan') void renderScanPage(this, this.scanPanel);
|
|
}
|
|
|
|
/**
|
|
* The canvas menu's "Open in AMB": selects `node` and opens its page; `section:
|
|
* 'parameters'` opens Current node at the node's saved values.
|
|
*/
|
|
showForNode(node, { section = '' } = {}) {
|
|
app.canvas?.selectNode?.(node, false);
|
|
const parameters = section === 'parameters';
|
|
this.show({ node, page: parameters ? 'assistant' : null });
|
|
if (parameters) this.assistantPanel?.querySelector('.anomalous-node-params')?.scrollIntoView({ block: 'start' });
|
|
}
|
|
|
|
/** The details of `model` (GET /anomalous/find_model's: with its type, path_idx and subfolder). */
|
|
openModelDetail(model) {
|
|
this.currentType = model.type || this.currentType;
|
|
this.currentPathIdx = model.path_idx ?? 0;
|
|
this.currentSubfolder = model.subfolder || '/';
|
|
this.show({ page: 'models' });
|
|
this.historyStack = [];
|
|
this.showDetail(model);
|
|
if (this.foldersData) this.renderSidebar();
|
|
}
|
|
|
|
/** The image / audio switch: the first page of that side. */
|
|
handleDomainChange(domain) {
|
|
this.goTo(domain === 'audio' ? 'voices' : 'models');
|
|
}
|
|
|
|
/** Single entry for audio-domain navigation: the rail, the audio list's entries and the domain switch. */
|
|
switchAudioTab(tabName, filter = null) {
|
|
if (!(tabName === 'presets' && filter?.type === 'group' && this.currentShellPage() === 'script')) this.hideAllPanels();
|
|
if (tabName === 'gallery') {
|
|
setActiveAudioFilter({ type: 'gallery', value: null });
|
|
this.markShellPage?.('audio-gallery');
|
|
this.audioGalleryPanel.style.display = 'block';
|
|
renderAudioGallery(this.audioGalleryPanel, { owner: this });
|
|
} else if (tabName === 'script') {
|
|
this.markShellPage?.('script');
|
|
this.scriptPanel.style.display = 'flex';
|
|
const pending = this.pendingScript || {};
|
|
this.pendingScript = null;
|
|
renderScriptPage(this.scriptPanel, this, pending);
|
|
} else if (filter?.type === 'group' && this.currentShellPage() === 'script') {
|
|
// On Voice-over the list is the cast: the side panel shows the character in this script.
|
|
castScriptCharacter(filter.value);
|
|
return;
|
|
} else {
|
|
if (filter) setActiveAudioFilter(filter);
|
|
else if (getActiveAudioFilter().type === 'gallery') setActiveAudioFilter(null);
|
|
this.markShellPage?.('voices');
|
|
this.audioStudioPanel.style.display = 'block';
|
|
renderAudioStudio(this.audioStudioPanel, { owner: this });
|
|
}
|
|
syncAudioSidebarSelection(this);
|
|
}
|
|
|
|
/** One character's voice card: the Anomalous_TTS node's "import or edit characters". */
|
|
openVoice(character) {
|
|
const filter = character ? { type: 'group', value: voiceGroupKey(character), character } : null;
|
|
if (this.currentShellPage() === 'voices') {
|
|
this.switchAudioTab('presets', filter);
|
|
return;
|
|
}
|
|
if (filter) setActiveAudioFilter(filter);
|
|
this.goTo('voices'); // keeps the filter just set
|
|
}
|
|
|
|
/**
|
|
* The Voices page's Voice-over view. `group`: a voice group key to choose (a card, the list);
|
|
* `script`: `{ speech, subfolder }` of a generated file to voice again (the audio gallery).
|
|
*/
|
|
openScript(group = null, script = null) {
|
|
this.pendingScript = { character: group, script };
|
|
if (this.currentShellPage() === 'script') this.switchAudioTab('script');
|
|
else this.goTo('script');
|
|
}
|
|
|
|
close() {
|
|
this.flushCanvasActivity?.();
|
|
leavePromptPage();
|
|
closeUpdateGuide(this);
|
|
leaveScanPage(); // a running scan's progress floats over the canvas
|
|
this.modal.classList.remove('visible');
|
|
this.setTriggerVisible(true);
|
|
stopAudioStudioPlayback();
|
|
stopGalleryAudio();
|
|
const canvas = document.getElementById('graph-canvas');
|
|
if (canvas instanceof HTMLElement) canvas.focus({ preventScroll: true });
|
|
if (this._modelLoadController) this._modelLoadController.abort();
|
|
if (this._modelMediaObserver) this._modelMediaObserver.disconnect();
|
|
this.modal.querySelectorAll('video, audio').forEach(media => media.pause());
|
|
this.stopMediaInContainer(this.grid);
|
|
if (this._idleReleaseTimer) clearTimeout(this._idleReleaseTimer);
|
|
this._idleReleaseTimer = setTimeout(() => {
|
|
if (this.modal.classList.contains('visible')) return;
|
|
this.stopMediaInContainer(this.grid);
|
|
this.grid.replaceChildren();
|
|
this.models = [];
|
|
// The output gallery reloads its first page when opened again.
|
|
this.galleryGrid.querySelectorAll('.anomalous-gallery-card').forEach(card => card.remove());
|
|
this.galleryImagesList = [];
|
|
}, 90000);
|
|
}
|
|
|
|
setTriggerVisible(visible) {
|
|
const trigger = this.triggerButton || document.getElementById('anomalous-trigger-btn');
|
|
trigger?.classList.toggle('anomalous-trigger-hidden', !visible || this.entryMode !== 'floating');
|
|
}
|
|
}
|
|
|
|
AnomalousBrowser.prototype.openDoctorPage = function () { openDoctorPage(this); };
|
|
AnomalousBrowser.prototype.showModelSources = function (scope) { showModelSources(this, scope); };
|
|
AnomalousBrowser.prototype.diagnoseNode = diagnoseNode;
|
|
AnomalousBrowser.prototype.openCurrentNode = function () { openCurrentNode(this); };
|
|
AnomalousBrowser.prototype.initAssistantPanel = initAssistantPanel;
|
|
AnomalousBrowser.prototype.renderAssistantModelCard = renderAssistantModelCard;
|
|
AnomalousBrowser.prototype._loadAssistantHistory = _loadAssistantHistory;
|
|
AnomalousBrowser.prototype._openGalleryReplacer = _openGalleryReplacer;
|
|
AnomalousBrowser.prototype.openLoraInsertionPicker = openLoraInsertionPicker;
|
|
|
|
AnomalousBrowser.prototype.showNotebooks = showNotebooks;
|
|
AnomalousBrowser.prototype.openCombo = openCombo;
|
|
AnomalousBrowser.prototype.openComboByFilename = openComboByFilename;
|
|
AnomalousBrowser.prototype.closeWorkspace = closeWorkspace;
|
|
AnomalousBrowser.prototype.refreshNotebooks = refreshNotebooks;
|
|
AnomalousBrowser.prototype.saveCurrentNotebook = saveCurrentNotebook;
|
|
AnomalousBrowser.prototype.deleteCurrentNotebook = deleteCurrentNotebook;
|
|
AnomalousBrowser.prototype.renderNotebookEditor = renderNotebookEditor;
|
|
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
|
|
|
|
AnomalousBrowser.prototype.showRecipes = showRecipes;
|
|
AnomalousBrowser.prototype.showSaves = showSaves;
|
|
AnomalousBrowser.prototype.openRecipeByFilename = openRecipeByFilename;
|
|
AnomalousBrowser.prototype.refreshRecipes = refreshRecipes;
|
|
AnomalousBrowser.prototype.renderRecipeList = renderRecipeList;
|
|
AnomalousBrowser.prototype.handleSaveRecipe = handleSaveRecipe;
|
|
AnomalousBrowser.prototype.openPromptStudio = openPromptStudio;
|
|
|
|
AnomalousBrowser.prototype.loadGalleryImages = loadGalleryImages;
|
|
AnomalousBrowser.prototype.refreshGalleryImages = refreshGalleryImages;
|
|
AnomalousBrowser.prototype.showGeneratedGallery = showGeneratedGallery;
|
|
AnomalousBrowser.prototype.showGallerySelectMode = showGallerySelectMode;
|
|
AnomalousBrowser.prototype.showGalleryViewer = showGalleryViewer;
|
|
AnomalousBrowser.prototype.showImageWorkbench = showImageWorkbench;
|
|
|
|
AnomalousBrowser.prototype.createDOM = createDOM;
|
|
AnomalousBrowser.prototype.openScanPage = function (view) { openScanPage(this, view); };
|
|
AnomalousBrowser.prototype.scanSingleModel = triggerDirectModelScan;
|
|
AnomalousBrowser.prototype.renderSidebar = renderSidebar;
|
|
AnomalousBrowser.prototype.loadFolders = loadFolders;
|
|
AnomalousBrowser.prototype.hideAllPanels = hideAllPanels;
|
|
|
|
AnomalousBrowser.prototype.loadModels = loadModels;
|
|
AnomalousBrowser.prototype.applyModelToCanvas = applyModelToCanvas;
|
|
AnomalousBrowser.prototype.stopMediaInContainer = stopMediaInContainer;
|
|
|
|
AnomalousBrowser.prototype.showDetail = showDetail;
|
|
AnomalousBrowser.prototype.showEditModal = showEditModal;
|
|
AnomalousBrowser.prototype.setWidgetValuePath = setWidgetValuePath;
|