mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-26 07:35:44 -03:00
feat(lora-cycler): add sequential LoRA cycling through filtered pool
Add Lora Cycler node that cycles through LoRAs sequentially from a filtered pool. Supports configurable sort order, strength settings, and persists cycle progress across workflow save/load. Backend: - New LoraCyclerNode with cycle() method - New /api/lm/loras/cycler-list endpoint - LoraService.get_cycler_list() for filtered/sorted list Frontend: - LoraCyclerWidget with Vue.js component - useLoraCyclerState composable - LoraCyclerSettingsView for UI display
This commit is contained in:
@@ -10,6 +10,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
|||||||
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
|
||||||
from .py.nodes.lora_pool import LoraPoolNode
|
from .py.nodes.lora_pool import LoraPoolNode
|
||||||
from .py.nodes.lora_randomizer import LoraRandomizerNode
|
from .py.nodes.lora_randomizer import LoraRandomizerNode
|
||||||
|
from .py.nodes.lora_cycler import LoraCyclerNode
|
||||||
from .py.metadata_collector import init as init_metadata_collector
|
from .py.metadata_collector import init as init_metadata_collector
|
||||||
except (
|
except (
|
||||||
ImportError
|
ImportError
|
||||||
@@ -46,6 +47,9 @@ except (
|
|||||||
LoraRandomizerNode = importlib.import_module(
|
LoraRandomizerNode = importlib.import_module(
|
||||||
"py.nodes.lora_randomizer"
|
"py.nodes.lora_randomizer"
|
||||||
).LoraRandomizerNode
|
).LoraRandomizerNode
|
||||||
|
LoraCyclerNode = importlib.import_module(
|
||||||
|
"py.nodes.lora_cycler"
|
||||||
|
).LoraCyclerNode
|
||||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -60,6 +64,7 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText,
|
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText,
|
||||||
LoraPoolNode.NAME: LoraPoolNode,
|
LoraPoolNode.NAME: LoraPoolNode,
|
||||||
LoraRandomizerNode.NAME: LoraRandomizerNode,
|
LoraRandomizerNode.NAME: LoraRandomizerNode,
|
||||||
|
LoraCyclerNode.NAME: LoraCyclerNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./web/comfyui"
|
WEB_DIRECTORY = "./web/comfyui"
|
||||||
|
|||||||
130
py/nodes/lora_cycler.py
Normal file
130
py/nodes/lora_cycler.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Lora Cycler Node - Sequentially cycles through LoRAs from a pool.
|
||||||
|
|
||||||
|
This node accepts optional pool_config input to filter available LoRAs, and outputs
|
||||||
|
a LORA_STACK with one LoRA at a time. Returns UI updates with current/next LoRA info
|
||||||
|
and tracks the cycle progress which persists across workflow save/load.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from ..utils.utils import get_lora_info
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoraCyclerNode:
|
||||||
|
"""Node that sequentially cycles through LoRAs from a pool"""
|
||||||
|
|
||||||
|
NAME = "Lora Cycler (LoraManager)"
|
||||||
|
CATEGORY = "Lora Manager/randomizer"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"cycler_config": ("CYCLER_CONFIG", {}),
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"pool_config": ("POOL_CONFIG", {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("LORA_STACK",)
|
||||||
|
RETURN_NAMES = ("LORA_STACK",)
|
||||||
|
|
||||||
|
FUNCTION = "cycle"
|
||||||
|
OUTPUT_NODE = False
|
||||||
|
|
||||||
|
async def cycle(self, cycler_config, pool_config=None):
|
||||||
|
"""
|
||||||
|
Cycle through LoRAs based on configuration and pool filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cycler_config: Dict with cycler settings (current_index, model_strength, clip_strength, sort_by)
|
||||||
|
pool_config: Optional config from LoRA Pool node for filtering
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with 'result' (LORA_STACK tuple) and 'ui' (for widget display)
|
||||||
|
"""
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..services.lora_service import LoraService
|
||||||
|
|
||||||
|
# Extract settings from cycler_config
|
||||||
|
current_index = cycler_config.get("current_index", 1) # 1-based
|
||||||
|
model_strength = float(cycler_config.get("model_strength", 1.0))
|
||||||
|
clip_strength = float(cycler_config.get("clip_strength", 1.0))
|
||||||
|
sort_by = cycler_config.get("sort_by", "filename")
|
||||||
|
|
||||||
|
# Get scanner and service
|
||||||
|
scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
lora_service = LoraService(scanner)
|
||||||
|
|
||||||
|
# Get filtered and sorted LoRA list
|
||||||
|
lora_list = await lora_service.get_cycler_list(
|
||||||
|
pool_config=pool_config, sort_by=sort_by
|
||||||
|
)
|
||||||
|
|
||||||
|
total_count = len(lora_list)
|
||||||
|
|
||||||
|
if total_count == 0:
|
||||||
|
logger.warning("[LoraCyclerNode] No LoRAs available in pool")
|
||||||
|
return {
|
||||||
|
"result": ([],),
|
||||||
|
"ui": {
|
||||||
|
"current_index": [1],
|
||||||
|
"next_index": [1],
|
||||||
|
"total_count": [0],
|
||||||
|
"current_lora_name": [""],
|
||||||
|
"current_lora_filename": [""],
|
||||||
|
"error": ["No LoRAs available in pool"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clamp index to valid range (1-based)
|
||||||
|
clamped_index = max(1, min(current_index, total_count))
|
||||||
|
|
||||||
|
# Get LoRA at current index (convert to 0-based for list access)
|
||||||
|
current_lora = lora_list[clamped_index - 1]
|
||||||
|
|
||||||
|
# Build LORA_STACK with single LoRA
|
||||||
|
lora_path, _ = get_lora_info(current_lora["file_name"])
|
||||||
|
if not lora_path:
|
||||||
|
logger.warning(
|
||||||
|
f"[LoraCyclerNode] Could not find path for LoRA: {current_lora['file_name']}"
|
||||||
|
)
|
||||||
|
lora_stack = []
|
||||||
|
else:
|
||||||
|
# Normalize path separators
|
||||||
|
lora_path = lora_path.replace("/", os.sep)
|
||||||
|
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||||
|
|
||||||
|
# Calculate next index (wrap to 1 if at end)
|
||||||
|
next_index = clamped_index + 1
|
||||||
|
if next_index > total_count:
|
||||||
|
next_index = 1
|
||||||
|
|
||||||
|
# Get next LoRA for UI display (what will be used next generation)
|
||||||
|
next_lora = lora_list[next_index - 1]
|
||||||
|
|
||||||
|
# Determine display name based on sort_by setting
|
||||||
|
if sort_by == "filename":
|
||||||
|
next_display_name = next_lora["file_name"]
|
||||||
|
else:
|
||||||
|
next_display_name = next_lora.get("model_name", next_lora["file_name"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"result": (lora_stack,),
|
||||||
|
"ui": {
|
||||||
|
"current_index": [clamped_index],
|
||||||
|
"next_index": [next_index],
|
||||||
|
"total_count": [total_count],
|
||||||
|
"current_lora_name": [
|
||||||
|
current_lora.get("model_name", current_lora["file_name"])
|
||||||
|
],
|
||||||
|
"current_lora_filename": [current_lora["file_name"]],
|
||||||
|
"next_lora_name": [next_display_name],
|
||||||
|
"next_lora_filename": [next_lora["file_name"]],
|
||||||
|
"sort_by": [sort_by],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -63,6 +63,11 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
"POST", "/api/lm/{prefix}/random-sample", prefix, self.get_random_loras
|
"POST", "/api/lm/{prefix}/random-sample", prefix, self.get_random_loras
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Cycler routes
|
||||||
|
registrar.add_prefixed_route(
|
||||||
|
"POST", "/api/lm/{prefix}/cycler-list", prefix, self.get_cycler_list
|
||||||
|
)
|
||||||
|
|
||||||
# ComfyUI integration
|
# ComfyUI integration
|
||||||
registrar.add_prefixed_route(
|
registrar.add_prefixed_route(
|
||||||
"POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words
|
"POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words
|
||||||
@@ -283,6 +288,29 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
logger.error(f"Error getting random LoRAs: {e}", exc_info=True)
|
logger.error(f"Error getting random LoRAs: {e}", exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def get_cycler_list(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get filtered and sorted LoRA list for cycler widget"""
|
||||||
|
try:
|
||||||
|
json_data = await request.json()
|
||||||
|
|
||||||
|
# Parse parameters
|
||||||
|
pool_config = json_data.get("pool_config")
|
||||||
|
sort_by = json_data.get("sort_by", "filename")
|
||||||
|
|
||||||
|
# Get cycler list from service
|
||||||
|
lora_list = await self.service.get_cycler_list(
|
||||||
|
pool_config=pool_config,
|
||||||
|
sort_by=sort_by
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "loras": lora_list, "count": len(lora_list)}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting cycler list: {e}", exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
|
|
||||||
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
async def get_trigger_words(self, request: web.Request) -> web.Response:
|
||||||
"""Get trigger words for specified LoRA models"""
|
"""Get trigger words for specified LoRA models"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -479,3 +479,49 @@ class LoraService(BaseModelService):
|
|||||||
]
|
]
|
||||||
|
|
||||||
return available_loras
|
return available_loras
|
||||||
|
|
||||||
|
async def get_cycler_list(
|
||||||
|
self,
|
||||||
|
pool_config: Optional[Dict] = None,
|
||||||
|
sort_by: str = "filename"
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get filtered and sorted LoRA list for cycling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pool_config: Optional pool config for filtering (filters dict)
|
||||||
|
sort_by: Sort field - 'filename' or 'model_name'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of LoRA dicts with file_name and model_name
|
||||||
|
"""
|
||||||
|
# Get cached data
|
||||||
|
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||||
|
available_loras = cache.raw_data if cache else []
|
||||||
|
|
||||||
|
# Apply pool filters if provided
|
||||||
|
if pool_config:
|
||||||
|
available_loras = await self._apply_pool_filters(
|
||||||
|
available_loras, pool_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by specified field
|
||||||
|
if sort_by == "model_name":
|
||||||
|
available_loras = sorted(
|
||||||
|
available_loras,
|
||||||
|
key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower()
|
||||||
|
)
|
||||||
|
else: # Default to filename
|
||||||
|
available_loras = sorted(
|
||||||
|
available_loras,
|
||||||
|
key=lambda x: x.get("file_name", "").lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return minimal data needed for cycling
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"file_name": lora["file_name"],
|
||||||
|
"model_name": lora.get("model_name", lora["file_name"]),
|
||||||
|
}
|
||||||
|
for lora in available_loras
|
||||||
|
]
|
||||||
|
|||||||
219
vue-widgets/src/components/LoraCyclerWidget.vue
Normal file
219
vue-widgets/src/components/LoraCyclerWidget.vue
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lora-cycler-widget">
|
||||||
|
<LoraCyclerSettingsView
|
||||||
|
:current-index="state.currentIndex.value"
|
||||||
|
:total-count="state.totalCount.value"
|
||||||
|
:current-lora-name="state.currentLoraName.value"
|
||||||
|
:current-lora-filename="state.currentLoraFilename.value"
|
||||||
|
:model-strength="state.modelStrength.value"
|
||||||
|
:clip-strength="state.clipStrength.value"
|
||||||
|
:use-custom-clip-range="state.useCustomClipRange.value"
|
||||||
|
:is-clip-strength-disabled="state.isClipStrengthDisabled.value"
|
||||||
|
:sort-by="state.sortBy.value"
|
||||||
|
:is-loading="state.isLoading.value"
|
||||||
|
@update:current-index="handleIndexUpdate"
|
||||||
|
@update:model-strength="state.modelStrength.value = $event"
|
||||||
|
@update:clip-strength="state.clipStrength.value = $event"
|
||||||
|
@update:use-custom-clip-range="handleUseCustomClipRangeChange"
|
||||||
|
@update:sort-by="handleSortByChange"
|
||||||
|
@refresh="handleRefresh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import LoraCyclerSettingsView from './lora-cycler/LoraCyclerSettingsView.vue'
|
||||||
|
import { useLoraCyclerState } from '../composables/useLoraCyclerState'
|
||||||
|
import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from '../composables/types'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps<{
|
||||||
|
widget: ComponentWidget
|
||||||
|
node: { id: number; inputs?: any[]; widgets?: any[]; graph?: any }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// State management
|
||||||
|
const state = useLoraCyclerState(props.widget)
|
||||||
|
|
||||||
|
// Track last known pool config hash
|
||||||
|
const lastPoolConfigHash = ref('')
|
||||||
|
|
||||||
|
// Track if component is mounted
|
||||||
|
const isMounted = ref(false)
|
||||||
|
|
||||||
|
// Get pool config from connected node
|
||||||
|
const getPoolConfig = (): LoraPoolConfig | null => {
|
||||||
|
// Check if getPoolConfig method exists on node (added by main.ts)
|
||||||
|
if ((props.node as any).getPoolConfig) {
|
||||||
|
return (props.node as any).getPoolConfig()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle index update from user
|
||||||
|
const handleIndexUpdate = async (newIndex: number) => {
|
||||||
|
state.setIndex(newIndex)
|
||||||
|
|
||||||
|
// Refresh list to update current LoRA display
|
||||||
|
try {
|
||||||
|
const poolConfig = getPoolConfig()
|
||||||
|
const loraList = await state.fetchCyclerList(poolConfig)
|
||||||
|
|
||||||
|
if (loraList.length > 0 && newIndex > 0 && newIndex <= loraList.length) {
|
||||||
|
const currentLora = loraList[newIndex - 1]
|
||||||
|
if (currentLora) {
|
||||||
|
state.currentLoraName.value = state.sortBy.value === 'filename'
|
||||||
|
? currentLora.file_name
|
||||||
|
: (currentLora.model_name || currentLora.file_name)
|
||||||
|
state.currentLoraFilename.value = currentLora.file_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoraCyclerWidget] Error updating index:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle sort by change
|
||||||
|
const handleSortByChange = async (newSortBy: 'filename' | 'model_name') => {
|
||||||
|
state.sortBy.value = newSortBy
|
||||||
|
|
||||||
|
// Refresh list with new sort order
|
||||||
|
try {
|
||||||
|
const poolConfig = getPoolConfig()
|
||||||
|
await state.refreshList(poolConfig)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoraCyclerWidget] Error changing sort:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle use custom clip range toggle
|
||||||
|
const handleUseCustomClipRangeChange = (newValue: boolean) => {
|
||||||
|
state.useCustomClipRange.value = newValue
|
||||||
|
// When toggling off, sync clip strength to model strength
|
||||||
|
if (!newValue) {
|
||||||
|
state.clipStrength.value = state.modelStrength.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle refresh button click
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
try {
|
||||||
|
const poolConfig = getPoolConfig()
|
||||||
|
await state.refreshList(poolConfig)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoraCyclerWidget] Error refreshing:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pool config changes
|
||||||
|
const checkPoolConfigChanges = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
|
||||||
|
const poolConfig = getPoolConfig()
|
||||||
|
const newHash = state.hashPoolConfig(poolConfig)
|
||||||
|
|
||||||
|
if (newHash !== lastPoolConfigHash.value) {
|
||||||
|
console.log('[LoraCyclerWidget] Pool config changed, refreshing list')
|
||||||
|
lastPoolConfigHash.value = newHash
|
||||||
|
try {
|
||||||
|
await state.refreshList(poolConfig)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoraCyclerWidget] Error on pool config change:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(async () => {
|
||||||
|
// Setup serialization
|
||||||
|
props.widget.serializeValue = async () => {
|
||||||
|
return state.buildConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle external value updates (e.g., loading workflow, paste)
|
||||||
|
props.widget.onSetValue = (v) => {
|
||||||
|
state.restoreFromConfig(v as CyclerConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore from saved value
|
||||||
|
if (props.widget.value) {
|
||||||
|
state.restoreFromConfig(props.widget.value as CyclerConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark component as mounted
|
||||||
|
isMounted.value = true
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
try {
|
||||||
|
const poolConfig = getPoolConfig()
|
||||||
|
lastPoolConfigHash.value = state.hashPoolConfig(poolConfig)
|
||||||
|
await state.refreshList(poolConfig)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoraCyclerWidget] Error on initial load:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override onExecuted to handle backend UI updates
|
||||||
|
const originalOnExecuted = (props.node as any).onExecuted?.bind(props.node)
|
||||||
|
|
||||||
|
;(props.node as any).onExecuted = function(output: any) {
|
||||||
|
console.log("[LoraCyclerWidget] Node executed with output:", output)
|
||||||
|
|
||||||
|
// Update state from backend response (values are wrapped in arrays)
|
||||||
|
if (output?.next_index !== undefined) {
|
||||||
|
const val = Array.isArray(output.next_index) ? output.next_index[0] : output.next_index
|
||||||
|
state.currentIndex.value = val
|
||||||
|
}
|
||||||
|
if (output?.total_count !== undefined) {
|
||||||
|
const val = Array.isArray(output.total_count) ? output.total_count[0] : output.total_count
|
||||||
|
state.totalCount.value = val
|
||||||
|
}
|
||||||
|
if (output?.current_lora_name !== undefined) {
|
||||||
|
const val = Array.isArray(output.current_lora_name) ? output.current_lora_name[0] : output.current_lora_name
|
||||||
|
state.currentLoraName.value = val
|
||||||
|
}
|
||||||
|
if (output?.current_lora_filename !== undefined) {
|
||||||
|
const val = Array.isArray(output.current_lora_filename) ? output.current_lora_filename[0] : output.current_lora_filename
|
||||||
|
state.currentLoraFilename.value = val
|
||||||
|
}
|
||||||
|
if (output?.next_lora_name !== undefined) {
|
||||||
|
const val = Array.isArray(output.next_lora_name) ? output.next_lora_name[0] : output.next_lora_name
|
||||||
|
state.currentLoraName.value = val
|
||||||
|
}
|
||||||
|
if (output?.next_lora_filename !== undefined) {
|
||||||
|
const val = Array.isArray(output.next_lora_filename) ? output.next_lora_filename[0] : output.next_lora_filename
|
||||||
|
state.currentLoraFilename.value = val
|
||||||
|
}
|
||||||
|
if (output?.sort_by !== undefined) {
|
||||||
|
const val = Array.isArray(output.sort_by) ? output.sort_by[0] : output.sort_by
|
||||||
|
state.sortBy.value = val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call original onExecuted if it exists
|
||||||
|
if (originalOnExecuted) {
|
||||||
|
return originalOnExecuted(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for connection changes by polling (since ComfyUI doesn't provide connection events)
|
||||||
|
const checkInterval = setInterval(checkPoolConfigChanges, 1000)
|
||||||
|
|
||||||
|
// Cleanup on unmount (handled by Vue's effect scope)
|
||||||
|
;(props.widget as any).onRemoveCleanup = () => {
|
||||||
|
clearInterval(checkInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lora-cycler-widget {
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(40, 44, 52, 0.6);
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cycler-settings">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h3 class="settings-title">CYCLER SETTINGS</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Display -->
|
||||||
|
<div class="setting-section progress-section">
|
||||||
|
<div class="progress-display">
|
||||||
|
<div class="progress-info">
|
||||||
|
<span class="progress-label">Next LoRA:</span>
|
||||||
|
<span class="progress-name" :title="currentLoraFilename">{{ currentLoraName || 'None' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-counter">
|
||||||
|
<span class="progress-index">{{ currentIndex }}</span>
|
||||||
|
<span class="progress-separator">/</span>
|
||||||
|
<span class="progress-total">{{ totalCount }}</span>
|
||||||
|
<button
|
||||||
|
class="refresh-button"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="$emit('refresh')"
|
||||||
|
title="Refresh list"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="refresh-icon"
|
||||||
|
:class="{ spinning: isLoading }"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
<path d="M21 3v5h-5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Starting Index -->
|
||||||
|
<div class="setting-section">
|
||||||
|
<label class="setting-label">Starting Index</label>
|
||||||
|
<div class="index-input-container">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="index-input"
|
||||||
|
:min="1"
|
||||||
|
:max="totalCount || 1"
|
||||||
|
:value="currentIndex"
|
||||||
|
:disabled="totalCount === 0"
|
||||||
|
@input="onIndexInput"
|
||||||
|
@blur="onIndexBlur"
|
||||||
|
/>
|
||||||
|
<span class="index-hint">1 - {{ totalCount || 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Strength -->
|
||||||
|
<div class="setting-section">
|
||||||
|
<label class="setting-label">Model Strength</label>
|
||||||
|
<div class="slider-container">
|
||||||
|
<SingleSlider
|
||||||
|
:min="-10"
|
||||||
|
:max="10"
|
||||||
|
:value="modelStrength"
|
||||||
|
:step="0.1"
|
||||||
|
:default-range="{ min: 0.5, max: 1.5 }"
|
||||||
|
@update:value="$emit('update:modelStrength', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clip Strength -->
|
||||||
|
<div class="setting-section">
|
||||||
|
<div class="section-header-with-toggle">
|
||||||
|
<label class="setting-label">
|
||||||
|
Clip Strength - {{ useCustomClipRange ? 'Custom Value' : 'Use Model Strength' }}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-switch"
|
||||||
|
:class="{ 'toggle-switch--active': useCustomClipRange }"
|
||||||
|
@click="$emit('update:useCustomClipRange', !useCustomClipRange)"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="useCustomClipRange"
|
||||||
|
title="Use custom clip strength when enabled, otherwise use model strength"
|
||||||
|
>
|
||||||
|
<span class="toggle-switch__track"></span>
|
||||||
|
<span class="toggle-switch__thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="slider-container" :class="{ 'slider-container--disabled': isClipStrengthDisabled }">
|
||||||
|
<SingleSlider
|
||||||
|
:min="-10"
|
||||||
|
:max="10"
|
||||||
|
:value="clipStrength"
|
||||||
|
:step="0.1"
|
||||||
|
:default-range="{ min: 0.5, max: 1.5 }"
|
||||||
|
:disabled="isClipStrengthDisabled"
|
||||||
|
@update:value="$emit('update:clipStrength', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort By -->
|
||||||
|
<div class="setting-section">
|
||||||
|
<label class="setting-label">Sort By</label>
|
||||||
|
<div class="sort-tabs">
|
||||||
|
<label class="sort-tab" :class="{ active: sortBy === 'filename' }">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-by"
|
||||||
|
value="filename"
|
||||||
|
:checked="sortBy === 'filename'"
|
||||||
|
@change="$emit('update:sortBy', 'filename')"
|
||||||
|
/>
|
||||||
|
<span class="sort-tab-label">Filename</span>
|
||||||
|
</label>
|
||||||
|
<label class="sort-tab" :class="{ active: sortBy === 'model_name' }">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-by"
|
||||||
|
value="model_name"
|
||||||
|
:checked="sortBy === 'model_name'"
|
||||||
|
@change="$emit('update:sortBy', 'model_name')"
|
||||||
|
/>
|
||||||
|
<span class="sort-tab-label">Model Name</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import SingleSlider from '../shared/SingleSlider.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentIndex: number
|
||||||
|
totalCount: number
|
||||||
|
currentLoraName: string
|
||||||
|
currentLoraFilename: string
|
||||||
|
modelStrength: number
|
||||||
|
clipStrength: number
|
||||||
|
useCustomClipRange: boolean
|
||||||
|
isClipStrengthDisabled: boolean
|
||||||
|
sortBy: 'filename' | 'model_name'
|
||||||
|
isLoading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:currentIndex': [value: number]
|
||||||
|
'update:modelStrength': [value: number]
|
||||||
|
'update:clipStrength': [value: number]
|
||||||
|
'update:useCustomClipRange': [value: boolean]
|
||||||
|
'update:sortBy': [value: 'filename' | 'model_name']
|
||||||
|
'refresh': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Temporary value for input while typing
|
||||||
|
const tempIndex = ref<string>('')
|
||||||
|
|
||||||
|
const onIndexInput = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
tempIndex.value = input.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const onIndexBlur = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const value = parseInt(input.value, 10)
|
||||||
|
|
||||||
|
if (!isNaN(value)) {
|
||||||
|
const clampedValue = Math.max(1, Math.min(value, props.totalCount || 1))
|
||||||
|
emit('update:currentIndex', clampedValue)
|
||||||
|
input.value = clampedValue.toString()
|
||||||
|
} else {
|
||||||
|
input.value = props.currentIndex.toString()
|
||||||
|
}
|
||||||
|
tempIndex.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cycler-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
color: #e4e4e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--fg-color, #fff);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(226, 232, 240, 0.8);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Display */
|
||||||
|
.progress-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-display {
|
||||||
|
background: rgba(26, 32, 44, 0.9);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(226, 232, 240, 0.5);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(191, 219, 254, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-counter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding-left: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-index {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(66, 153, 225, 1);
|
||||||
|
font-family: 'SF Mono', 'Roboto Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-separator {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(226, 232, 240, 0.4);
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-total {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(226, 232, 240, 0.6);
|
||||||
|
font-family: 'SF Mono', 'Roboto Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: rgba(226, 232, 240, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button:hover:not(:disabled) {
|
||||||
|
background: rgba(66, 153, 225, 0.2);
|
||||||
|
border-color: rgba(66, 153, 225, 0.4);
|
||||||
|
color: rgba(191, 219, 254, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-icon.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Index Input */
|
||||||
|
.index-input-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-input {
|
||||||
|
width: 80px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(26, 32, 44, 0.9);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e4e4e7;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'SF Mono', 'Roboto Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(66, 153, 225, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-input:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(226, 232, 240, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider Container */
|
||||||
|
.slider-container {
|
||||||
|
background: rgba(26, 32, 44, 0.9);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-with-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header-with-toggle .setting-label {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Switch */
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch__track {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--comfy-input-bg, #333);
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch--active .toggle-switch__track {
|
||||||
|
background: rgba(66, 153, 225, 0.3);
|
||||||
|
border-color: rgba(66, 153, 225, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch__thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--fg-color, #fff);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch--active .toggle-switch__thumb {
|
||||||
|
transform: translateX(16px);
|
||||||
|
background: #4299e1;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch:hover .toggle-switch__thumb {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sort Tabs */
|
||||||
|
.sort-tabs {
|
||||||
|
display: flex;
|
||||||
|
background: rgba(26, 32, 44, 0.9);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tab {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tab input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tab-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(226, 232, 240, 0.7);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tab:hover .sort-tab-label {
|
||||||
|
color: rgba(226, 232, 240, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tab.active .sort-tab-label {
|
||||||
|
color: rgba(191, 219, 254, 1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tab.active {
|
||||||
|
background: rgba(66, 153, 225, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(66, 153, 225, 0.9);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -82,9 +82,22 @@ export interface LoraEntry {
|
|||||||
locked: boolean
|
locked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentWidget {
|
// Cycler config
|
||||||
serializeValue?: () => Promise<LoraPoolConfig | RandomizerConfig>
|
export interface CyclerConfig {
|
||||||
value?: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig
|
current_index: number // 1-based index
|
||||||
onSetValue?: (v: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig) => void
|
total_count: number // Cached for display
|
||||||
updateConfig?: (v: LoraPoolConfig | RandomizerConfig) => void
|
pool_config_hash: string // For change detection
|
||||||
|
model_strength: number
|
||||||
|
clip_strength: number
|
||||||
|
use_same_clip_strength: boolean
|
||||||
|
sort_by: 'filename' | 'model_name'
|
||||||
|
current_lora_name: string // For display
|
||||||
|
current_lora_filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentWidget {
|
||||||
|
serializeValue?: () => Promise<LoraPoolConfig | RandomizerConfig | CyclerConfig>
|
||||||
|
value?: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig | CyclerConfig
|
||||||
|
onSetValue?: (v: LoraPoolConfig | LegacyLoraPoolConfig | RandomizerConfig | CyclerConfig) => void
|
||||||
|
updateConfig?: (v: LoraPoolConfig | RandomizerConfig | CyclerConfig) => void
|
||||||
}
|
}
|
||||||
|
|||||||
208
vue-widgets/src/composables/useLoraCyclerState.ts
Normal file
208
vue-widgets/src/composables/useLoraCyclerState.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from './types'
|
||||||
|
|
||||||
|
export interface CyclerLoraItem {
|
||||||
|
file_name: string
|
||||||
|
model_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoraCyclerState(widget: ComponentWidget) {
|
||||||
|
// State refs
|
||||||
|
const currentIndex = ref(1) // 1-based
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const poolConfigHash = ref('')
|
||||||
|
const modelStrength = ref(1.0)
|
||||||
|
const clipStrength = ref(1.0)
|
||||||
|
const useCustomClipRange = ref(false)
|
||||||
|
const sortBy = ref<'filename' | 'model_name'>('filename')
|
||||||
|
const currentLoraName = ref('')
|
||||||
|
const currentLoraFilename = ref('')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
// Build config object from current state
|
||||||
|
const buildConfig = (): CyclerConfig => ({
|
||||||
|
current_index: currentIndex.value,
|
||||||
|
total_count: totalCount.value,
|
||||||
|
pool_config_hash: poolConfigHash.value,
|
||||||
|
model_strength: modelStrength.value,
|
||||||
|
clip_strength: clipStrength.value,
|
||||||
|
use_same_clip_strength: !useCustomClipRange.value,
|
||||||
|
sort_by: sortBy.value,
|
||||||
|
current_lora_name: currentLoraName.value,
|
||||||
|
current_lora_filename: currentLoraFilename.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Restore state from config object
|
||||||
|
const restoreFromConfig = (config: CyclerConfig) => {
|
||||||
|
currentIndex.value = config.current_index || 1
|
||||||
|
totalCount.value = config.total_count || 0
|
||||||
|
poolConfigHash.value = config.pool_config_hash || ''
|
||||||
|
modelStrength.value = config.model_strength ?? 1.0
|
||||||
|
clipStrength.value = config.clip_strength ?? 1.0
|
||||||
|
useCustomClipRange.value = !(config.use_same_clip_strength ?? true)
|
||||||
|
sortBy.value = config.sort_by || 'filename'
|
||||||
|
currentLoraName.value = config.current_lora_name || ''
|
||||||
|
currentLoraFilename.value = config.current_lora_filename || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate hash from pool config for change detection
|
||||||
|
const hashPoolConfig = (poolConfig: LoraPoolConfig | null): string => {
|
||||||
|
if (!poolConfig || !poolConfig.filters) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return btoa(JSON.stringify(poolConfig.filters))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch cycler list from API
|
||||||
|
const fetchCyclerList = async (
|
||||||
|
poolConfig: LoraPoolConfig | null
|
||||||
|
): Promise<CyclerLoraItem[]> => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
const requestBody: Record<string, unknown> = {
|
||||||
|
sort_by: sortBy.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (poolConfig?.filters) {
|
||||||
|
requestBody.pool_config = poolConfig.filters
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/lm/loras/cycler-list', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || 'Failed to fetch cycler list')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Failed to get cycler list')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.loras || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoraCyclerState] Error fetching cycler list:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh list and update state
|
||||||
|
const refreshList = async (poolConfig: LoraPoolConfig | null) => {
|
||||||
|
try {
|
||||||
|
const newHash = hashPoolConfig(poolConfig)
|
||||||
|
const hashChanged = newHash !== poolConfigHash.value
|
||||||
|
|
||||||
|
// Fetch the list
|
||||||
|
const loraList = await fetchCyclerList(poolConfig)
|
||||||
|
|
||||||
|
// Update total count
|
||||||
|
totalCount.value = loraList.length
|
||||||
|
|
||||||
|
// If pool config changed, reset index to 1
|
||||||
|
if (hashChanged) {
|
||||||
|
currentIndex.value = 1
|
||||||
|
poolConfigHash.value = newHash
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp index to valid range
|
||||||
|
if (currentIndex.value > totalCount.value) {
|
||||||
|
currentIndex.value = Math.max(1, totalCount.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current LoRA info
|
||||||
|
if (loraList.length > 0 && currentIndex.value > 0) {
|
||||||
|
const currentLora = loraList[currentIndex.value - 1]
|
||||||
|
if (currentLora) {
|
||||||
|
currentLoraName.value = sortBy.value === 'filename'
|
||||||
|
? currentLora.file_name
|
||||||
|
: (currentLora.model_name || currentLora.file_name)
|
||||||
|
currentLoraFilename.value = currentLora.file_name
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentLoraName.value = ''
|
||||||
|
currentLoraFilename.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return loraList
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[LoraCyclerState] Error refreshing list:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set index manually
|
||||||
|
const setIndex = (index: number) => {
|
||||||
|
if (index >= 1 && index <= totalCount.value) {
|
||||||
|
currentIndex.value = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed property to check if clip strength is disabled
|
||||||
|
const isClipStrengthDisabled = computed(() => !useCustomClipRange.value)
|
||||||
|
|
||||||
|
// Watch model strength changes to sync with clip strength when not using custom range
|
||||||
|
watch(modelStrength, (newValue) => {
|
||||||
|
if (!useCustomClipRange.value) {
|
||||||
|
clipStrength.value = newValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch all state changes and update widget value
|
||||||
|
watch([
|
||||||
|
currentIndex,
|
||||||
|
totalCount,
|
||||||
|
poolConfigHash,
|
||||||
|
modelStrength,
|
||||||
|
clipStrength,
|
||||||
|
useCustomClipRange,
|
||||||
|
sortBy,
|
||||||
|
currentLoraName,
|
||||||
|
currentLoraFilename,
|
||||||
|
], () => {
|
||||||
|
const config = buildConfig()
|
||||||
|
if (widget.updateConfig) {
|
||||||
|
widget.updateConfig(config)
|
||||||
|
} else {
|
||||||
|
widget.value = config
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State refs
|
||||||
|
currentIndex,
|
||||||
|
totalCount,
|
||||||
|
poolConfigHash,
|
||||||
|
modelStrength,
|
||||||
|
clipStrength,
|
||||||
|
useCustomClipRange,
|
||||||
|
sortBy,
|
||||||
|
currentLoraName,
|
||||||
|
currentLoraFilename,
|
||||||
|
isLoading,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
isClipStrengthDisabled,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
buildConfig,
|
||||||
|
restoreFromConfig,
|
||||||
|
hashPoolConfig,
|
||||||
|
fetchCyclerList,
|
||||||
|
refreshList,
|
||||||
|
setIndex,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,18 @@ import { createApp, type App as VueApp } from 'vue'
|
|||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import LoraPoolWidget from '@/components/LoraPoolWidget.vue'
|
import LoraPoolWidget from '@/components/LoraPoolWidget.vue'
|
||||||
import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
|
import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
|
||||||
|
import LoraCyclerWidget from '@/components/LoraCyclerWidget.vue'
|
||||||
import JsonDisplayWidget from '@/components/JsonDisplayWidget.vue'
|
import JsonDisplayWidget from '@/components/JsonDisplayWidget.vue'
|
||||||
import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig } from './composables/types'
|
import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig, CyclerConfig } from './composables/types'
|
||||||
|
|
||||||
const LORA_POOL_WIDGET_MIN_WIDTH = 500
|
const LORA_POOL_WIDGET_MIN_WIDTH = 500
|
||||||
const LORA_POOL_WIDGET_MIN_HEIGHT = 400
|
const LORA_POOL_WIDGET_MIN_HEIGHT = 400
|
||||||
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
|
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
|
||||||
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448
|
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448
|
||||||
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
|
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
|
||||||
|
const LORA_CYCLER_WIDGET_MIN_WIDTH = 380
|
||||||
|
const LORA_CYCLER_WIDGET_MIN_HEIGHT = 410
|
||||||
|
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT
|
||||||
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
|
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
|
||||||
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200
|
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200
|
||||||
|
|
||||||
@@ -210,6 +214,80 @@ function createLoraRandomizerWidget(node) {
|
|||||||
return { widget }
|
return { widget }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
function createLoraCyclerWidget(node) {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.id = `lora-cycler-widget-${node.id}`
|
||||||
|
container.style.width = '100%'
|
||||||
|
container.style.height = '100%'
|
||||||
|
container.style.display = 'flex'
|
||||||
|
container.style.flexDirection = 'column'
|
||||||
|
container.style.overflow = 'hidden'
|
||||||
|
|
||||||
|
forwardMiddleMouseToCanvas(container)
|
||||||
|
|
||||||
|
let internalValue: CyclerConfig | undefined
|
||||||
|
|
||||||
|
const widget = node.addDOMWidget(
|
||||||
|
'cycler_config',
|
||||||
|
'CYCLER_CONFIG',
|
||||||
|
container,
|
||||||
|
{
|
||||||
|
getValue() {
|
||||||
|
return internalValue
|
||||||
|
},
|
||||||
|
setValue(v: CyclerConfig) {
|
||||||
|
internalValue = v
|
||||||
|
if (typeof widget.onSetValue === 'function') {
|
||||||
|
widget.onSetValue(v)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serialize: true,
|
||||||
|
getMinHeight() {
|
||||||
|
return LORA_CYCLER_WIDGET_MIN_HEIGHT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
widget.updateConfig = (v: CyclerConfig) => {
|
||||||
|
internalValue = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add method to get pool config from connected node
|
||||||
|
node.getPoolConfig = () => getPoolConfigFromConnectedNode(node)
|
||||||
|
|
||||||
|
const vueApp = createApp(LoraCyclerWidget, {
|
||||||
|
widget,
|
||||||
|
node
|
||||||
|
})
|
||||||
|
|
||||||
|
vueApp.use(PrimeVue, {
|
||||||
|
unstyled: true,
|
||||||
|
ripple: false
|
||||||
|
})
|
||||||
|
|
||||||
|
vueApp.mount(container)
|
||||||
|
vueApps.set(node.id + 30000, vueApp) // Offset to avoid collision with other widgets
|
||||||
|
|
||||||
|
widget.computeLayoutSize = () => {
|
||||||
|
const minWidth = LORA_CYCLER_WIDGET_MIN_WIDTH
|
||||||
|
const minHeight = LORA_CYCLER_WIDGET_MIN_HEIGHT
|
||||||
|
const maxHeight = LORA_CYCLER_WIDGET_MAX_HEIGHT
|
||||||
|
|
||||||
|
return { minHeight, minWidth, maxHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onRemove = () => {
|
||||||
|
const vueApp = vueApps.get(node.id + 30000)
|
||||||
|
if (vueApp) {
|
||||||
|
vueApp.unmount()
|
||||||
|
vueApps.delete(node.id + 30000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { widget }
|
||||||
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
function createJsonDisplayWidget(node) {
|
function createJsonDisplayWidget(node) {
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
@@ -290,6 +368,10 @@ app.registerExtension({
|
|||||||
return createLoraRandomizerWidget(node)
|
return createLoraRandomizerWidget(node)
|
||||||
},
|
},
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
CYCLER_CONFIG(node) {
|
||||||
|
return createLoraCyclerWidget(node)
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
async LORAS(node: any) {
|
async LORAS(node: any) {
|
||||||
if (!addLorasWidgetCache) {
|
if (!addLorasWidgetCache) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user