12 KiB
Style Mixer — Project Plan & Dev Notes
ComfyUI custom-node extension with a React/TypeScript sidebar panel.
Follows the Comfy-Org/ComfyUI-React-Extension-Template pattern.
Implementation Status
✅ Step 1 — Python layer cleanup
- Deleted broken tests (used old-style class attributes incompatible with
io.ComfyNode) - Removed dead dict registration from
src/immac_tools/__init__.py - Removed unused
ExampleForwardingExtensionfromforwarding_nodes.py
✅ Step 2 — Root __init__.py
NODE_CLASS_MAPPINGS+NODE_DISPLAY_NAME_MAPPINGS(required by ComfyUI core and Manager)comfy_entrypointwired tosrc/immac_tools/(forward-compat)- aiohttp static routes serve
dist/immac_style_mixer/at/immac_style_mixer/ nodes.EXTENSION_WEB_DIRS[project_name]registered viacomfy_config(with fallback)
✅ Step 3 — REST API (src/immac_tools/api.py)
style_mixer_data.jsonload/save helpersGET /immac_style_mixer/api/dataPOST /immac_style_mixer/api/data(with structural validation)GET /immac_style_mixer/api/backup— download ZIP with imagesPOST /immac_style_mixer/api/restore— restore from JSON or ZIP (auto-detected server-side)- Routes wired into root
__init__.pyviaregister_routes() - Images handled by ComfyUI built-ins (
/upload/image+/view)
✅ Step 4 — pyproject.toml
includes = ["dist/"]- Removed
[build-system]/[tool.setuptools]blocks - Metadata updated (
immac_tools.json)
✅ Step 5 — UI scaffold (ui/)
ui/package.json,ui/vite.config.ts(output →../dist/immac_style_mixer/)ui/tsconfig.json+ui/tsconfig.node.jsonui/src/main.tsx— registers ComfyUI sidebar tabimmac-style-mixer- Build verified (
npm run buildfromui/✔)
✅ Step 6 — UI components
types.ts—Style,Mix,MixEntry,StyleMixerDatahooks/useStyleMixerData.ts— fetch/save via API;uploadStyleImage()+styleImageUrl()helperscomponents/StyleCard.tsx— image upload (drag&drop/picker), editable name & prompt, optional negative prompt, favorite star, deletecomponents/MixCard.tsx— name, click to activate, style entries (ON/OFF + weight bar + remove), add-style dropdown, favorite starcomponents/StyleGallery.tsx— auto-fill grid (favorites first), inline "Add" formcomponents/StyleMixerPanel.tsx— Current Mix / Mixes / Styles sections wired to data hookcomponents/BarInput.tsx— draggable weight slider (ComfyUI style), drag range 0–1, typed range −10–10components/ImageLightbox.tsx— full-size image overlay on magnify button click
✅ Step 7 — Python nodes (V3 API)
- All nodes migrated to V3 API (
comfy_api.latest.io) - Pick Style (
style_pick_node.py) — combo of style names; image preview widget on canvas - Pick Mix (
style_mix_node.py) — combo of mix names;mix_idoutput; canvas image preview (DOM widget); preview updates on widget change without execution - Blend Style (
style_blend_node.py) —io.Autogrowgrowing slots; accepts rawstyle_id(no weight node) with default weight 1.0; skips zero-weight styles - Weight Style (
style_weight_node.py) — wraps astyle_idwith a float weight - Save Mix (
save_mix_node.py) — saves current mix state; optionalexample_image+mix_idinputs - Save Style (
save_style_node.py) — Create / Create or Skip / Create or Update / Overwrite modes; optionalexample_imageinput;OUTPUT_NODE = True; updatesimage_updated_atfor cache-busting IS_CHANGEDuses data file mtime to bust node cache on sidebar saves- Node display names are verb-first: Pick Style, Blend Style, etc.
✅ Step 8 — Canvas integration
- Drag style card onto canvas → creates a Pick Style node set to that style (
onDragEndpattern; ComfyUI intercepts the nativedropevent) - Drag mix card onto canvas → creates a Pick Mix node
loadedGraphNodehook upgrades placeholder nodes on workflow reloadscheduleCanvasDirty: 10 rAF frames after node creation to force canvas redraw on page reload (avoids race with LiteGraph render loop)- Node combos refresh via
refreshComboInNodes1500 ms after sidebar saves (add/delete only; weight/toggle changes are silent)
✅ Step 9 — Execution feedback
execution_successevent → refreshes all node previews (_immacUpdatePreview)execution_successevent → dispatchesimmac:execution_successcustom window event → re-fetches sidebar data so images update automatically without page reloadimage_updated_attimestamps instyle_mixer_data.jsonflow through tostyleImageUrl/mixImageUrlcache-busters
✅ Step 10 — Backup / Restore
- Moved to ComfyUI Settings panel (registered via
settings[]inregisterExtension) - Download ZIP — server-side endpoint bundles
style_mixer_data.json+ all referenced images - Restore — auto-detects JSON vs ZIP; ZIP restore extracts images server-side; shows real server error on failure
✅ Step 11 — Negative prompts
negative_promptfield onStyleandMixtypes- Pick Style node exposes
negative_promptoutput - Skip zero-weight styles from blended prompt output
✅ Step 12 — Documentation & packaging
README.mdrewritten (nodes, sidebar, dev build)LICENSE(MIT, 2026)- Example workflow moved to
examples/(ComfyUI template discovery) style_mixer_data.json.exampleadded; actual data file removed from trackingdist/tracked in git (required for registry publishing)
UI Polish (all done)
- Auto-fill CSS grid — styles:
minmax(180px, 1fr), mixes:minmax(280px, 1fr) - Click card to select mix (no radio button)
- Magnify button on style thumbnail hover;
scale-105CSS transition - Full-size image lightbox (
ImageLightbox.tsx) - Copy prompt button on style and mix cards (clipboard API)
- Draggable bar input for weights (
BarInput.tsx) - Delete confirmation as centered card overlay (
overflow: hiddenclipping); shows actual name - On/off toggle and weight bar in Current Mix section
- Double-click current mix chip to toggle on/off
- Remove button on current mix style chips
- Drag style cards onto mix cards or Current Mix to add them
- Toggle add/remove from mix button on style cards
- Mixes section scrolls horizontally when cards overflow
- Toast warning on delete; dirty badge on add/delete; auto-refresh on delete
object-fit: contain(padded) for all card and mix thumbnails- Fill-bar loading animation while sidebar data fetches
Key Decisions
- Single
POST /immac_style_mixer/api/dataendpoint for all persistence (minimal backend) - Sidebar tab (not floating window) for the panel
dist/tracked in git — required for registry publishing viaincludes = ["dist/"]- Styles are user-defined prompt text snippets (no fixed schema)
- Weight drag range 0–1, typed range −10–10 (separate
dragMin/dragMax)
Dev Notes
Build
package.json is in ui/, not the repo root. Always build from there:
cd /path/to/Immac_Style_Mixer/ui && npm run build
# or as a one-liner from any directory:
bash -c 'cd /path/to/.../ui && npm run build'
Accessing the api singleton
Do NOT use (app as any).api — it is undefined in the newer ComfyUI Vue frontend.
Always import directly:
// @ts-ignore
import { api } from '/scripts/api.js'
import { app } from '/scripts/app.js'
Declare types in comfy.d.ts and add to Vite externals in vite.config.ts:
rollupOptions: { external: ['/scripts/app.js', '/scripts/api.js'] }
Choosing the right ComfyUI event
| Event | Fires | Use for |
|---|---|---|
executed |
Once per node that produced output | Fine-grained per-node logic (filter by comfyClass) |
execution_success |
Once per completed queue item | "Anything finished — refresh everything" |
For simple global refresh, prefer execution_success.
Refreshing node previews after execution
api.addEventListener('execution_success', () => {
const nodes: any[] = (app as any).graph?._nodes ?? []
for (const n of nodes) {
if (typeof n._immacUpdatePreview === 'function') n._immacUpdatePreview()
}
})
Refreshing sidebar panel data after execution
main.tsxdispatches a custom event alongside canvas refresh:api.addEventListener('execution_success', () => { refreshAllPreviews() window.dispatchEvent(new CustomEvent('immac:execution_success')) })useStyleMixerDatalistens and re-fetches:useEffect(() => { const handler = () => { fetchData().catch(() => {}) } window.addEventListener('immac:execution_success', handler) return () => window.removeEventListener('immac:execution_success', handler) }, [fetchData])
Canvas / LiteGraph timing on page load
Node preview images must not be set before LiteGraph's render loop is running.
What didn't work:
- Calling
_immacUpdatePreviewimmediately inloadedGraphNode→ loop not started yet setTimeout(500ms)→ fragile, still sometimes too early- rAF polling for
LiteGraph.use_deferred_text_metrics→ property unavailable in ComfyUI's build Object.definePropertyonwidget.value→ misses internal ComboWidget private field writes (arrow navigation)
What works:
function scheduleCanvasDirty(node: any, frames = 10) {
let count = 0
function tick() {
node.setDirtyCanvas?.(true, true)
if (++count < frames) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
10 consecutive rAF calls guarantees the render loop is running and the DOM widget image renders correctly.
ComfyUI node preview image pattern
Each Pick Mix / Pick Style node gets _immacUpdatePreview(value?) attached in nodeCreated. It:
- Fetches fresh data from
/immac_style_mixer/api/data - Finds the matching style/mix by name
- Sets
node.imgs = [img]+ callsscheduleCanvasDirty
Cache-busting via image_updated_at timestamps stored in style_mixer_data.json, updated whenever a node saves a new image file.
Style Mix node — preview image on widget change
Goal: show the selected mix's cover image (or a style fallback) in the node body when the mix combo widget changes, without triggering execution.
What doesn't work:
node.imgs+setSizeForImage()— deprecated; no visible effect unless$$canvas-image-previewDOM widget already exists (only afteronExecuted)Object.definePropertyonwidget.value— misses internal ComboWidget private field writes (arrow buttons)
What works:
node.addDOMWidget(...)injects a real<img>into the node body — renders immediately, fully controlled, not subject to deprecation issuesmixWidget.callbackwrapping is the correct hook — fires reliably on all interaction paths (arrows, context menu, programmatic calls), withwidget.valuealready updated
Implementation (ui/src/main.tsx, nodeCreated hook):
- Create a
<div><img></div>DOM widget on the node (serialize: false) - Wrap
mixWidget.callback→ callupdatePreview(mixWidget.value) updatePreviewfetches data, finds the mix, resolves image URL (mix cover → first enabled style → nothing), setsimgEl.srcscheduleCanvasDirtyfires across 10 rAF frames after node creation
Style drag to canvas
ComfyUI intercepts the native drop event on the canvas. Use the onDragEnd pattern instead:
card.addEventListener('dragend', (e) => {
// use e.clientX / e.clientY to find drop position
// call app.graph.createNode(...) and position it
})
comfy_api Python package
The comfy_api package (comfy_api.latest.io, etc.) is not on PyPI — it ships inside ComfyUI itself and is only importable at runtime inside a running ComfyUI process. Tests that import it require mocking or running inside ComfyUI's environment.
Node combo refresh strategy
- Add or delete a style or mix → call
app.refreshComboInNodes()after 1500 ms (debounced) - Weight change / enabled toggle / current_mix_id change → silent save, no combo refresh
- This avoids disruptive node widget resets during normal slider interaction