A node's menu offers Open in AMB, change model by picture, insert a LoRA before / after, saved values, the model's pictures and details, save values and save as combo, each where it applies; the empty canvas's menu opens AMB's pages. Entries can be switched off, moved, and put in one AMB submenu or right in the menu. The model picker's Esc closes only the picker, wherever the focus is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
13 KiB
JavaScript
262 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, showGalleryViewer } from './ui_gallery.js';
|
|
import { showNotebooks, openCombo, openComboByFilename, refreshNotebooks, saveCurrentNotebook, deleteCurrentNotebook } from './ui_notebooks.js';
|
|
import { renderNotebookEditor, fillNotebookGalleries } from './ui_notebook_editor.js';
|
|
import { sendNotebookToCanvas } from './notebook_canvas.js';
|
|
import { showRecipes, refreshRecipes } from './ui_recipe_catalog.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 character reads the picked lines, or by default.
|
|
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.fillNotebookGalleries = fillNotebookGalleries;
|
|
AnomalousBrowser.prototype.sendNotebookToCanvas = sendNotebookToCanvas;
|
|
|
|
AnomalousBrowser.prototype.showRecipes = showRecipes;
|
|
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;
|