1 Commits
Author SHA1 Message Date
kijai 698d958085 Update setgetnodes.js 2025-09-05 01:35:42 +03:00
67 changed files with 5642 additions and 28728 deletions
+2
View File
@@ -0,0 +1,2 @@
github: [kijai]
custom: ["https://www.paypal.me/kijaidesign"]
-2
View File
@@ -9,5 +9,3 @@ types
models
jsconfig.json
custom_dimensions.json
.claude
.tests
+34 -53
View File
@@ -1,9 +1,10 @@
# KJNodes for ComfyUI
At this point pretty random collection of utility, model optimization and QoL nodes, while keeping dependencies at minimum.
Various quality of life and masking related -nodes and scripts made by combining functionality of existing nodes for ComfyUI.
Documentation is mostly in the node descriptions and tooltips.
I know I'm bad at documentation, especially this project that has grown from random practice nodes to... too many lines in one file.
I have however started to add descriptions to the nodes themselves, there's a small ? you can click for info what the node does.
This is still work in progress, like everything else.
# Installation
1. Clone this repo into `custom_nodes` folder.
@@ -11,74 +12,54 @@ Documentation is mostly in the node descriptions and tooltips.
or if you use the portable install, run this in ComfyUI_windows_portable -folder:
`python_embeded\python.exe -m pip install -r ComfyUI\custom_nodes\ComfyUI-KJNodes\requirements.txt`
# JS extensions:
## Javascript
Node insert (default hotkey D, configurable) and shake to disconnect (default disabled, found in settings to enable):
https://github.com/user-attachments/assets/e81f02e6-9cb0-4cb3-bcdd-67e7bd5bf6fb
Node swap (default hotkey S, configurable):
https://github.com/user-attachments/assets/735d973e-1b8b-4936-a1fb-29736b9c063a
### browserstatus.js
Sets the favicon to green circle when not processing anything, sets it to red when processing and shows progress percentage and the length of your queue.
Default off, needs to be enabled from options, overrides Custom-Scripts favicon when enabled.
## Nodes:
### Set/Get
# UPDATE March 19th 2026
Javascript nodes to set and get constants to reduce unnecessary lines. Takes in and returns anything, purely visual nodes.
On the right click menu of these nodes there's now an options to visualize the paths, as well as option to jump to the corresponding node on the other end.
Complete (backwards compatible as far as I'm aware currently) rewrite of Set/Get nodes.
**Known limitations**:
- Will not work with any node that dynamically sets it's outpute, such as reroute or other Set/Get node
- Will not work when directly connected to a bypassed node
- Other possible conflicts with javascript based nodes.
# Changelog March 19th 2026
### ColorToMask
## New Features
RBG color value to mask, works with batches and AnimateDiff.
- **Nodes 2.0 support** - Works with nodes 2.0 as well as legacy.
### ConditioningMultiCombine
- **Subgraph support** — Set/Get now works across subgraph boundaries. A Set in a parent graph is visible to all child subgraphs, and Get nodes search upward through ancestors. Cross-graph connections are resolved during prompt execution.
Combine any number of conditions, saves space.
- **Convert link to Set/Get** — Right-click any link midpoint to convert it into a Set/Get pair.
### ConditioningSetMaskAndCombine
- **Convert to links** — Right-click a Set or Get node to convert the pair back to direct links.
Mask and combine two sets of conditions, saves space.
- **Convert outputs to Set/Get** — Batch operation to convert all outputs of selected nodes into Set/Get pairs at once.
### GrowMaskWithBlur
- **Canvas right-click menu** — "Convert outputs on all selected nodes to Set/Get" and "Convert selected Set/Get to links".
Grows or shrinks (with negative values) mask, option to invert input, returns mask and inverted mask. Additionally Blurs the mask, this is a slow operation especially with big batches.
- **Keyboard shortcuts** — Ctrl+Shift+S (add Set to selected / at cursor), Ctrl+Shift+G (add Get at cursor), Ctrl+Shift+L (force-show all connections toggle).
### RoundMask
- **"Convert ALL Set/Get to links" setting** — One-click button in settings to replace every Set/Get pair with direct links, including across subgraph boundaries.
![image](https://github.com/kijai/ComfyUI-KJNodes/assets/40791699/52c85202-f74e-4b96-9dac-c8bda5ddcc40)
- **Show links setting** — Combo setting (never / selected / always) to control when virtual links between Set/Get pairs are drawn. "Selected" mode shows links only for currently selected Set/Get nodes. Ctrl+Shift+L temporarily forces all links visible.
### WidgetToString
Outputs the value of a widget on any node as a string
![example of use](docs/images/2024-04-03_20_49_29-ComfyUI.png)
- **Add Set/Get from connection menu** — When dragging from a slot, "Add SetNode" and "Add GetNode" entries appear next to "Add Reroute" in the connection menu.
Enable node id display from Manager menu, to get the ID of the node you want to read a widget from:
![enable node id display](docs/images/319121636-706b5081-9120-4a29-bd76-901691ada688.png)
- **Shift+middle-click creates Set/Get** — Shift+middle-click on an output slot creates a connected SetNode; on an input slot creates a connected GetNode. Optional setting to override normal middle-click to also create Set/Get instead of Reroute.
- **Add paired GetNode** — Right-click a SetNode to add a paired GetNode pre-configured with the same name.
- **Double-click Get to jump to Set** — Double-click a GetNode to center and select its paired SetNode.
- **Default widget value setting** — Controls the initial Constant value when a Set node is first connected: empty (default), slot name, slot name (lowercase), or slot name (UPPERCASE).
- **Type inference from output** — If a SetNode's input is unconnected but its output is connected to a typed input, the Set adopts that type and recolors accordingly.
- **Getters submenu navigates into subgraphs** — SetNode's getter list now includes cross-subgraph GetNodes and can navigate into subgraphs to focus them.
## Fixes
- **Paste rename coordination** — When pasting Set+Get pairs, GetNodes now correctly follow their SetNode's rename (e.g., `MODEL` -> `MODEL_0`).
- **Type/color reset on disconnect** — Disconnecting input or output now correctly resets type and color, but only when neither side is still connected.
- **Suffix stripping only on paste** — `validateName` no longer strips intentional `_N` suffixes from user-typed names. Only strips during paste to avoid `FOO_0_1_2` accumulation.
- **Connection drawing moved to canvas level** — Visualization links remain visible even when nodes are off-screen or collapsed.
## Settings
- Settings are now organized under **KJNodes > Set & Get** and **KJNodes > General** categories in the settings panel.
---
Use the node id of the target node, and add the name of the widget to read from
![use node id and widget name](docs/images/319121566-05f66385-7568-4b1f-8bbc-11053660b02f.png)
Recreating or reloading the target node will change its id, and the WidgetToString node will no longer be able to find it until you update the node id value with the new id.
+28 -206
View File
@@ -1,92 +1,12 @@
from .nodes.nodes import (
BOOLConstant, INTConstant, FloatConstant, StringConstant, StringConstantMultiline,
ScaleBatchPromptSchedule, GetLatentsFromBatchIndexed, ConditioningMultiCombine,
AppendStringsToList, JoinStrings, JoinStringMulti, CondPassThrough, ModelPassThrough,
ConditioningSetMaskAndCombine, ConditioningSetMaskAndCombine3,
ConditioningSetMaskAndCombine4, ConditioningSetMaskAndCombine5,
VRAM_Debug, SomethingToString, Sleep, EmptyLatentImagePresets,
EmptyLatentImageCustomPresets, WidgetToString, DummyOut, FlipSigmasAdjusted,
CustomSigmas, StringToFloatList, InjectNoiseToLatent, SoundReactive, GenerateNoise,
StableZero123_BatchSchedule, SV3D_BatchSchedule, Superprompt, CameraPoseVisualizer,
CheckpointPerturbWeights, DifferentialDiffusionAdvanced, FluxBlockLoraSelect,
HunyuanVideoBlockLoraSelect, Wan21BlockLoraSelect, LTX2BlockLoraSelect,
DiTBlockLoraLoader, CustomControlNetWeightsFluxFromList,
SetShakkerLabsUnionControlNetType, ModelSaveKJ, StyleModelApplyAdvanced,
AudioConcatenate, LeapfusionHunyuanI2V, ImageNoiseAugmentation, VAELoaderKJ, VAEMergeKJ,
ScheduledCFGGuidance, ApplyRifleXRoPE_WanVideo, ApplyRifleXRoPE_HunuyanVideo,
TimerNodeKJ, HunyuanVideoEncodeKeyframesToCond, LazySwitchKJ, LatentInpaintTTM,
SimpleCalculatorKJ, GetTrackRange, AddNoiseToTrackPath, VAEDecodeLoopKJ,
WanImageToVideoSVIPro, DeprecatedCompileNodeKJ, VisualizeSigmasKJ,
PreviewLatentNoiseMask, PlaySoundKJ,
)
from .nodes.curve_nodes import (
PlotCoordinates, SplineEditor, CreateShapeMaskOnPath, CreateShapeImageOnPath,
CreateTextOnPath, CreateGradientFromCoords, GradientToFloat, MaskOrImageToWeight,
WeightScheduleConvert, FloatToMask, WeightScheduleExtend, FloatToSigmas, SigmasToFloat,
GLIGENTextBoxApplyBatchCoords, CreateInstanceDiffusionTracking,
AppendInstanceDiffusionTracking, InterpolateCoords, DrawInstanceDiffusionTracking,
PointsEditor, CutAndDragOnPath,
)
from .nodes.batchcrop_nodes import (
BatchCropFromMask, BatchUncrop, BatchCropFromMaskAdvanced,
FilterZeroMasksAndCorrespondingImages, InsertImageBatchByIndexes, BatchUncropAdvanced,
SplitBboxes, BboxToInt, BboxVisualize,
)
from .nodes.audioscheduler_nodes import (
NormalizedAmplitudeToMask, NormalizedAmplitudeToFloatList,
OffsetMaskByNormalizedAmplitude, ImageTransformByNormalizedAmplitude,
)
from .nodes.image_nodes import (
ImagePass, ColorMatch, ColorMatchV2, SaveImageWithAlpha, ImageConcanate,
ImageConcatFromBatch, ImageGridComposite2x2, ImageGridComposite3x3,
ImageBatchTestPattern, ImageGrabPIL, Screencap_mss, ScreencapStream, WebcamCaptureCV2,
AddLabel, GetImageSizeAndCount, GetLatentSizeAndCount, ImageBatchRepeatInterleaving,
ImageUpscaleWithModelBatched, ImageNormalize_Neg1_To_1, RemapImageRange,
SplitImageChannels, MergeImageChannels, ImagePadForOutpaintMasked,
ImagePadForOutpaintTargetSize, ImagePrepForICLora, ImageAndMaskPreview,
CrossFadeImages, CrossFadeImagesMulti, TransitionImagesMulti, TransitionImagesInBatch,
ImageBatchJoinWithTransition, ShuffleImageBatch, GetImageRangeFromBatch,
RandomImageFromBatch, ImageBatchExtendWithOverlap, GetLatentRangeFromBatch,
InsertLatentToIndex, ImageBatchFilter, GetImagesFromBatchIndexed,
InsertImagesToBatchIndexed, PadImageBatchInterleaved, ReplaceImagesInBatch,
ReverseImageBatch, ImageBatchMulti, ImageTensorList, ImageAddMulti, ImageConcatMulti,
PreviewAnimation, ImageResizeKJ, ImageResizeKJv2, LoadAndResizeImage,
LoadImagesFromFolderKJ, ImageGridtoBatch, SaveImageKJ, SaveStringKJ, FastPreview,
FastPreviewBatch, ImageCropByMaskAndResize, ImageCropByMask, ImageUncropByMask,
ImageCropByMaskBatch, ImagePadKJ, LoadVideosFromFolder, EncodeVideoComponents,
DecodeAndSaveVideo, PreviewImageOrMask,
)
from .nodes.mask_nodes import (
BatchCLIPSeg, DownloadAndLoadCLIPSeg, CreateTextMask, ColorToMask, CreateFluidMask,
CreateAudioMask, CreateGradientMask, CreateFadeMask, CreateFadeMaskAdvanced,
CreateMagicMask, CreateShapeMask, CreateVoronoiMask, GetMaskSizeAndCount,
GrowMaskWithBlur, MaskBatchMulti, OffsetMask, RoundMask, ResizeMask, RemapMaskRange,
SeparateMasks, ConsolidateMasksKJ, DrawMaskOnImage, BlockifyMask,
)
from .nodes.model_optimization_nodes import (
PathchSageAttentionKJ, PatchFlashAttentionKJ, CheckpointLoaderKJ, DiffusionModelSelector,
DiffusionModelLoaderKJ, ModelPatchTorchSettings, PatchModelPatcherOrder,
TorchCompileModelFluxAdvancedV2, TorchCompileModelWanVideoV2,
TorchCompileModelAdvanced, TorchCompileVAE, TorchCompileControlNet,
WanVideoTeaCacheKJ, WanVideoEnhanceAVideoKJ, LTXVEnhanceAVideoKJ, WanVideoNAG, Krea2PromptWeight,
SkipLayerGuidanceWanVideo, CFGZeroStarAndInit, GGUFLoaderKJ, NABLA_AttentionKJ,
StartRecordCUDAMemoryHistory, EndRecordCUDAMemoryHistory, VisualizeCUDAMemoryHistory,
ModelMemoryUseReportPatch, ModelMemoryUsageFactorOverride, WanChunkFeedForward,
SamplerSelfRefineVideo, PiDColorBiasCorrection, Ideogram4OptimizationsKJ,
)
from .nodes.lora_nodes import LoraExtractKJ, LoraReduceRank
from .nodes.image_transform_node import ImageTransformKJ, BBOXToBoundingBoxKJ
from .nodes.sharpen_nodes import ImageSharpenKJ
from .nodes.nodes_looping import LOOP_NODES
from .nodes.hdr_preview_node import HDRPreviewKJ
from .nodes.preview_override_node import ModelPreviewOverrideKJ, GetPreviewOverrideFramesKJ
from .nodes.context_windows_visualizer import ContextWindowsVisualizerKJ
from .nodes.ideogram4_nodes import Ideogram4PromptBuilderKJ
from .nodes.flux3_nodes import Flux3PromptBuilderKJ, Flux3BoxReconcileKJ
import logging
from .nodes.nodes import *
from .nodes.curve_nodes import *
from .nodes.batchcrop_nodes import *
from .nodes.audioscheduler_nodes import *
from .nodes.image_nodes import *
from .nodes.intrinsic_lora_nodes import *
from .nodes.mask_nodes import *
from .nodes.model_optimization_nodes import *
from .nodes.lora_nodes import *
NODE_CONFIG = {
#constants
"BOOLConstant": {"class": BOOLConstant, "name": "BOOL Constant"},
@@ -101,12 +21,9 @@ NODE_CONFIG = {
"ConditioningSetMaskAndCombine4": {"class": ConditioningSetMaskAndCombine4, "name": "ConditioningSetMaskAndCombine4"},
"ConditioningSetMaskAndCombine5": {"class": ConditioningSetMaskAndCombine5, "name": "ConditioningSetMaskAndCombine5"},
"CondPassThrough": {"class": CondPassThrough},
"WanImageToVideoSVIPro": {"class": WanImageToVideoSVIPro, "name": "Wan Image To Video SVIPro"},
#masking
"DrawMaskOnImage": {"class": DrawMaskOnImage, "name": "Draw Mask On Image"},
"DownloadAndLoadCLIPSeg": {"class": DownloadAndLoadCLIPSeg, "name": "(Down)load CLIPSeg"},
"BatchCLIPSeg": {"class": BatchCLIPSeg, "name": "Batch CLIPSeg"},
"BlockifyMask": {"class": BlockifyMask, "name": "Blockify Mask"},
"ColorToMask": {"class": ColorToMask, "name": "Color To Mask"},
"CreateGradientMask": {"class": CreateGradientMask, "name": "Create Gradient Mask"},
"CreateTextMask": {"class": CreateTextMask, "name": "Create Text Mask"},
@@ -125,22 +42,18 @@ NODE_CONFIG = {
"ResizeMask": {"class": ResizeMask, "name": "Resize Mask"},
"RoundMask": {"class": RoundMask, "name": "Round Mask"},
"SeparateMasks": {"class": SeparateMasks, "name": "Separate Masks"},
"ConsolidateMasksKJ": {"class": ConsolidateMasksKJ, "name": "Consolidate Masks"},
#images
"AddLabel": {"class": AddLabel, "name": "Add Label"},
"ColorMatch": {"class": ColorMatch, "name": "Color Match"},
"ColorMatchV2": {"class": ColorMatchV2, "name": "Color Match V2"},
"ImageTensorList": {"class": ImageTensorList, "name": "Image Tensor List"},
"CrossFadeImages": {"class": CrossFadeImages, "name": "Cross Fade Images"},
"CrossFadeImagesMulti": {"class": CrossFadeImagesMulti, "name": "Cross Fade Images Multi"},
"GetImagesFromBatchIndexed": {"class": GetImagesFromBatchIndexed, "name": "Get Images From Batch Indexed"},
"GetImageRangeFromBatch": {"class": GetImageRangeFromBatch, "name": "Get Image or Mask Range From Batch"},
"RandomImageFromBatch": {"class": RandomImageFromBatch, "name": "Random Image From Batch"},
"GetLatentRangeFromBatch": {"class": GetLatentRangeFromBatch, "name": "Get Latent Range From Batch"},
"GetLatentSizeAndCount": {"class": GetLatentSizeAndCount, "name": "Get Latent Size & Count"},
"GetImageSizeAndCount": {"class": GetImageSizeAndCount, "name": "Get Image Size & Count"},
"FastPreview": {"class": FastPreview, "name": "Fast Preview"},
"FastPreviewBatch": {"class": FastPreviewBatch, "name": "Fast Preview Batch"},
"ImageBatchFilter": {"class": ImageBatchFilter, "name": "Image Batch Filter"},
"ImageAndMaskPreview": {"class": ImageAndMaskPreview},
"ImageAddMulti": {"class": ImageAddMulti, "name": "Image Add Multi"},
@@ -155,7 +68,6 @@ NODE_CONFIG = {
"ImageCropByMaskAndResize": {"class": ImageCropByMaskAndResize, "name": "Image Crop By Mask And Resize"},
"ImageCropByMaskBatch": {"class": ImageCropByMaskBatch, "name": "Image Crop By Mask Batch"},
"ImageUncropByMask": {"class": ImageUncropByMask, "name": "Image Uncrop By Mask"},
"ImageBatchExtendWithOverlap": {"class": ImageBatchExtendWithOverlap, "name": "Image Batch Extend With Overlap"},
"ImageGrabPIL": {"class": ImageGrabPIL, "name": "Image Grab PIL"},
"ImageGridComposite2x2": {"class": ImageGridComposite2x2, "name": "Image Grid Composite 2x2"},
"ImageGridComposite3x3": {"class": ImageGridComposite3x3, "name": "Image Grid Composite 3x3"},
@@ -187,18 +99,6 @@ NODE_CONFIG = {
"SplitImageChannels": {"class": SplitImageChannels, "name": "Split Image Channels"},
"TransitionImagesMulti": {"class": TransitionImagesMulti, "name": "Transition Images Multi"},
"TransitionImagesInBatch": {"class": TransitionImagesInBatch, "name": "Transition Images In Batch"},
"EncodeVideoComponents": {"class": EncodeVideoComponents, "name": "Encode Video Components"},
"DecodeAndSaveVideo": {"class": DecodeAndSaveVideo, "name": "Decode And Save Video"},
"ImageTransformKJ": {"class": ImageTransformKJ, "name": "Image Transform KJ"},
"BBOXToBoundingBoxKJ": {"class": BBOXToBoundingBoxKJ, "name": "BBOX to Bounding Box KJ"},
"Ideogram4PromptBuilderKJ": {"class": Ideogram4PromptBuilderKJ, "name": "Ideogram 4 Prompt Builder KJ"},
"Flux3PromptBuilderKJ": {"class": Flux3PromptBuilderKJ, "name": "FLUX.3 Prompt Builder KJ"},
"Flux3BoxReconcileKJ": {"class": Flux3BoxReconcileKJ, "name": "FLUX.3 Box Reconcile KJ"},
"HDRPreviewKJ": {"class": HDRPreviewKJ, "name": "HDR Preview KJ"},
"ModelPreviewOverrideKJ": {"class": ModelPreviewOverrideKJ, "name": "Model Preview Override KJ"},
"GetPreviewOverrideFramesKJ": {"class": GetPreviewOverrideFramesKJ, "name": "Get Preview Override Frames KJ"},
"PreviewImageOrMask": {"class": PreviewImageOrMask, "name": "Preview Image Or Mask"},
"ImageSharpenKJ": {"class": ImageSharpenKJ, "name": "Image Sharpen KJ"},
#batch cropping
"BatchCropFromMask": {"class": BatchCropFromMask, "name": "Batch Crop From Mask"},
"BatchCropFromMaskAdvanced": {"class": BatchCropFromMaskAdvanced, "name": "Batch Crop From Mask Advanced"},
@@ -225,10 +125,10 @@ NODE_CONFIG = {
"AppendStringsToList": {"class": AppendStringsToList, "name": "Append Strings To List"},
"JoinStrings": {"class": JoinStrings, "name": "Join Strings"},
"JoinStringMulti": {"class": JoinStringMulti, "name": "Join String Multi"},
"SimpleCalculatorKJ": {"class": SimpleCalculatorKJ, "name": "Simple Calculator KJ"},
"SomethingToString": {"class": SomethingToString, "name": "Something To String"},
"Sleep": {"class": Sleep, "name": "Sleep"},
"VRAM_Debug": {"class": VRAM_Debug, "name": "VRAM Debug"},
"SomethingToString": {"class": SomethingToString, "name": "Something To String"},
"EmptyLatentImagePresets": {"class": EmptyLatentImagePresets, "name": "Empty Latent Image Presets"},
"EmptyLatentImageCustomPresets": {"class": EmptyLatentImageCustomPresets, "name": "Empty Latent Image Custom Presets"},
"ModelPassThrough": {"class": ModelPassThrough, "name": "ModelPass"},
@@ -237,7 +137,6 @@ NODE_CONFIG = {
"StyleModelApplyAdvanced": {"class": StyleModelApplyAdvanced, "name": "Style Model Apply Advanced"},
"DiffusionModelSelector": {"class": DiffusionModelSelector, "name": "Diffusion Model Selector"},
"LazySwitchKJ": {"class": LazySwitchKJ, "name": "Lazy Switch KJ"},
"VisualizeSigmasKJ": {"class": VisualizeSigmasKJ, "name": "Visualize Sigmas KJ"},
#audioscheduler stuff
"NormalizedAmplitudeToMask": {"class": NormalizedAmplitudeToMask},
"NormalizedAmplitudeToFloatList": {"class": NormalizedAmplitudeToFloatList},
@@ -265,31 +164,35 @@ NODE_CONFIG = {
"SoundReactive": {"class": SoundReactive, "name": "Sound Reactive"},
"StableZero123_BatchSchedule": {"class": StableZero123_BatchSchedule, "name": "Stable Zero123 Batch Schedule"},
"SV3D_BatchSchedule": {"class": SV3D_BatchSchedule, "name": "SV3D Batch Schedule"},
"LoadResAdapterNormalization": {"class": LoadResAdapterNormalization},
"Superprompt": {"class": Superprompt, "name": "Superprompt"},
"GLIGENTextBoxApplyBatchCoords": {"class": GLIGENTextBoxApplyBatchCoords},
"Intrinsic_lora_sampling": {"class": Intrinsic_lora_sampling, "name": "Intrinsic Lora Sampling"},
"CheckpointPerturbWeights": {"class": CheckpointPerturbWeights, "name": "CheckpointPerturbWeights"},
"Screencap_mss": {"class": Screencap_mss, "name": "Screencap mss"},
"ScreencapStream": {"class": ScreencapStream, "name": "Screencap Stream"},
"WebcamCaptureCV2": {"class": WebcamCaptureCV2, "name": "Webcam Capture CV2"},
"DifferentialDiffusionAdvanced": {"class": DifferentialDiffusionAdvanced, "name": "Differential Diffusion Advanced"},
"DiTBlockLoraLoader": {"class": DiTBlockLoraLoader, "name": "DiT Block Lora Loader"},
"FluxBlockLoraSelect": {"class": FluxBlockLoraSelect, "name": "Flux Block Lora Select"},
"HunyuanVideoBlockLoraSelect": {"class": HunyuanVideoBlockLoraSelect, "name": "Hunyuan Video Block Lora Select"},
"Wan21BlockLoraSelect": {"class": Wan21BlockLoraSelect, "name": "Wan21 Block Lora Select"},
"LTX2BlockLoraSelect": {"class": LTX2BlockLoraSelect, "name": "LTX2 Block Lora Select"},
"CustomControlNetWeightsFluxFromList": {"class": CustomControlNetWeightsFluxFromList, "name": "Custom ControlNet Weights Flux From List"},
"CheckpointLoaderKJ": {"class": CheckpointLoaderKJ, "name": "CheckpointLoaderKJ"},
"DiffusionModelLoaderKJ": {"class": DiffusionModelLoaderKJ, "name": "Diffusion Model Loader KJ"},
"TorchCompileModelFluxAdvanced": {"class": TorchCompileModelFluxAdvanced, "name": "TorchCompileModelFluxAdvanced"},
"TorchCompileModelFluxAdvancedV2": {"class": TorchCompileModelFluxAdvancedV2, "name": "TorchCompileModelFluxAdvancedV2"},
"TorchCompileModelHyVideo": {"class": TorchCompileModelHyVideo, "name": "TorchCompileModelHyVideo"},
"TorchCompileVAE": {"class": TorchCompileVAE, "name": "TorchCompileVAE"},
"TorchCompileControlNet": {"class": TorchCompileControlNet, "name": "TorchCompileControlNet"},
"PatchModelPatcherOrder": {"class": PatchModelPatcherOrder, "name": "Patch Model Patcher Order"},
"TorchCompileLTXModel": {"class": TorchCompileLTXModel, "name": "TorchCompileLTXModel"},
"TorchCompileCosmosModel": {"class": TorchCompileCosmosModel, "name": "TorchCompileCosmosModel"},
"TorchCompileModelQwenImage": {"class": TorchCompileModelQwenImage, "name": "TorchCompileModelQwenImage"},
"TorchCompileModelWanVideo": {"class": TorchCompileModelWanVideo, "name": "TorchCompileModelWanVideo"},
"TorchCompileModelWanVideoV2": {"class": TorchCompileModelWanVideoV2, "name": "TorchCompileModelWanVideoV2"},
"PathchSageAttentionKJ": {"class": PathchSageAttentionKJ, "name": "Patch Sage Attention KJ"},
"PatchFlashAttentionKJ": {"class": PatchFlashAttentionKJ, "name": "Patch Flash Attention KJ"},
"LeapfusionHunyuanI2VPatcher": {"class": LeapfusionHunyuanI2V, "name": "Leapfusion Hunyuan I2V Patcher"},
"VAELoaderKJ": {"class": VAELoaderKJ, "name": "VAELoader KJ"},
"VAEMergeKJ": {"class": VAEMergeKJ, "name": "VAE Merge KJ"},
"VAEDecodeLoopKJ": {"class": VAEDecodeLoopKJ, "name": "VAE Decode Loop KJ"},
"ScheduledCFGGuidance": {"class": ScheduledCFGGuidance, "name": "Scheduled CFG Guidance"},
"ApplyRifleXRoPE_HunuyanVideo": {"class": ApplyRifleXRoPE_HunuyanVideo, "name": "Apply RifleXRoPE HunuyanVideo"},
"ApplyRifleXRoPE_WanVideo": {"class": ApplyRifleXRoPE_WanVideo, "name": "Apply RifleXRoPE WanVideo"},
@@ -299,24 +202,8 @@ NODE_CONFIG = {
"TimerNodeKJ": {"class": TimerNodeKJ, "name": "Timer Node KJ"},
"HunyuanVideoEncodeKeyframesToCond": {"class": HunyuanVideoEncodeKeyframesToCond, "name": "HunyuanVideo Encode Keyframes To Cond"},
"CFGZeroStarAndInit": {"class": CFGZeroStarAndInit, "name": "CFG Zero Star/Init"},
"PiDColorBiasCorrection": {"class": PiDColorBiasCorrection, "name": "PiD Color Bias Correction"},
"ModelPatchTorchSettings": {"class": ModelPatchTorchSettings, "name": "Model Patch Torch Settings"},
"WanVideoNAG": {"class": WanVideoNAG, "name": "WanVideoNAG"},
"Krea2PromptWeight": {"class": Krea2PromptWeight, "name": "Krea2 Prompt Weight"},
"GGUFLoaderKJ": {"class": GGUFLoaderKJ, "name": "GGUF Loader KJ"},
"LatentInpaintTTM": {"class": LatentInpaintTTM, "name": "Latent Inpaint TTM"},
"NABLA_AttentionKJ": {"class": NABLA_AttentionKJ, "name": "NABLA Attention KJ"},
"TorchCompileModelAdvanced": {"class": TorchCompileModelAdvanced, "name": "TorchCompileModelAdvanced"},
"StartRecordCUDAMemoryHistory": {"class": StartRecordCUDAMemoryHistory, "name": "Start Recording CUDAMemory History"},
"EndRecordCUDAMemoryHistory": {"class": EndRecordCUDAMemoryHistory, "name": "End Recording CUDAMemory History"},
"VisualizeCUDAMemoryHistory": {"class": VisualizeCUDAMemoryHistory, "name": "Visualize CUDAMemory History"},
"PreviewLatentNoiseMask": {"class": PreviewLatentNoiseMask, "name": "Preview Latent Noise Mask"},
"ModelMemoryUseReportPatch": {"class": ModelMemoryUseReportPatch, "name": "Model Memory Use Report Patch"},
"ModelMemoryUsageFactorOverride": {"class": ModelMemoryUsageFactorOverride, "name": "Model Memory Usage Factor Override"},
"WanChunkFeedForward": {"class": WanChunkFeedForward, "name": "Wan ChunkFeedForward"},
"Ideogram4OptimizationsKJ": {"class": Ideogram4OptimizationsKJ, "name": "Ideogram4 Optimizations KJ"},
"SamplerSelfRefineVideo": {"class": SamplerSelfRefineVideo, "name": "Sampler SelfRefineVideo"},
"PlaySoundKJ": {"class": PlaySoundKJ, "name": "Play Sound KJ"},
#instance diffusion
"CreateInstanceDiffusionTracking": {"class": CreateInstanceDiffusionTracking},
@@ -325,74 +212,9 @@ NODE_CONFIG = {
#lora
"LoraExtractKJ": {"class": LoraExtractKJ, "name": "LoraExtractKJ"},
"LoraReduceRankKJ": {"class": LoraReduceRank, "name": "LoraReduceRank"},
#looping
**{n.GET_SCHEMA().node_id: {"class": n, "name": n.GET_SCHEMA().display_name} for n in LOOP_NODES},
#tracks
"GetTrackRange": {"class": GetTrackRange, "name": "Get Track Range"},
"AddNoiseToTrackPath": {"class": AddNoiseToTrackPath, "name": "Add Noise To Track"},
#context windows
"ContextWindowsVisualizerKJ": {"class": ContextWindowsVisualizerKJ, "name": "Context Windows Visualizer (KJ)"},
# deprecated
"PatchModelPatcherOrder": {"class": PatchModelPatcherOrder, "name": "Patch Model Patcher Order"},
"TorchCompileModelFluxAdvanced": {"class": DeprecatedCompileNodeKJ, "name": "TorchCompileModelFluxAdvanced"},
"TorchCompileLTXModel": {"class": DeprecatedCompileNodeKJ, "name": "TorchCompileLTXModel"},
"TorchCompileCosmosModel": {"class": DeprecatedCompileNodeKJ, "name": "TorchCompileCosmosModel"},
"TorchCompileModelHyVideo": {"class": DeprecatedCompileNodeKJ, "name": "TorchCompileModelHyVideo"},
"TorchCompileModelQwenImage": {"class": DeprecatedCompileNodeKJ, "name": "TorchCompileModelQwenImage"},
"TorchCompileModelWanVideo": {"class": DeprecatedCompileNodeKJ, "name": "TorchCompileModelWanVideo"},
"LoraReduceRankKJ": {"class": LoraReduceRank, "name": "LoraReduceRank"}
}
#ltxv
try:
from .nodes.ltxv_nodes import (
LTXVAddGuideMulti, LTXVAddGuidesFromBatch, LTXVAudioVideoMask, LTX2_NAG,
LTXVChunkFeedForward, LTX2SamplingPreviewOverride,
LTX2AudioLatentNormalizingSampling, LTXVImgToVideoInplaceKJ,
LTX2AttentionTunerPatch, LTX2MemoryEfficientSageAttentionPatch,
LTX2LoraLoaderAdvanced, WanVideoMemoryEfficientSageAttentionPatch,
MiniMaxH3MemoryEfficientSageAttentionPatch,
)
NODE_CONFIG.update({
"LTXVEnhanceAVideoKJ": {"class": LTXVEnhanceAVideoKJ, "name": "LTXV Enhance A Video KJ"},
"LTXVAddGuideMulti": {"class": LTXVAddGuideMulti, "name": "LTXV Add Guide Multi"},
"LTXVAddGuidesFromBatch": {"class": LTXVAddGuidesFromBatch, "name": "LTXV Add Guides From Batch"},
"LTXVAudioVideoMask": {"class": LTXVAudioVideoMask, "name": "LTXV Audio Video Mask"},
"LTX2_NAG": {"class": LTX2_NAG, "name": "LTX2 NAG"},
"LTXVChunkFeedForward": {"class": LTXVChunkFeedForward, "name": "LTXV Chunk Feed Forward"},
"LTX2SamplingPreviewOverride": {"class": LTX2SamplingPreviewOverride, "name": "LTX2 Sampling Preview Override"},
"LTX2AudioLatentNormalizingSampling": {"class": LTX2AudioLatentNormalizingSampling, "name": "LTX2 Audio Latent Normalizing Sampling"},
"LTXVImgToVideoInplaceKJ": {"class": LTXVImgToVideoInplaceKJ, "name": "LTXV Img To Video Inplace KJ"},
"LTX2AttentionTunerPatch": {"class": LTX2AttentionTunerPatch, "name": "LTX2 Attention Tuner Patch"},
"LTX2MemoryEfficientSageAttentionPatch": {"class": LTX2MemoryEfficientSageAttentionPatch, "name": "LTX2 Memory Efficient Sage Attention Patch"},
"LTX2LoraLoaderAdvanced": {"class": LTX2LoraLoaderAdvanced, "name": "LTX2 Lora Loader Advanced"},
"WanVideoMemoryEfficientSageAttentionPatch": {"class": WanVideoMemoryEfficientSageAttentionPatch, "name": "WanVideo Memory Efficient Sage Attention Patch"},
"MiniMaxH3MemoryEfficientSageAttentionPatch": {"class": MiniMaxH3MemoryEfficientSageAttentionPatch, "name": "MiniMax H3 Memory Efficient Sage Attention Patch"},
})
except Exception as e:
logging.warning(f"KJNodes: LTXV nodes could not be imported. LTXV nodes will be unavailable. Error: {e}", exc_info=True)
#minimax
try:
from .nodes.minimax_nodes import MiniMaxChunkFeedForward, MiniMaxLowVRAMAttention, MiniMaxH3TokenCounter
NODE_CONFIG.update({
"MiniMaxChunkFeedForward": {"class": MiniMaxChunkFeedForward, "name": "MiniMax H3 ChunkFeedForward"},
"MiniMaxLowVRAMAttention": {"class": MiniMaxLowVRAMAttention, "name": "MiniMax H3 Low VRAM Attention"},
"MiniMaxH3TokenCounter": {"class": MiniMaxH3TokenCounter, "name": "MiniMax H3 Token Counter"},
})
except Exception as e:
logging.warning(f"KJNodes: MiniMax nodes could not be imported. MiniMax nodes will be unavailable. Error: {e}", exc_info=True)
try:
from .nodes.triton_vae import PatchTritonVAE
NODE_CONFIG.update({
"PatchTritonVAE": {"class": PatchTritonVAE, "name": "Patch Triton VAE"},
})
except Exception:
logging.warning("KJNodes: PatchTritonVAE node could not be imported. PatchTritonVAE will be unavailable.", exc_info=True)
def generate_node_mappings(node_config):
node_class_mappings = {}
node_display_name_mappings = {}
@@ -414,11 +236,11 @@ from server import PromptServer
from pathlib import Path
if hasattr(PromptServer, "instance"):
# NOTE: we add an extra static path to avoid comfy mechanism that loads every script in web.
if not PromptServer.instance.app.router.frozen:
try:
PromptServer.instance.app.add_routes(
[web.static("/kjweb_async", (Path(__file__).parent.absolute() / "kjweb_async").as_posix())]
)
except Exception:
logging.exception("KJNodes: failed to register /kjweb_async static route")
try:
# NOTE: we add an extra static path to avoid comfy mechanism
# that loads every script in web.
PromptServer.instance.app.add_routes(
[web.static("/kjweb_async", (Path(__file__).parent.absolute() / "kjweb_async").as_posix())]
)
except:
pass
+4
View File
@@ -0,0 +1,4 @@
source for the loras:
https://github.com/duxiaodan/intrinsic-lora
Renamed and conveted to .safetensors
+277
View File
@@ -0,0 +1,277 @@
var a;if(!Array.prototype.map)Array.prototype.map=function(b,c){for(var d=this.length,f=new Array(d),g=0;g<d;g++)if(g in this)f[g]=b.call(c,this[g],g,this);return f};if(!Array.prototype.filter)Array.prototype.filter=function(b,c){for(var d=this.length,f=[],g=0;g<d;g++)if(g in this){var h=this[g];b.call(c,h,g,this)&&f.push(h)}return f};if(!Array.prototype.forEach)Array.prototype.forEach=function(b,c){for(var d=this.length>>>0,f=0;f<d;f++)f in this&&b.call(c,this[f],f,this)};
if(!Array.prototype.reduce)Array.prototype.reduce=function(b,c){var d=this.length;if(!d&&arguments.length==1)throw new Error("reduce: empty array, no initial value");var f=0;if(arguments.length<2)for(;;){if(f in this){c=this[f++];break}if(++f>=d)throw new Error("reduce: no values, no initial value");}for(;f<d;f++)if(f in this)c=b(c,this[f],f,this);return c};var pv={};pv.version="3.3.1";pv.identity=function(b){return b};pv.index=function(){return this.index};pv.child=function(){return this.childIndex};
pv.parent=function(){return this.parent.index};pv.extend=function(b){function c(){}c.prototype=b.prototype||b;return new c};
try{eval("pv.parse = function(x) x;")}catch(e){pv.parse=function(b){for(var c=new RegExp("function\\s*(\\b\\w+)?\\s*\\([^)]*\\)\\s*","mg"),d,f,g=0,h="";d=c.exec(b);){d=d.index+d[0].length;if(b.charAt(d)!="{"){h+=b.substring(g,d)+"{return ";g=d;for(var i=0;i>=0&&d<b.length;d++){var j=b.charAt(d);switch(j){case '"':case "'":for(;++d<b.length&&(f=b.charAt(d))!=j;)f=="\\"&&d++;break;case "[":case "(":i++;break;case "]":case ")":i--;break;case ";":case ",":i==0&&i--;break}}h+=pv.parse(b.substring(g,--d))+
";}";g=d}c.lastIndex=d}h+=b.substring(g);return h}}pv.css=function(b,c){return window.getComputedStyle?window.getComputedStyle(b,null).getPropertyValue(c):b.currentStyle[c]};pv.error=function(b){typeof console=="undefined"?alert(b):console.error(b)};pv.listen=function(b,c,d){d=pv.listener(d);return b.addEventListener?b.addEventListener(c,d,false):b.attachEvent("on"+c,d)};pv.listener=function(b){return b.$listener||(b.$listener=function(c){try{pv.event=c;return b.call(this,c)}finally{delete pv.event}})};
pv.ancestor=function(b,c){for(;c;){if(c==b)return true;c=c.parentNode}return false};pv.id=function(){var b=1;return function(){return b++}}();pv.functor=function(b){return typeof b=="function"?b:function(){return b}};pv.listen(window,"load",function(){for(pv.$={i:0,x:document.getElementsByTagName("script")};pv.$.i<pv.$.x.length;pv.$.i++){pv.$.s=pv.$.x[pv.$.i];if(pv.$.s.type=="text/javascript+protovis")try{window.eval(pv.parse(pv.$.s.text))}catch(b){pv.error(b)}}delete pv.$});pv.Format={};
pv.Format.re=function(b){return b.replace(/[\\\^\$\*\+\?\[\]\(\)\.\{\}]/g,"\\$&")};pv.Format.pad=function(b,c,d){c=c-String(d).length;return c<1?d:(new Array(c+1)).join(b)+d};
pv.Format.date=function(b){function c(f){return b.replace(/%[a-zA-Z0-9]/g,function(g){switch(g){case "%a":return["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][f.getDay()];case "%A":return["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"][f.getDay()];case "%h":case "%b":return["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][f.getMonth()];case "%B":return["January","February","March","April","May","June","July","August","September","October","November","December"][f.getMonth()];
case "%c":return f.toLocaleString();case "%C":return d("0",2,Math.floor(f.getFullYear()/100)%100);case "%d":return d("0",2,f.getDate());case "%x":case "%D":return d("0",2,f.getMonth()+1)+"/"+d("0",2,f.getDate())+"/"+d("0",2,f.getFullYear()%100);case "%e":return d(" ",2,f.getDate());case "%H":return d("0",2,f.getHours());case "%I":return(g=f.getHours()%12)?d("0",2,g):12;case "%m":return d("0",2,f.getMonth()+1);case "%M":return d("0",2,f.getMinutes());case "%n":return"\n";case "%p":return f.getHours()<
12?"AM":"PM";case "%T":case "%X":case "%r":g=f.getHours()%12;return(g?d("0",2,g):12)+":"+d("0",2,f.getMinutes())+":"+d("0",2,f.getSeconds())+" "+(f.getHours()<12?"AM":"PM");case "%R":return d("0",2,f.getHours())+":"+d("0",2,f.getMinutes());case "%S":return d("0",2,f.getSeconds());case "%Q":return d("0",3,f.getMilliseconds());case "%t":return"\t";case "%u":return(g=f.getDay())?g:1;case "%w":return f.getDay();case "%y":return d("0",2,f.getFullYear()%100);case "%Y":return f.getFullYear();case "%%":return"%"}return g})}
var d=pv.Format.pad;c.format=c;c.parse=function(f){var g=1970,h=0,i=1,j=0,k=0,l=0,q=[function(){}],n=pv.Format.re(b).replace(/%[a-zA-Z0-9]/g,function(p){switch(p){case "%b":q.push(function(m){h={Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11}[m]});return"([A-Za-z]+)";case "%h":case "%B":q.push(function(m){h={January:0,February:1,March:2,April:3,May:4,June:5,July:6,August:7,September:8,October:9,November:10,December:11}[m]});return"([A-Za-z]+)";case "%e":case "%d":q.push(function(m){i=
m});return"([0-9]+)";case "%I":case "%H":q.push(function(m){j=m});return"([0-9]+)";case "%m":q.push(function(m){h=m-1});return"([0-9]+)";case "%M":q.push(function(m){k=m});return"([0-9]+)";case "%p":q.push(function(m){if(j==12){if(m=="am")j=0}else if(m=="pm")j=Number(j)+12});return"(am|pm)";case "%S":q.push(function(m){l=m});return"([0-9]+)";case "%y":q.push(function(m){m=Number(m);g=m+(0<=m&&m<69?2E3:m>=69&&m<100?1900:0)});return"([0-9]+)";case "%Y":q.push(function(m){g=m});return"([0-9]+)";case "%%":q.push(function(){});
return"%"}return p});(f=f.match(n))&&f.forEach(function(p,m){q[m](p)});return new Date(g,h,i,j,k,l)};return c};
pv.Format.time=function(b){function c(f){f=Number(f);switch(b){case "short":if(f>=31536E6)return(f/31536E6).toFixed(1)+" years";else if(f>=6048E5)return(f/6048E5).toFixed(1)+" weeks";else if(f>=864E5)return(f/864E5).toFixed(1)+" days";else if(f>=36E5)return(f/36E5).toFixed(1)+" hours";else if(f>=6E4)return(f/6E4).toFixed(1)+" minutes";return(f/1E3).toFixed(1)+" seconds";case "long":var g=[],h=f%36E5/6E4>>0;g.push(d("0",2,f%6E4/1E3>>0));if(f>=36E5){var i=f%864E5/36E5>>0;g.push(d("0",2,h));if(f>=864E5){g.push(d("0",
2,i));g.push(Math.floor(f/864E5).toFixed())}else g.push(i.toFixed())}else g.push(h.toFixed());return g.reverse().join(":")}}var d=pv.Format.pad;c.format=c;c.parse=function(f){switch(b){case "short":for(var g=/([0-9,.]+)\s*([a-z]+)/g,h,i=0;h=g.exec(f);){var j=parseFloat(h[0].replace(",","")),k=0;switch(h[2].toLowerCase()){case "year":case "years":k=31536E6;break;case "week":case "weeks":k=6048E5;break;case "day":case "days":k=864E5;break;case "hour":case "hours":k=36E5;break;case "minute":case "minutes":k=
6E4;break;case "second":case "seconds":k=1E3;break}i+=j*k}return i;case "long":h=f.replace(",","").split(":").reverse();i=0;if(h.length)i+=parseFloat(h[0])*1E3;if(h.length>1)i+=parseFloat(h[1])*6E4;if(h.length>2)i+=parseFloat(h[2])*36E5;if(h.length>3)i+=parseFloat(h[3])*864E5;return i}};return c};
pv.Format.number=function(){function b(r){if(Infinity>h)r=Math.round(r*i)/i;var s=String(Math.abs(r)).split("."),t=s[0];if(t.length>d)t=t.substring(t.length-d);if(l&&t.length<c)t=(new Array(c-t.length+1)).join(j)+t;if(t.length>3)t=t.replace(/\B(?=(?:\d{3})+(?!\d))/g,n);if(!l&&t.length<f)t=(new Array(f-t.length+1)).join(j)+t;s[0]=r<0?p+t+m:t;r=s[1]||"";if(r.length<g)s[1]=r+(new Array(g-r.length+1)).join(k);return s.join(q)}var c=0,d=Infinity,f=0,g=0,h=0,i=1,j="0",k="0",l=true,q=".",n=",",p="\u2212",
m="";b.format=b;b.parse=function(r){var s=pv.Format.re;r=String(r).replace(new RegExp("^("+s(j)+")*"),"").replace(new RegExp("("+s(k)+")*$"),"").split(q);s=r[0].replace(new RegExp(s(n),"g"),"");if(s.length>d)s=s.substring(s.length-d);r=r[1]?Number("0."+r[1]):0;if(Infinity>h)r=Math.round(r*i)/i;return Math.round(s)+r};b.integerDigits=function(r,s){if(arguments.length){c=Number(r);d=arguments.length>1?Number(s):c;f=c+Math.floor(c/3)*n.length;return this}return[c,d]};b.fractionDigits=function(r,s){if(arguments.length){g=
Number(r);h=arguments.length>1?Number(s):g;i=Math.pow(10,h);return this}return[g,h]};b.integerPad=function(r){if(arguments.length){j=String(r);l=/\d/.test(j);return this}return j};b.fractionPad=function(r){if(arguments.length){k=String(r);return this}return k};b.decimal=function(r){if(arguments.length){q=String(r);return this}return q};b.group=function(r){if(arguments.length){n=r?String(r):"";f=c+Math.floor(c/3)*n.length;return this}return n};b.negativeAffix=function(r,s){if(arguments.length){p=String(r||
"");m=String(s||"");return this}return[p,m]};return b};pv.map=function(b,c){var d={};return c?b.map(function(f,g){d.index=g;return c.call(d,f)}):b.slice()};pv.repeat=function(b,c){if(arguments.length==1)c=2;return pv.blend(pv.range(c).map(function(){return b}))};pv.cross=function(b,c){for(var d=[],f=0,g=b.length,h=c.length;f<g;f++)for(var i=0,j=b[f];i<h;i++)d.push([j,c[i]]);return d};pv.blend=function(b){return Array.prototype.concat.apply([],b)};
pv.transpose=function(b){var c=b.length,d=pv.max(b,function(i){return i.length});if(d>c){b.length=d;for(var f=c;f<d;f++)b[f]=new Array(c);for(f=0;f<c;f++)for(var g=f+1;g<d;g++){var h=b[f][g];b[f][g]=b[g][f];b[g][f]=h}}else{for(f=0;f<d;f++)b[f].length=c;for(f=0;f<c;f++)for(g=0;g<f;g++){h=b[f][g];b[f][g]=b[g][f];b[g][f]=h}}b.length=d;for(f=0;f<d;f++)b[f].length=c;return b};pv.normalize=function(b,c){b=pv.map(b,c);c=pv.sum(b);for(var d=0;d<b.length;d++)b[d]/=c;return b};
pv.permute=function(b,c,d){if(!d)d=pv.identity;var f=new Array(c.length),g={};c.forEach(function(h,i){g.index=h;f[i]=d.call(g,b[h])});return f};pv.numerate=function(b,c){if(!c)c=pv.identity;var d={},f={};b.forEach(function(g,h){f.index=h;d[c.call(f,g)]=h});return d};pv.uniq=function(b,c){if(!c)c=pv.identity;var d={},f=[],g={},h;b.forEach(function(i,j){g.index=j;h=c.call(g,i);h in d||(d[h]=f.push(h))});return f};pv.naturalOrder=function(b,c){return b<c?-1:b>c?1:0};
pv.reverseOrder=function(b,c){return c<b?-1:c>b?1:0};pv.search=function(b,c,d){if(!d)d=pv.identity;for(var f=0,g=b.length-1;f<=g;){var h=f+g>>1,i=d(b[h]);if(i<c)f=h+1;else if(i>c)g=h-1;else return h}return-f-1};pv.search.index=function(b,c,d){b=pv.search(b,c,d);return b<0?-b-1:b};
pv.range=function(b,c,d){if(arguments.length==1){c=b;b=0}if(d==undefined)d=1;if((c-b)/d==Infinity)throw new Error("range must be finite");var f=[],g=0,h;c-=(c-b)*1.0E-10;if(d<0)for(;(h=b+d*g++)>c;)f.push(h);else for(;(h=b+d*g++)<c;)f.push(h);return f};pv.random=function(b,c,d){if(arguments.length==1){c=b;b=0}if(d==undefined)d=1;return d?Math.floor(Math.random()*(c-b)/d)*d+b:Math.random()*(c-b)+b};
pv.sum=function(b,c){var d={};return b.reduce(c?function(f,g,h){d.index=h;return f+c.call(d,g)}:function(f,g){return f+g},0)};pv.max=function(b,c){if(c==pv.index)return b.length-1;return Math.max.apply(null,c?pv.map(b,c):b)};pv.max.index=function(b,c){if(!b.length)return-1;if(c==pv.index)return b.length-1;if(!c)c=pv.identity;for(var d=0,f=-Infinity,g={},h=0;h<b.length;h++){g.index=h;var i=c.call(g,b[h]);if(i>f){f=i;d=h}}return d};
pv.min=function(b,c){if(c==pv.index)return 0;return Math.min.apply(null,c?pv.map(b,c):b)};pv.min.index=function(b,c){if(!b.length)return-1;if(c==pv.index)return 0;if(!c)c=pv.identity;for(var d=0,f=Infinity,g={},h=0;h<b.length;h++){g.index=h;var i=c.call(g,b[h]);if(i<f){f=i;d=h}}return d};pv.mean=function(b,c){return pv.sum(b,c)/b.length};
pv.median=function(b,c){if(c==pv.index)return(b.length-1)/2;b=pv.map(b,c).sort(pv.naturalOrder);if(b.length%2)return b[Math.floor(b.length/2)];c=b.length/2;return(b[c-1]+b[c])/2};pv.variance=function(b,c){if(b.length<1)return NaN;if(b.length==1)return 0;var d=pv.mean(b,c),f=0,g={};if(!c)c=pv.identity;for(var h=0;h<b.length;h++){g.index=h;var i=c.call(g,b[h])-d;f+=i*i}return f};pv.deviation=function(b,c){return Math.sqrt(pv.variance(b,c)/(b.length-1))};pv.log=function(b,c){return Math.log(b)/Math.log(c)};
pv.logSymmetric=function(b,c){return b==0?0:b<0?-pv.log(-b,c):pv.log(b,c)};pv.logAdjusted=function(b,c){if(!isFinite(b))return b;var d=b<0;if(b<c)b+=(c-b)/c;return d?-pv.log(b,c):pv.log(b,c)};pv.logFloor=function(b,c){return b>0?Math.pow(c,Math.floor(pv.log(b,c))):-Math.pow(c,-Math.floor(-pv.log(-b,c)))};pv.logCeil=function(b,c){return b>0?Math.pow(c,Math.ceil(pv.log(b,c))):-Math.pow(c,-Math.ceil(-pv.log(-b,c)))};
(function(){var b=Math.PI/180,c=180/Math.PI;pv.radians=function(d){return b*d};pv.degrees=function(d){return c*d}})();pv.keys=function(b){var c=[];for(var d in b)c.push(d);return c};pv.entries=function(b){var c=[];for(var d in b)c.push({key:d,value:b[d]});return c};pv.values=function(b){var c=[];for(var d in b)c.push(b[d]);return c};pv.dict=function(b,c){for(var d={},f={},g=0;g<b.length;g++)if(g in b){var h=b[g];f.index=g;d[h]=c.call(f,h)}return d};pv.dom=function(b){return new pv.Dom(b)};
pv.Dom=function(b){this.$map=b};pv.Dom.prototype.$leaf=function(b){return typeof b!="object"};pv.Dom.prototype.leaf=function(b){if(arguments.length){this.$leaf=b;return this}return this.$leaf};pv.Dom.prototype.root=function(b){function c(g){var h=new pv.Dom.Node;for(var i in g){var j=g[i];h.appendChild(d(j)?new pv.Dom.Node(j):c(j)).nodeName=i}return h}var d=this.$leaf,f=c(this.$map);f.nodeName=b;return f};pv.Dom.prototype.nodes=function(){return this.root().nodes()};
pv.Dom.Node=function(b){this.nodeValue=b;this.childNodes=[]};a=pv.Dom.Node.prototype;a.parentNode=null;a.firstChild=null;a.lastChild=null;a.previousSibling=null;a.nextSibling=null;
a.removeChild=function(b){var c=this.childNodes.indexOf(b);if(c==-1)throw new Error("child not found");this.childNodes.splice(c,1);if(b.previousSibling)b.previousSibling.nextSibling=b.nextSibling;else this.firstChild=b.nextSibling;if(b.nextSibling)b.nextSibling.previousSibling=b.previousSibling;else this.lastChild=b.previousSibling;delete b.nextSibling;delete b.previousSibling;delete b.parentNode;return b};
a.appendChild=function(b){b.parentNode&&b.parentNode.removeChild(b);b.parentNode=this;if(b.previousSibling=this.lastChild)this.lastChild.nextSibling=b;else this.firstChild=b;this.lastChild=b;this.childNodes.push(b);return b};
a.insertBefore=function(b,c){if(!c)return this.appendChild(b);var d=this.childNodes.indexOf(c);if(d==-1)throw new Error("child not found");b.parentNode&&b.parentNode.removeChild(b);b.parentNode=this;b.nextSibling=c;if(b.previousSibling=c.previousSibling)c.previousSibling.nextSibling=b;else{if(c==this.lastChild)this.lastChild=b;this.firstChild=b}this.childNodes.splice(d,0,b);return b};
a.replaceChild=function(b,c){var d=this.childNodes.indexOf(c);if(d==-1)throw new Error("child not found");b.parentNode&&b.parentNode.removeChild(b);b.parentNode=this;b.nextSibling=c.nextSibling;if(b.previousSibling=c.previousSibling)c.previousSibling.nextSibling=b;else this.firstChild=b;if(c.nextSibling)c.nextSibling.previousSibling=b;else this.lastChild=b;this.childNodes[d]=b;return c};a.visitBefore=function(b){function c(d,f){b(d,f);for(d=d.firstChild;d;d=d.nextSibling)c(d,f+1)}c(this,0)};
a.visitAfter=function(b){function c(d,f){for(var g=d.firstChild;g;g=g.nextSibling)c(g,f+1);b(d,f)}c(this,0)};a.sort=function(b){if(this.firstChild){this.childNodes.sort(b);var c=this.firstChild=this.childNodes[0],d;delete c.previousSibling;for(var f=1;f<this.childNodes.length;f++){c.sort(b);d=this.childNodes[f];d.previousSibling=c;c=c.nextSibling=d}this.lastChild=c;delete c.nextSibling;c.sort(b)}return this};
a.reverse=function(){var b=[];this.visitAfter(function(c){for(;c.lastChild;)b.push(c.removeChild(c.lastChild));for(var d;d=b.pop();)c.insertBefore(d,c.firstChild)});return this};a.nodes=function(){function b(d){c.push(d);d.childNodes.forEach(b)}var c=[];b(this,c);return c};
a.toggle=function(b){if(b)return this.toggled?this.visitBefore(function(d){d.toggled&&d.toggle()}):this.visitAfter(function(d){d.toggled||d.toggle()});b=this;if(b.toggled){for(var c;c=b.toggled.pop();)b.appendChild(c);delete b.toggled}else if(b.lastChild)for(b.toggled=[];b.lastChild;)b.toggled.push(b.removeChild(b.lastChild))};pv.nodes=function(b){for(var c=new pv.Dom.Node,d=0;d<b.length;d++)c.appendChild(new pv.Dom.Node(b[d]));return c.nodes()};pv.tree=function(b){return new pv.Tree(b)};
pv.Tree=function(b){this.array=b};pv.Tree.prototype.keys=function(b){this.k=b;return this};pv.Tree.prototype.value=function(b){this.v=b;return this};pv.Tree.prototype.map=function(){for(var b={},c={},d=0;d<this.array.length;d++){c.index=d;for(var f=this.array[d],g=this.k.call(c,f),h=b,i=0;i<g.length-1;i++)h=h[g[i]]||(h[g[i]]={});h[g[i]]=this.v?this.v.call(c,f):f}return b};pv.nest=function(b){return new pv.Nest(b)};pv.Nest=function(b){this.array=b;this.keys=[]};a=pv.Nest.prototype;
a.key=function(b){this.keys.push(b);return this};a.sortKeys=function(b){this.keys[this.keys.length-1].order=b||pv.naturalOrder;return this};a.sortValues=function(b){this.order=b||pv.naturalOrder;return this};a.map=function(){for(var b={},c=[],d,f=0;f<this.array.length;f++){var g=this.array[f],h=b;for(d=0;d<this.keys.length-1;d++){var i=this.keys[d](g);h[i]||(h[i]={});h=h[i]}i=this.keys[d](g);if(!h[i]){d=[];c.push(d);h[i]=d}h[i].push(g)}if(this.order)for(d=0;d<c.length;d++)c[d].sort(this.order);return b};
a.entries=function(){function b(d){var f=[];for(var g in d){var h=d[g];f.push({key:g,values:h instanceof Array?h:b(h)})}return f}function c(d,f){var g=this.keys[f].order;g&&d.sort(function(i,j){return g(i.key,j.key)});if(++f<this.keys.length)for(var h=0;h<d.length;h++)c.call(this,d[h].values,f);return d}return c.call(this,b(this.map()),0)};a.rollup=function(b){function c(d){for(var f in d){var g=d[f];if(g instanceof Array)d[f]=b(g);else c(g)}return d}return c(this.map())};pv.flatten=function(b){return new pv.Flatten(b)};
pv.Flatten=function(b){this.map=b;this.keys=[]};pv.Flatten.prototype.key=function(b,c){this.keys.push({name:b,value:c});delete this.$leaf;return this};pv.Flatten.prototype.leaf=function(b){this.keys.length=0;this.$leaf=b;return this};
pv.Flatten.prototype.array=function(){function b(i,j){if(j<f.length-1)for(var k in i){d.push(k);b(i[k],j+1);d.pop()}else c.push(d.concat(i))}var c=[],d=[],f=this.keys,g=this.$leaf;if(g){function h(i,j){if(g(i))c.push({keys:d.slice(),value:i});else for(var k in i){d.push(k);h(i[k],j+1);d.pop()}}h(this.map,0);return c}b(this.map,0);return c.map(function(i){for(var j={},k=0;k<f.length;k++){var l=f[k],q=i[k];j[l.name]=l.value?l.value.call(null,q):q}return j})};
pv.vector=function(b,c){return new pv.Vector(b,c)};pv.Vector=function(b,c){this.x=b;this.y=c};a=pv.Vector.prototype;a.perp=function(){return new pv.Vector(-this.y,this.x)};a.norm=function(){var b=this.length();return this.times(b?1/b:1)};a.length=function(){return Math.sqrt(this.x*this.x+this.y*this.y)};a.times=function(b){return new pv.Vector(this.x*b,this.y*b)};a.plus=function(b,c){return arguments.length==1?new pv.Vector(this.x+b.x,this.y+b.y):new pv.Vector(this.x+b,this.y+c)};
a.minus=function(b,c){return arguments.length==1?new pv.Vector(this.x-b.x,this.y-b.y):new pv.Vector(this.x-b,this.y-c)};a.dot=function(b,c){return arguments.length==1?this.x*b.x+this.y*b.y:this.x*b+this.y*c};pv.Transform=function(){};pv.Transform.prototype={k:1,x:0,y:0};pv.Transform.identity=new pv.Transform;pv.Transform.prototype.translate=function(b,c){var d=new pv.Transform;d.k=this.k;d.x=this.k*b+this.x;d.y=this.k*c+this.y;return d};
pv.Transform.prototype.scale=function(b){var c=new pv.Transform;c.k=this.k*b;c.x=this.x;c.y=this.y;return c};pv.Transform.prototype.invert=function(){var b=new pv.Transform,c=1/this.k;b.k=c;b.x=-this.x*c;b.y=-this.y*c;return b};pv.Transform.prototype.times=function(b){var c=new pv.Transform;c.k=this.k*b.k;c.x=this.k*b.x+this.x;c.y=this.k*b.y+this.y;return c};pv.Scale=function(){};
pv.Scale.interpolator=function(b,c){if(typeof b=="number")return function(d){return d*(c-b)+b};b=pv.color(b).rgb();c=pv.color(c).rgb();return function(d){var f=b.a*(1-d)+c.a*d;if(f<1.0E-5)f=0;return b.a==0?pv.rgb(c.r,c.g,c.b,f):c.a==0?pv.rgb(b.r,b.g,b.b,f):pv.rgb(Math.round(b.r*(1-d)+c.r*d),Math.round(b.g*(1-d)+c.g*d),Math.round(b.b*(1-d)+c.b*d),f)}};
pv.Scale.quantitative=function(){function b(n){return new Date(n)}function c(n){var p=pv.search(d,n);if(p<0)p=-p-2;p=Math.max(0,Math.min(h.length-1,p));return h[p]((k(n)-f[p])/(f[p+1]-f[p]))}var d=[0,1],f=[0,1],g=[0,1],h=[pv.identity],i=Number,j=false,k=pv.identity,l=pv.identity,q=String;c.transform=function(n,p){k=function(m){return j?-n(-m):n(m)};l=function(m){return j?-p(-m):p(m)};f=d.map(k);return this};c.domain=function(n,p,m){if(arguments.length){var r;if(n instanceof Array){if(arguments.length<
2)p=pv.identity;if(arguments.length<3)m=p;r=n.length&&p(n[0]);d=n.length?[pv.min(n,p),pv.max(n,m)]:[]}else{r=n;d=Array.prototype.slice.call(arguments).map(Number)}if(d.length){if(d.length==1)d=[d[0],d[0]]}else d=[-Infinity,Infinity];j=(d[0]||d[d.length-1])<0;f=d.map(k);i=r instanceof Date?b:Number;return this}return d.map(i)};c.range=function(){if(arguments.length){g=Array.prototype.slice.call(arguments);if(g.length){if(g.length==1)g=[g[0],g[0]]}else g=[-Infinity,Infinity];h=[];for(var n=0;n<g.length-
1;n++)h.push(pv.Scale.interpolator(g[n],g[n+1]));return this}return g};c.invert=function(n){var p=pv.search(g,n);if(p<0)p=-p-2;p=Math.max(0,Math.min(h.length-1,p));return i(l(f[p]+(n-g[p])/(g[p+1]-g[p])*(f[p+1]-f[p])))};c.ticks=function(n){var p=d[0],m=d[d.length-1],r=m<p,s=r?m:p;m=r?p:m;var t=m-s;if(!t||!isFinite(t)){if(i==b)q=pv.Format.date("%x");return[i(s)]}if(i==b){function x(w,y){switch(y){case 31536E6:w.setMonth(0);case 2592E6:w.setDate(1);case 6048E5:y==6048E5&&w.setDate(w.getDate()-w.getDay());
case 864E5:w.setHours(0);case 36E5:w.setMinutes(0);case 6E4:w.setSeconds(0);case 1E3:w.setMilliseconds(0)}}var u,o,v=1;if(t>=94608E6){p=31536E6;u="%Y";o=function(w){w.setFullYear(w.getFullYear()+v)}}else if(t>=7776E6){p=2592E6;u="%m/%Y";o=function(w){w.setMonth(w.getMonth()+v)}}else if(t>=18144E5){p=6048E5;u="%m/%d";o=function(w){w.setDate(w.getDate()+7*v)}}else if(t>=2592E5){p=864E5;u="%m/%d";o=function(w){w.setDate(w.getDate()+v)}}else if(t>=108E5){p=36E5;u="%I:%M %p";o=function(w){w.setHours(w.getHours()+
v)}}else if(t>=18E4){p=6E4;u="%I:%M %p";o=function(w){w.setMinutes(w.getMinutes()+v)}}else if(t>=3E3){p=1E3;u="%I:%M:%S";o=function(w){w.setSeconds(w.getSeconds()+v)}}else{p=1;u="%S.%Qs";o=function(w){w.setTime(w.getTime()+v)}}q=pv.Format.date(u);s=new Date(s);u=[];x(s,p);t=t/p;if(t>10)switch(p){case 36E5:v=t>20?6:3;s.setHours(Math.floor(s.getHours()/v)*v);break;case 2592E6:v=3;s.setMonth(Math.floor(s.getMonth()/v)*v);break;case 6E4:v=t>30?15:t>15?10:5;s.setMinutes(Math.floor(s.getMinutes()/v)*v);
break;case 1E3:v=t>90?15:t>60?10:5;s.setSeconds(Math.floor(s.getSeconds()/v)*v);break;case 1:v=t>1E3?250:t>200?100:t>100?50:t>50?25:5;s.setMilliseconds(Math.floor(s.getMilliseconds()/v)*v);break;default:v=pv.logCeil(t/15,10);if(t/v<2)v/=5;else if(t/v<5)v/=2;s.setFullYear(Math.floor(s.getFullYear()/v)*v);break}for(;;){o(s);if(s>m)break;u.push(new Date(s))}return r?u.reverse():u}arguments.length||(n=10);v=pv.logFloor(t/n,10);p=n/(t/v);if(p<=0.15)v*=10;else if(p<=0.35)v*=5;else if(p<=0.75)v*=2;p=Math.ceil(s/
v)*v;m=Math.floor(m/v)*v;q=pv.Format.number().fractionDigits(Math.max(0,-Math.floor(pv.log(v,10)+0.01)));m=pv.range(p,m+v,v);return r?m.reverse():m};c.tickFormat=function(n){return q(n)};c.nice=function(){if(d.length!=2)return this;var n=d[0],p=d[d.length-1],m=p<n,r=m?p:n;n=m?n:p;p=n-r;if(!p||!isFinite(p))return this;p=Math.pow(10,Math.round(Math.log(p)/Math.log(10))-1);d=[Math.floor(r/p)*p,Math.ceil(n/p)*p];m&&d.reverse();f=d.map(k);return this};c.by=function(n){function p(){return c(n.apply(this,
arguments))}for(var m in c)p[m]=c[m];return p};c.domain.apply(c,arguments);return c};pv.Scale.linear=function(){var b=pv.Scale.quantitative();b.domain.apply(b,arguments);return b};
pv.Scale.log=function(){var b=pv.Scale.quantitative(1,10),c,d,f=function(h){return Math.log(h)/d},g=function(h){return Math.pow(c,h)};b.ticks=function(){var h=b.domain(),i=h[0]<0,j=Math.floor(i?-f(-h[0]):f(h[0])),k=Math.ceil(i?-f(-h[1]):f(h[1])),l=[];if(i)for(l.push(-g(-j));j++<k;)for(i=c-1;i>0;i--)l.push(-g(-j)*i);else{for(;j<k;j++)for(i=1;i<c;i++)l.push(g(j)*i);l.push(g(j))}for(j=0;l[j]<h[0];j++);for(k=l.length;l[k-1]>h[1];k--);return l.slice(j,k)};b.tickFormat=function(h){return h.toPrecision(1)};
b.nice=function(){var h=b.domain();return b.domain(pv.logFloor(h[0],c),pv.logCeil(h[1],c))};b.base=function(h){if(arguments.length){c=Number(h);d=Math.log(c);b.transform(f,g);return this}return c};b.domain.apply(b,arguments);return b.base(10)};pv.Scale.root=function(){var b=pv.Scale.quantitative();b.power=function(c){if(arguments.length){var d=Number(c),f=1/d;b.transform(function(g){return Math.pow(g,f)},function(g){return Math.pow(g,d)});return this}return d};b.domain.apply(b,arguments);return b.power(2)};
pv.Scale.ordinal=function(){function b(g){g in d||(d[g]=c.push(g)-1);return f[d[g]%f.length]}var c=[],d={},f=[];b.domain=function(g,h){if(arguments.length){g=g instanceof Array?arguments.length>1?pv.map(g,h):g:Array.prototype.slice.call(arguments);c=[];for(var i={},j=0;j<g.length;j++){var k=g[j];if(!(k in i)){i[k]=true;c.push(k)}}d=pv.numerate(c);return this}return c};b.range=function(g,h){if(arguments.length){f=g instanceof Array?arguments.length>1?pv.map(g,h):g:Array.prototype.slice.call(arguments);
if(typeof f[0]=="string")f=f.map(pv.color);return this}return f};b.split=function(g,h){var i=(h-g)/this.domain().length;f=pv.range(g+i/2,h,i);return this};b.splitFlush=function(g,h){var i=this.domain().length,j=(h-g)/(i-1);f=i==1?[(g+h)/2]:pv.range(g,h+j/2,j);return this};b.splitBanded=function(g,h,i){if(arguments.length<3)i=1;if(i<0){var j=this.domain().length;j=(h-g- -i*j)/(j+1);f=pv.range(g+j,h,j-i);f.band=-i}else{j=(h-g)/(this.domain().length+(1-i));f=pv.range(g+j*(1-i),h,j);f.band=j*i}return this};
b.by=function(g){function h(){return b(g.apply(this,arguments))}for(var i in b)h[i]=b[i];return h};b.domain.apply(b,arguments);return b};
pv.Scale.quantile=function(){function b(i){return h(Math.max(0,Math.min(d,pv.search.index(f,i)-1))/d)}var c=-1,d=-1,f=[],g=[],h=pv.Scale.linear();b.quantiles=function(i){if(arguments.length){c=Number(i);if(c<0){f=[g[0]].concat(g);d=g.length-1}else{f=[];f[0]=g[0];for(var j=1;j<=c;j++)f[j]=g[~~(j*(g.length-1)/c)];d=c-1}return this}return f};b.domain=function(i,j){if(arguments.length){g=i instanceof Array?pv.map(i,j):Array.prototype.slice.call(arguments);g.sort(pv.naturalOrder);b.quantiles(c);return this}return g};
b.range=function(){if(arguments.length){h.range.apply(h,arguments);return this}return h.range()};b.by=function(i){function j(){return b(i.apply(this,arguments))}for(var k in b)j[k]=b[k];return j};b.domain.apply(b,arguments);return b};
pv.histogram=function(b,c){var d=true;return{bins:function(f){var g=pv.map(b,c),h=[];arguments.length||(f=pv.Scale.linear(g).ticks());for(var i=0;i<f.length-1;i++){var j=h[i]=[];j.x=f[i];j.dx=f[i+1]-f[i];j.y=0}for(i=0;i<g.length;i++){j=pv.search.index(f,g[i])-1;j=h[Math.max(0,Math.min(h.length-1,j))];j.y++;j.push(b[i])}if(!d)for(i=0;i<h.length;i++)h[i].y/=g.length;return h},frequency:function(f){if(arguments.length){d=Boolean(f);return this}return d}}};
pv.color=function(b){if(b.rgb)return b.rgb();var c=/([a-z]+)\((.*)\)/i.exec(b);if(c){var d=c[2].split(","),f=1;switch(c[1]){case "hsla":case "rgba":f=parseFloat(d[3]);if(!f)return pv.Color.transparent;break}switch(c[1]){case "hsla":case "hsl":b=parseFloat(d[0]);var g=parseFloat(d[1])/100;d=parseFloat(d[2])/100;return(new pv.Color.Hsl(b,g,d,f)).rgb();case "rgba":case "rgb":function h(k){var l=parseFloat(k);return k[k.length-1]=="%"?Math.round(l*2.55):l}g=h(d[0]);var i=h(d[1]),j=h(d[2]);return pv.rgb(g,
i,j,f)}}if(f=pv.Color.names[b])return f;if(b.charAt(0)=="#"){if(b.length==4){g=b.charAt(1);g+=g;i=b.charAt(2);i+=i;j=b.charAt(3);j+=j}else if(b.length==7){g=b.substring(1,3);i=b.substring(3,5);j=b.substring(5,7)}return pv.rgb(parseInt(g,16),parseInt(i,16),parseInt(j,16),1)}return new pv.Color(b,1)};pv.Color=function(b,c){this.color=b;this.opacity=c};pv.Color.prototype.brighter=function(b){return this.rgb().brighter(b)};pv.Color.prototype.darker=function(b){return this.rgb().darker(b)};
pv.rgb=function(b,c,d,f){return new pv.Color.Rgb(b,c,d,arguments.length==4?f:1)};pv.Color.Rgb=function(b,c,d,f){pv.Color.call(this,f?"rgb("+b+","+c+","+d+")":"none",f);this.r=b;this.g=c;this.b=d;this.a=f};pv.Color.Rgb.prototype=pv.extend(pv.Color);a=pv.Color.Rgb.prototype;a.red=function(b){return pv.rgb(b,this.g,this.b,this.a)};a.green=function(b){return pv.rgb(this.r,b,this.b,this.a)};a.blue=function(b){return pv.rgb(this.r,this.g,b,this.a)};
a.alpha=function(b){return pv.rgb(this.r,this.g,this.b,b)};a.rgb=function(){return this};a.brighter=function(b){b=Math.pow(0.7,arguments.length?b:1);var c=this.r,d=this.g,f=this.b;if(!c&&!d&&!f)return pv.rgb(30,30,30,this.a);if(c&&c<30)c=30;if(d&&d<30)d=30;if(f&&f<30)f=30;return pv.rgb(Math.min(255,Math.floor(c/b)),Math.min(255,Math.floor(d/b)),Math.min(255,Math.floor(f/b)),this.a)};
a.darker=function(b){b=Math.pow(0.7,arguments.length?b:1);return pv.rgb(Math.max(0,Math.floor(b*this.r)),Math.max(0,Math.floor(b*this.g)),Math.max(0,Math.floor(b*this.b)),this.a)};pv.hsl=function(b,c,d,f){return new pv.Color.Hsl(b,c,d,arguments.length==4?f:1)};pv.Color.Hsl=function(b,c,d,f){pv.Color.call(this,"hsl("+b+","+c*100+"%,"+d*100+"%)",f);this.h=b;this.s=c;this.l=d;this.a=f};pv.Color.Hsl.prototype=pv.extend(pv.Color);a=pv.Color.Hsl.prototype;
a.hue=function(b){return pv.hsl(b,this.s,this.l,this.a)};a.saturation=function(b){return pv.hsl(this.h,b,this.l,this.a)};a.lightness=function(b){return pv.hsl(this.h,this.s,b,this.a)};a.alpha=function(b){return pv.hsl(this.h,this.s,this.l,b)};
a.rgb=function(){function b(j){if(j>360)j-=360;else if(j<0)j+=360;if(j<60)return i+(h-i)*j/60;if(j<180)return h;if(j<240)return i+(h-i)*(240-j)/60;return i}function c(j){return Math.round(b(j)*255)}var d=this.h,f=this.s,g=this.l;d%=360;if(d<0)d+=360;f=Math.max(0,Math.min(f,1));g=Math.max(0,Math.min(g,1));var h=g<=0.5?g*(1+f):g+f-g*f,i=2*g-h;return pv.rgb(c(d+120),c(d),c(d-120),this.a)};
pv.Color.names={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",
darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",
ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",
lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",
moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",
seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32",transparent:pv.Color.transparent=pv.rgb(0,0,0,0)};(function(){var b=pv.Color.names;for(var c in b)b[c]=pv.color(b[c])})();
pv.colors=function(){var b=pv.Scale.ordinal();b.range.apply(b,arguments);return b};pv.Colors={};pv.Colors.category10=function(){var b=pv.colors("#1f77b4","#ff7f0e","#2ca02c","#d62728","#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf");b.domain.apply(b,arguments);return b};
pv.Colors.category20=function(){var b=pv.colors("#1f77b4","#aec7e8","#ff7f0e","#ffbb78","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5","#8c564b","#c49c94","#e377c2","#f7b6d2","#7f7f7f","#c7c7c7","#bcbd22","#dbdb8d","#17becf","#9edae5");b.domain.apply(b,arguments);return b};
pv.Colors.category19=function(){var b=pv.colors("#9c9ede","#7375b5","#4a5584","#cedb9c","#b5cf6b","#8ca252","#637939","#e7cb94","#e7ba52","#bd9e39","#8c6d31","#e7969c","#d6616b","#ad494a","#843c39","#de9ed6","#ce6dbd","#a55194","#7b4173");b.domain.apply(b,arguments);return b};pv.ramp=function(){var b=pv.Scale.linear();b.range.apply(b,arguments);return b};
pv.Scene=pv.SvgScene={svg:"http://www.w3.org/2000/svg",xmlns:"http://www.w3.org/2000/xmlns",xlink:"http://www.w3.org/1999/xlink",xhtml:"http://www.w3.org/1999/xhtml",scale:1,events:["DOMMouseScroll","mousewheel","mousedown","mouseup","mouseover","mouseout","mousemove","click","dblclick"],implicit:{svg:{"shape-rendering":"auto","pointer-events":"painted",x:0,y:0,dy:0,"text-anchor":"start",transform:"translate(0,0)",fill:"none","fill-opacity":1,stroke:"none","stroke-opacity":1,"stroke-width":1.5,"stroke-linejoin":"miter"},
css:{font:"10px sans-serif"}}};pv.SvgScene.updateAll=function(b){if(b.length&&b[0].reverse&&b.type!="line"&&b.type!="area"){for(var c=pv.extend(b),d=0,f=b.length-1;f>=0;d++,f--)c[d]=b[f];b=c}this.removeSiblings(this[b.type](b))};pv.SvgScene.create=function(b){return document.createElementNS(this.svg,b)};
pv.SvgScene.expect=function(b,c,d,f){if(b){if(b.tagName=="a")b=b.firstChild;if(b.tagName!=c){c=this.create(c);b.parentNode.replaceChild(c,b);b=c}}else b=this.create(c);for(var g in d){c=d[g];if(c==this.implicit.svg[g])c=null;c==null?b.removeAttribute(g):b.setAttribute(g,c)}for(g in f){c=f[g];if(c==this.implicit.css[g])c=null;if(c==null)b.style.removeProperty(g);else b.style[g]=c}return b};
pv.SvgScene.append=function(b,c,d){b.$scene={scenes:c,index:d};b=this.title(b,c[d]);b.parentNode||c.$g.appendChild(b);return b.nextSibling};pv.SvgScene.title=function(b,c){var d=b.parentNode;if(d&&d.tagName!="a")d=null;if(c.title){if(!d){d=this.create("a");b.parentNode&&b.parentNode.replaceChild(d,b);d.appendChild(b)}d.setAttributeNS(this.xlink,"title",c.title);return d}d&&d.parentNode.replaceChild(b,d);return b};
pv.SvgScene.dispatch=pv.listener(function(b){var c=b.target.$scene;if(c){var d=b.type;switch(d){case "DOMMouseScroll":d="mousewheel";b.wheel=-480*b.detail;break;case "mousewheel":b.wheel=(window.opera?12:1)*b.wheelDelta;break}pv.Mark.dispatch(d,c.scenes,c.index)&&b.preventDefault()}});pv.SvgScene.removeSiblings=function(b){for(;b;){var c=b.nextSibling;b.parentNode.removeChild(b);b=c}};pv.SvgScene.undefined=function(){};
pv.SvgScene.pathBasis=function(){function b(f,g,h,i,j){return{x:f[0]*g.left+f[1]*h.left+f[2]*i.left+f[3]*j.left,y:f[0]*g.top+f[1]*h.top+f[2]*i.top+f[3]*j.top}}var c=[[1/6,2/3,1/6,0],[0,2/3,1/3,0],[0,1/3,2/3,0],[0,1/6,2/3,1/6]],d=function(f,g,h,i){var j=b(c[1],f,g,h,i),k=b(c[2],f,g,h,i);f=b(c[3],f,g,h,i);return"C"+j.x+","+j.y+","+k.x+","+k.y+","+f.x+","+f.y};d.segment=function(f,g,h,i){var j=b(c[0],f,g,h,i),k=b(c[1],f,g,h,i),l=b(c[2],f,g,h,i);f=b(c[3],f,g,h,i);return"M"+j.x+","+j.y+"C"+k.x+","+k.y+
","+l.x+","+l.y+","+f.x+","+f.y};return d}();pv.SvgScene.curveBasis=function(b){if(b.length<=2)return"";var c="",d=b[0],f=d,g=d,h=b[1];c+=this.pathBasis(d,f,g,h);for(var i=2;i<b.length;i++){d=f;f=g;g=h;h=b[i];c+=this.pathBasis(d,f,g,h)}c+=this.pathBasis(f,g,h,h);c+=this.pathBasis(g,h,h,h);return c};
pv.SvgScene.curveBasisSegments=function(b){if(b.length<=2)return"";var c=[],d=b[0],f=d,g=d,h=b[1],i=this.pathBasis.segment(d,f,g,h);d=f;f=g;g=h;h=b[2];c.push(i+this.pathBasis(d,f,g,h));for(i=3;i<b.length;i++){d=f;f=g;g=h;h=b[i];c.push(this.pathBasis.segment(d,f,g,h))}c.push(this.pathBasis.segment(f,g,h,h)+this.pathBasis(g,h,h,h));return c};
pv.SvgScene.curveHermite=function(b,c){if(c.length<1||b.length!=c.length&&b.length!=c.length+2)return"";var d=b.length!=c.length,f="",g=b[0],h=b[1],i=c[0],j=i,k=1;if(d){f+="Q"+(h.left-i.x*2/3)+","+(h.top-i.y*2/3)+","+h.left+","+h.top;g=b[1];k=2}if(c.length>1){j=c[1];h=b[k];k++;f+="C"+(g.left+i.x)+","+(g.top+i.y)+","+(h.left-j.x)+","+(h.top-j.y)+","+h.left+","+h.top;for(g=2;g<c.length;g++,k++){h=b[k];j=c[g];f+="S"+(h.left-j.x)+","+(h.top-j.y)+","+h.left+","+h.top}}if(d){b=b[k];f+="Q"+(h.left+j.x*2/
3)+","+(h.top+j.y*2/3)+","+b.left+","+b.top}return f};
pv.SvgScene.curveHermiteSegments=function(b,c){if(c.length<1||b.length!=c.length&&b.length!=c.length+2)return[];var d=b.length!=c.length,f=[],g=b[0],h=g,i=c[0],j=i,k=1;if(d){h=b[1];f.push("M"+g.left+","+g.top+"Q"+(h.left-j.x*2/3)+","+(h.top-j.y*2/3)+","+h.left+","+h.top);k=2}for(var l=1;l<c.length;l++,k++){g=h;i=j;h=b[k];j=c[l];f.push("M"+g.left+","+g.top+"C"+(g.left+i.x)+","+(g.top+i.y)+","+(h.left-j.x)+","+(h.top-j.y)+","+h.left+","+h.top)}if(d){b=b[k];f.push("M"+h.left+","+h.top+"Q"+(h.left+j.x*
2/3)+","+(h.top+j.y*2/3)+","+b.left+","+b.top)}return f};pv.SvgScene.cardinalTangents=function(b,c){var d=[];c=(1-c)/2;for(var f=b[0],g=b[1],h=b[2],i=3;i<b.length;i++){d.push({x:c*(h.left-f.left),y:c*(h.top-f.top)});f=g;g=h;h=b[i]}d.push({x:c*(h.left-f.left),y:c*(h.top-f.top)});return d};pv.SvgScene.curveCardinal=function(b,c){if(b.length<=2)return"";return this.curveHermite(b,this.cardinalTangents(b,c))};
pv.SvgScene.curveCardinalSegments=function(b,c){if(b.length<=2)return"";return this.curveHermiteSegments(b,this.cardinalTangents(b,c))};
pv.SvgScene.monotoneTangents=function(b){var c=[],d=[],f=[],g=[],h=0;for(h=0;h<b.length-1;h++)d[h]=(b[h+1].top-b[h].top)/(b[h+1].left-b[h].left);f[0]=d[0];g[0]=b[1].left-b[0].left;for(h=1;h<b.length-1;h++){f[h]=(d[h-1]+d[h])/2;g[h]=(b[h+1].left-b[h-1].left)/2}f[h]=d[h-1];g[h]=b[h].left-b[h-1].left;for(h=0;h<b.length-1;h++)if(d[h]==0){f[h]=0;f[h+1]=0}for(h=0;h<b.length-1;h++)if(!(Math.abs(f[h])<1.0E-5||Math.abs(f[h+1])<1.0E-5)){var i=f[h]/d[h],j=f[h+1]/d[h],k=i*i+j*j;if(k>9){k=3/Math.sqrt(k);f[h]=
k*i*d[h];f[h+1]=k*j*d[h]}}for(h=0;h<b.length;h++){d=1+f[h]*f[h];c.push({x:g[h]/3/d,y:f[h]*g[h]/3/d})}return c};pv.SvgScene.curveMonotone=function(b){if(b.length<=2)return"";return this.curveHermite(b,this.monotoneTangents(b))};pv.SvgScene.curveMonotoneSegments=function(b){if(b.length<=2)return"";return this.curveHermiteSegments(b,this.monotoneTangents(b))};
pv.SvgScene.area=function(b){function c(n,p){for(var m=[],r=[],s=p;n<=s;n++,p--){var t=b[n],x=b[p];t=t.left+","+t.top;x=x.left+x.width+","+(x.top+x.height);if(n<s){var u=b[n+1],o=b[p-1];switch(g.interpolate){case "step-before":t+="V"+u.top;x+="H"+(o.left+o.width);break;case "step-after":t+="H"+u.left;x+="V"+(o.top+o.height);break}}m.push(t);r.push(x)}return m.concat(r).join("L")}function d(n,p){for(var m=[],r=[],s=p;n<=s;n++,p--){var t=b[p];m.push(b[n]);r.push({left:t.left+t.width,top:t.top+t.height})}if(g.interpolate==
"basis"){n=pv.SvgScene.curveBasis(m);p=pv.SvgScene.curveBasis(r)}else if(g.interpolate=="cardinal"){n=pv.SvgScene.curveCardinal(m,g.tension);p=pv.SvgScene.curveCardinal(r,g.tension)}else{n=pv.SvgScene.curveMonotone(m);p=pv.SvgScene.curveMonotone(r)}return m[0].left+","+m[0].top+n+"L"+r[0].left+","+r[0].top+p}var f=b.$g.firstChild;if(!b.length)return f;var g=b[0];if(g.segmented)return this.areaSegment(b);if(!g.visible)return f;var h=g.fillStyle,i=g.strokeStyle;if(!h.opacity&&!i.opacity)return f;for(var j=
[],k,l=0;l<b.length;l++){k=b[l];if(k.width||k.height){for(var q=l+1;q<b.length;q++){k=b[q];if(!k.width&&!k.height)break}l&&g.interpolate!="step-after"&&l--;q<b.length&&g.interpolate!="step-before"&&q++;j.push((q-l>2&&(g.interpolate=="basis"||g.interpolate=="cardinal"||g.interpolate=="monotone")?d:c)(l,q-1));l=q-1}}if(!j.length)return f;f=this.expect(f,"path",{"shape-rendering":g.antialias?null:"crispEdges","pointer-events":g.events,cursor:g.cursor,d:"M"+j.join("ZM")+"Z",fill:h.color,"fill-opacity":h.opacity||
null,stroke:i.color,"stroke-opacity":i.opacity||null,"stroke-width":i.opacity?g.lineWidth/this.scale:null});return this.append(f,b,0)};
pv.SvgScene.areaSegment=function(b){var c=b.$g.firstChild,d=b[0],f,g;if(d.interpolate=="basis"||d.interpolate=="cardinal"||d.interpolate=="monotone"){f=[];g=[];for(var h=0,i=b.length;h<i;h++){var j=b[i-h-1];f.push(b[h]);g.push({left:j.left+j.width,top:j.top+j.height})}if(d.interpolate=="basis"){f=this.curveBasisSegments(f);g=this.curveBasisSegments(g)}else if(d.interpolate=="cardinal"){f=this.curveCardinalSegments(f,d.tension);g=this.curveCardinalSegments(g,d.tension)}else{f=this.curveMonotoneSegments(f);
g=this.curveMonotoneSegments(g)}}h=0;for(i=b.length-1;h<i;h++){d=b[h];var k=b[h+1];if(d.visible&&k.visible){var l=d.fillStyle,q=d.strokeStyle;if(l.opacity||q.opacity){if(f){j=f[h];k="L"+g[i-h-1].substr(1);j=j+k+"Z"}else{var n=d;j=k;switch(d.interpolate){case "step-before":n=k;break;case "step-after":j=d;break}j="M"+d.left+","+n.top+"L"+k.left+","+j.top+"L"+(k.left+k.width)+","+(j.top+j.height)+"L"+(d.left+d.width)+","+(n.top+n.height)+"Z"}c=this.expect(c,"path",{"shape-rendering":d.antialias?null:
"crispEdges","pointer-events":d.events,cursor:d.cursor,d:j,fill:l.color,"fill-opacity":l.opacity||null,stroke:q.color,"stroke-opacity":q.opacity||null,"stroke-width":q.opacity?d.lineWidth/this.scale:null});c=this.append(c,b,h)}}}return c};
pv.SvgScene.bar=function(b){for(var c=b.$g.firstChild,d=0;d<b.length;d++){var f=b[d];if(f.visible){var g=f.fillStyle,h=f.strokeStyle;if(g.opacity||h.opacity){c=this.expect(c,"rect",{"shape-rendering":f.antialias?null:"crispEdges","pointer-events":f.events,cursor:f.cursor,x:f.left,y:f.top,width:Math.max(1.0E-10,f.width),height:Math.max(1.0E-10,f.height),fill:g.color,"fill-opacity":g.opacity||null,stroke:h.color,"stroke-opacity":h.opacity||null,"stroke-width":h.opacity?f.lineWidth/this.scale:null});
c=this.append(c,b,d)}}}return c};
pv.SvgScene.dot=function(b){for(var c=b.$g.firstChild,d=0;d<b.length;d++){var f=b[d];if(f.visible){var g=f.fillStyle,h=f.strokeStyle;if(g.opacity||h.opacity){var i=f.radius,j=null;switch(f.shape){case "cross":j="M"+-i+","+-i+"L"+i+","+i+"M"+i+","+-i+"L"+-i+","+i;break;case "triangle":j=i;var k=i*1.1547;j="M0,"+j+"L"+k+","+-j+" "+-k+","+-j+"Z";break;case "diamond":i*=Math.SQRT2;j="M0,"+-i+"L"+i+",0 0,"+i+" "+-i+",0Z";break;case "square":j="M"+-i+","+-i+"L"+i+","+-i+" "+i+","+i+" "+-i+","+i+"Z";break;
case "tick":j="M0,0L0,"+-f.size;break;case "bar":j="M0,"+f.size/2+"L0,"+-(f.size/2);break}g={"shape-rendering":f.antialias?null:"crispEdges","pointer-events":f.events,cursor:f.cursor,fill:g.color,"fill-opacity":g.opacity||null,stroke:h.color,"stroke-opacity":h.opacity||null,"stroke-width":h.opacity?f.lineWidth/this.scale:null};if(j){g.transform="translate("+f.left+","+f.top+")";if(f.angle)g.transform+=" rotate("+180*f.angle/Math.PI+")";g.d=j;c=this.expect(c,"path",g)}else{g.cx=f.left;g.cy=f.top;g.r=
i;c=this.expect(c,"circle",g)}c=this.append(c,b,d)}}}return c};
pv.SvgScene.image=function(b){for(var c=b.$g.firstChild,d=0;d<b.length;d++){var f=b[d];if(f.visible){c=this.fill(c,b,d);if(f.image){c=this.expect(c,"foreignObject",{cursor:f.cursor,x:f.left,y:f.top,width:f.width,height:f.height});var g=c.firstChild||c.appendChild(document.createElementNS(this.xhtml,"canvas"));g.$scene={scenes:b,index:d};g.style.width=f.width;g.style.height=f.height;g.width=f.imageWidth;g.height=f.imageHeight;g.getContext("2d").putImageData(f.image,0,0)}else{c=this.expect(c,"image",
{preserveAspectRatio:"none",cursor:f.cursor,x:f.left,y:f.top,width:f.width,height:f.height});c.setAttributeNS(this.xlink,"href",f.url)}c=this.append(c,b,d);c=this.stroke(c,b,d)}}return c};
pv.SvgScene.label=function(b){for(var c=b.$g.firstChild,d=0;d<b.length;d++){var f=b[d];if(f.visible){var g=f.textStyle;if(g.opacity&&f.text){var h=0,i=0,j=0,k="start";switch(f.textBaseline){case "middle":j=".35em";break;case "top":j=".71em";i=f.textMargin;break;case "bottom":i="-"+f.textMargin;break}switch(f.textAlign){case "right":k="end";h="-"+f.textMargin;break;case "center":k="middle";break;case "left":h=f.textMargin;break}c=this.expect(c,"text",{"pointer-events":f.events,cursor:f.cursor,x:h,
y:i,dy:j,transform:"translate("+f.left+","+f.top+")"+(f.textAngle?" rotate("+180*f.textAngle/Math.PI+")":"")+(this.scale!=1?" scale("+1/this.scale+")":""),fill:g.color,"fill-opacity":g.opacity||null,"text-anchor":k},{font:f.font,"text-shadow":f.textShadow,"text-decoration":f.textDecoration});if(c.firstChild)c.firstChild.nodeValue=f.text;else c.appendChild(document.createTextNode(f.text));c=this.append(c,b,d)}}}return c};
pv.SvgScene.line=function(b){var c=b.$g.firstChild;if(b.length<2)return c;var d=b[0];if(d.segmented)return this.lineSegment(b);if(!d.visible)return c;var f=d.fillStyle,g=d.strokeStyle;if(!f.opacity&&!g.opacity)return c;var h="M"+d.left+","+d.top;if(b.length>2&&(d.interpolate=="basis"||d.interpolate=="cardinal"||d.interpolate=="monotone"))switch(d.interpolate){case "basis":h+=this.curveBasis(b);break;case "cardinal":h+=this.curveCardinal(b,d.tension);break;case "monotone":h+=this.curveMonotone(b);
break}else for(var i=1;i<b.length;i++)h+=this.pathSegment(b[i-1],b[i]);c=this.expect(c,"path",{"shape-rendering":d.antialias?null:"crispEdges","pointer-events":d.events,cursor:d.cursor,d:h,fill:f.color,"fill-opacity":f.opacity||null,stroke:g.color,"stroke-opacity":g.opacity||null,"stroke-width":g.opacity?d.lineWidth/this.scale:null,"stroke-linejoin":d.lineJoin});return this.append(c,b,0)};
pv.SvgScene.lineSegment=function(b){var c=b.$g.firstChild,d=b[0],f;switch(d.interpolate){case "basis":f=this.curveBasisSegments(b);break;case "cardinal":f=this.curveCardinalSegments(b,d.tension);break;case "monotone":f=this.curveMonotoneSegments(b);break}d=0;for(var g=b.length-1;d<g;d++){var h=b[d],i=b[d+1];if(h.visible&&i.visible){var j=h.strokeStyle,k=pv.Color.transparent;if(j.opacity){if(h.interpolate=="linear"&&h.lineJoin=="miter"){k=j;j=pv.Color.transparent;i=this.pathJoin(b[d-1],h,i,b[d+2])}else i=
f?f[d]:"M"+h.left+","+h.top+this.pathSegment(h,i);c=this.expect(c,"path",{"shape-rendering":h.antialias?null:"crispEdges","pointer-events":h.events,cursor:h.cursor,d:i,fill:k.color,"fill-opacity":k.opacity||null,stroke:j.color,"stroke-opacity":j.opacity||null,"stroke-width":j.opacity?h.lineWidth/this.scale:null,"stroke-linejoin":h.lineJoin});c=this.append(c,b,d)}}}return c};
pv.SvgScene.pathSegment=function(b,c){var d=1;switch(b.interpolate){case "polar-reverse":d=0;case "polar":var f=c.left-b.left,g=c.top-b.top;b=1-b.eccentricity;f=Math.sqrt(f*f+g*g)/(2*b);if(b<=0||b>1)break;return"A"+f+","+f+" 0 0,"+d+" "+c.left+","+c.top;case "step-before":return"V"+c.top+"H"+c.left;case "step-after":return"H"+c.left+"V"+c.top}return"L"+c.left+","+c.top};pv.SvgScene.lineIntersect=function(b,c,d,f){return b.plus(c.times(d.minus(b).dot(f.perp())/c.dot(f.perp())))};
pv.SvgScene.pathJoin=function(b,c,d,f){var g=pv.vector(c.left,c.top);d=pv.vector(d.left,d.top);var h=d.minus(g),i=h.perp().norm(),j=i.times(c.lineWidth/(2*this.scale));c=g.plus(j);var k=d.plus(j),l=d.minus(j);j=g.minus(j);if(b&&b.visible){b=g.minus(b.left,b.top).perp().norm().plus(i);j=this.lineIntersect(g,b,j,h);c=this.lineIntersect(g,b,c,h)}if(f&&f.visible){f=pv.vector(f.left,f.top).minus(d).perp().norm().plus(i);l=this.lineIntersect(d,f,l,h);k=this.lineIntersect(d,f,k,h)}return"M"+c.x+","+c.y+
"L"+k.x+","+k.y+" "+l.x+","+l.y+" "+j.x+","+j.y};
pv.SvgScene.panel=function(b){for(var c=b.$g,d=c&&c.firstChild,f=0;f<b.length;f++){var g=b[f];if(g.visible){if(!b.parent){g.canvas.style.display="inline-block";if(c&&c.parentNode!=g.canvas)d=(c=g.canvas.firstChild)&&c.firstChild;if(!c){c=g.canvas.appendChild(this.create("svg"));c.setAttribute("font-size","10px");c.setAttribute("font-family","sans-serif");c.setAttribute("fill","none");c.setAttribute("stroke","none");c.setAttribute("stroke-width",1.5);for(var h=0;h<this.events.length;h++)c.addEventListener(this.events[h],
this.dispatch,false);d=c.firstChild}b.$g=c;c.setAttribute("width",g.width+g.left+g.right);c.setAttribute("height",g.height+g.top+g.bottom)}if(g.overflow=="hidden"){h=pv.id().toString(36);var i=this.expect(d,"g",{"clip-path":"url(#"+h+")"});i.parentNode||c.appendChild(i);b.$g=c=i;d=i.firstChild;d=this.expect(d,"clipPath",{id:h});h=d.firstChild||d.appendChild(this.create("rect"));h.setAttribute("x",g.left);h.setAttribute("y",g.top);h.setAttribute("width",g.width);h.setAttribute("height",g.height);d.parentNode||
c.appendChild(d);d=d.nextSibling}d=this.fill(d,b,f);var j=this.scale,k=g.transform,l=g.left+k.x,q=g.top+k.y;this.scale*=k.k;for(h=0;h<g.children.length;h++){g.children[h].$g=d=this.expect(d,"g",{transform:"translate("+l+","+q+")"+(k.k!=1?" scale("+k.k+")":"")});this.updateAll(g.children[h]);d.parentNode||c.appendChild(d);d=d.nextSibling}this.scale=j;d=this.stroke(d,b,f);if(g.overflow=="hidden"){b.$g=c=i.parentNode;d=i.nextSibling}}}return d};
pv.SvgScene.fill=function(b,c,d){var f=c[d],g=f.fillStyle;if(g.opacity||f.events=="all"){b=this.expect(b,"rect",{"shape-rendering":f.antialias?null:"crispEdges","pointer-events":f.events,cursor:f.cursor,x:f.left,y:f.top,width:f.width,height:f.height,fill:g.color,"fill-opacity":g.opacity,stroke:null});b=this.append(b,c,d)}return b};
pv.SvgScene.stroke=function(b,c,d){var f=c[d],g=f.strokeStyle;if(g.opacity||f.events=="all"){b=this.expect(b,"rect",{"shape-rendering":f.antialias?null:"crispEdges","pointer-events":f.events=="all"?"stroke":f.events,cursor:f.cursor,x:f.left,y:f.top,width:Math.max(1.0E-10,f.width),height:Math.max(1.0E-10,f.height),fill:null,stroke:g.color,"stroke-opacity":g.opacity,"stroke-width":f.lineWidth/this.scale});b=this.append(b,c,d)}return b};
pv.SvgScene.rule=function(b){for(var c=b.$g.firstChild,d=0;d<b.length;d++){var f=b[d];if(f.visible){var g=f.strokeStyle;if(g.opacity){c=this.expect(c,"line",{"shape-rendering":f.antialias?null:"crispEdges","pointer-events":f.events,cursor:f.cursor,x1:f.left,y1:f.top,x2:f.left+f.width,y2:f.top+f.height,stroke:g.color,"stroke-opacity":g.opacity,"stroke-width":f.lineWidth/this.scale});c=this.append(c,b,d)}}}return c};
pv.SvgScene.wedge=function(b){for(var c=b.$g.firstChild,d=0;d<b.length;d++){var f=b[d];if(f.visible){var g=f.fillStyle,h=f.strokeStyle;if(g.opacity||h.opacity){var i=f.innerRadius,j=f.outerRadius,k=Math.abs(f.angle);if(k>=2*Math.PI)i=i?"M0,"+j+"A"+j+","+j+" 0 1,1 0,"+-j+"A"+j+","+j+" 0 1,1 0,"+j+"M0,"+i+"A"+i+","+i+" 0 1,1 0,"+-i+"A"+i+","+i+" 0 1,1 0,"+i+"Z":"M0,"+j+"A"+j+","+j+" 0 1,1 0,"+-j+"A"+j+","+j+" 0 1,1 0,"+j+"Z";else{var l=Math.min(f.startAngle,f.endAngle),q=Math.max(f.startAngle,f.endAngle),
n=Math.cos(l),p=Math.cos(q);l=Math.sin(l);q=Math.sin(q);i=i?"M"+j*n+","+j*l+"A"+j+","+j+" 0 "+(k<Math.PI?"0":"1")+",1 "+j*p+","+j*q+"L"+i*p+","+i*q+"A"+i+","+i+" 0 "+(k<Math.PI?"0":"1")+",0 "+i*n+","+i*l+"Z":"M"+j*n+","+j*l+"A"+j+","+j+" 0 "+(k<Math.PI?"0":"1")+",1 "+j*p+","+j*q+"L0,0Z"}c=this.expect(c,"path",{"shape-rendering":f.antialias?null:"crispEdges","pointer-events":f.events,cursor:f.cursor,transform:"translate("+f.left+","+f.top+")",d:i,fill:g.color,"fill-rule":"evenodd","fill-opacity":g.opacity||
null,stroke:h.color,"stroke-opacity":h.opacity||null,"stroke-width":h.opacity?f.lineWidth/this.scale:null});c=this.append(c,b,d)}}}return c};pv.Mark=function(){this.$properties=[];this.$handlers={}};pv.Mark.prototype.properties={};pv.Mark.cast={};pv.Mark.prototype.property=function(b,c){if(!this.hasOwnProperty("properties"))this.properties=pv.extend(this.properties);this.properties[b]=true;pv.Mark.prototype.propertyMethod(b,false,pv.Mark.cast[b]=c);return this};
pv.Mark.prototype.propertyMethod=function(b,c,d){d||(d=pv.Mark.cast[b]);this[b]=function(f){if(c&&this.scene){var g=this.scene.defs;if(arguments.length){g[b]={id:f==null?0:pv.id(),value:f!=null&&d?d(f):f};return this}return g[b]?g[b].value:null}if(arguments.length){g=!c<<1|typeof f=="function";this.propertyValue(b,g&1&&d?function(){var h=f.apply(this,arguments);return h!=null?d(h):null}:f!=null&&d?d(f):f).type=g;return this}return this.instance()[b]}};
pv.Mark.prototype.propertyValue=function(b,c){var d=this.$properties;c={name:b,id:pv.id(),value:c};for(var f=0;f<d.length;f++)if(d[f].name==b){d.splice(f,1);break}d.push(c);return c};pv.Mark.prototype.property("data").property("visible",Boolean).property("left",Number).property("right",Number).property("top",Number).property("bottom",Number).property("cursor",String).property("title",String).property("reverse",Boolean).property("antialias",Boolean).property("events",String);a=pv.Mark.prototype;
a.childIndex=-1;a.index=-1;a.scale=1;a.defaults=(new pv.Mark).data(function(b){return[b]}).visible(true).antialias(true).events("painted");a.extend=function(b){this.proto=b;this.target=b.target;return this};a.add=function(b){return this.parent.add(b).extend(this)};a.def=function(b,c){this.propertyMethod(b,true);return this[b](arguments.length>1?c:null)};
a.anchor=function(b){b||(b="center");return(new pv.Anchor(this)).name(b).data(function(){return this.scene.target.map(function(c){return c.data})}).visible(function(){return this.scene.target[this.index].visible}).left(function(){var c=this.scene.target[this.index],d=c.width||0;switch(this.name()){case "bottom":case "top":case "center":return c.left+d/2;case "left":return null}return c.left+d}).top(function(){var c=this.scene.target[this.index],d=c.height||0;switch(this.name()){case "left":case "right":case "center":return c.top+
d/2;case "top":return null}return c.top+d}).right(function(){var c=this.scene.target[this.index];return this.name()=="left"?c.right+(c.width||0):null}).bottom(function(){var c=this.scene.target[this.index];return this.name()=="top"?c.bottom+(c.height||0):null}).textAlign(function(){switch(this.name()){case "bottom":case "top":case "center":return"center";case "right":return"right"}return"left"}).textBaseline(function(){switch(this.name()){case "right":case "left":case "center":return"middle";case "top":return"top"}return"bottom"})};
a.anchorTarget=function(){return this.target};a.margin=function(b){return this.left(b).right(b).top(b).bottom(b)};a.instance=function(b){var c=this.scene||this.parent.instance(-1).children[this.childIndex],d=!arguments.length||this.hasOwnProperty("index")?this.index:b;return c[d<0?c.length-1:d]};
a.instances=function(b){for(var c=this,d=[],f;!(f=c.scene);){b=b.parent;d.push({index:b.index,childIndex:c.childIndex});c=c.parent}for(;d.length;){b=d.pop();f=f[b.index].children[b.childIndex]}if(this.hasOwnProperty("index")){d=pv.extend(f[this.index]);d.right=d.top=d.left=d.bottom=0;return[d]}return f};a.first=function(){return this.scene[0]};a.last=function(){return this.scene[this.scene.length-1]};a.sibling=function(){return this.index==0?null:this.scene[this.index-1]};
a.cousin=function(){var b=this.parent;return(b=b&&b.sibling())&&b.children?b.children[this.childIndex][this.index]:null};
a.render=function(){function b(i,j,k){i.scale=k;if(j<g.length){f.unshift(null);if(i.hasOwnProperty("index"))c(i,j,k);else{for(var l=0,q=i.scene.length;l<q;l++){i.index=l;c(i,j,k)}delete i.index}f.shift()}else{i.build();pv.Scene.scale=k;pv.Scene.updateAll(i.scene)}delete i.scale}function c(i,j,k){var l=i.scene[i.index],q;if(l.visible){var n=g[j],p=i.children[n];for(q=0;q<n;q++)i.children[q].scene=l.children[q];f[0]=l.data;if(p.scene)b(p,j+1,k*l.transform.k);else{p.scene=l.children[n];b(p,j+1,k*l.transform.k);
delete p.scene}for(q=0;q<n;q++)delete i.children[q].scene}}var d=this.parent,f=pv.Mark.stack;if(d&&!this.root.scene)this.root.render();else{for(var g=[],h=this;h.parent;h=h.parent)g.unshift(h.childIndex);for(this.bind();d&&!d.hasOwnProperty("index");)d=d.parent;this.context(d?d.scene:undefined,d?d.index:-1,function(){b(this.root,0,1)})}};pv.Mark.stack=[];a=pv.Mark.prototype;
a.bind=function(){function b(j){do for(var k=j.$properties,l=k.length-1;l>=0;l--){var q=k[l];if(!(q.name in c)){c[q.name]=q;switch(q.name){case "data":f=q;break;case "visible":g=q;break;default:d[q.type].push(q);break}}}while(j=j.proto)}var c={},d=[[],[],[],[]],f,g;b(this);b(this.defaults);d[1].reverse();d[3].reverse();var h=this;do for(var i in h.properties)i in c||d[2].push(c[i]={name:i,type:2,value:null});while(h=h.proto);h=d[0].concat(d[1]);for(i=0;i<h.length;i++)this.propertyMethod(h[i].name,
true);this.binds={properties:c,data:f,defs:h,required:[g],optional:pv.blend(d)}};
a.build=function(){var b=this.scene,c=pv.Mark.stack;if(!b){b=this.scene=[];b.mark=this;b.type=this.type;b.childIndex=this.childIndex;if(this.parent){b.parent=this.parent.scene;b.parentIndex=this.parent.index}}if(this.target)b.target=this.target.instances(b);if(this.binds.defs.length){var d=b.defs;if(!d)b.defs=d={};for(var f=0;f<this.binds.defs.length;f++){var g=this.binds.defs[f],h=d[g.name];if(!h||g.id>h.id)d[g.name]={id:0,value:g.type&1?g.value.apply(this,c):g.value}}}d=this.binds.data;d=d.type&
1?d.value.apply(this,c):d.value;c.unshift(null);b.length=d.length;for(f=0;f<d.length;f++){pv.Mark.prototype.index=this.index=f;(g=b[f])||(b[f]=g={});g.data=c[0]=d[f];this.buildInstance(g)}pv.Mark.prototype.index=-1;delete this.index;c.shift();return this};a.buildProperties=function(b,c){for(var d=0,f=c.length;d<f;d++){var g=c[d],h=g.value;switch(g.type){case 0:case 1:h=this.scene.defs[g.name].value;break;case 3:h=h.apply(this,pv.Mark.stack);break}b[g.name]=h}};
a.buildInstance=function(b){this.buildProperties(b,this.binds.required);if(b.visible){this.buildProperties(b,this.binds.optional);this.buildImplied(b)}};
a.buildImplied=function(b){var c=b.left,d=b.right,f=b.top,g=b.bottom,h=this.properties,i=h.width?b.width:0,j=h.height?b.height:0,k=this.parent?this.parent.width():i+c+d;if(i==null)i=k-(d=d||0)-(c=c||0);else if(d==null)if(c==null)c=d=(k-i)/2;else d=k-i-(c=c||0);else if(c==null)c=k-i-d;k=this.parent?this.parent.height():j+f+g;if(j==null)j=k-(f=f||0)-(g=g||0);else if(g==null)g=f==null?(f=(k-j)/2):k-j-(f=f||0);else if(f==null)f=k-j-g;b.left=c;b.right=d;b.top=f;b.bottom=g;if(h.width)b.width=i;if(h.height)b.height=
j;if(h.textStyle&&!b.textStyle)b.textStyle=pv.Color.transparent;if(h.fillStyle&&!b.fillStyle)b.fillStyle=pv.Color.transparent;if(h.strokeStyle&&!b.strokeStyle)b.strokeStyle=pv.Color.transparent};
a.mouse=function(){var b=pv.event.pageX||0,c=pv.event.pageY||0,d=this.root.canvas();do{b-=d.offsetLeft;c-=d.offsetTop}while(d=d.offsetParent);d=pv.Transform.identity;var f=this.properties.transform?this:this.parent,g=[];do g.push(f);while(f=f.parent);for(;f=g.pop();)d=d.translate(f.left(),f.top()).times(f.transform());d=d.invert();return pv.vector(b*d.k+d.x,c*d.k+d.y)};a.event=function(b,c){this.$handlers[b]=pv.functor(c);return this};
a.context=function(b,c,d){function f(l,q){pv.Mark.scene=l;h.index=q;if(l){var n=l.mark,p=n,m=[];do{m.push(p);i.push(l[q].data);p.index=q;p.scene=l;q=l.parentIndex;l=l.parent}while(p=p.parent);l=m.length-1;for(q=1;l>0;l--){p=m[l];p.scale=q;q*=p.scene[p.index].transform.k}if(n.children){l=0;for(m=n.children.length;l<m;l++){p=n.children[l];p.scene=n.scene[n.index].children[l];p.scale=q}}}}function g(l){if(l){l=l.mark;var q;if(l.children)for(var n=0,p=l.children.length;n<p;n++){q=l.children[n];delete q.scene;
delete q.scale}q=l;do{i.pop();if(q.parent){delete q.scene;delete q.scale}delete q.index}while(q=q.parent)}}var h=pv.Mark.prototype,i=pv.Mark.stack,j=pv.Mark.scene,k=h.index;g(j,k);f(b,c);try{d.apply(this,i)}finally{g(b,c);f(j,k)}};pv.Mark.dispatch=function(b,c,d){var f=c.mark,g=c.parent,h=f.$handlers[b];if(!h)return g&&pv.Mark.dispatch(b,g,c.parentIndex);f.context(c,d,function(){(f=h.apply(f,pv.Mark.stack))&&f.render&&f.render()});return true};
pv.Anchor=function(b){pv.Mark.call(this);this.target=b;this.parent=b.parent};pv.Anchor.prototype=pv.extend(pv.Mark).property("name",String);pv.Anchor.prototype.extend=function(b){this.proto=b;return this};pv.Area=function(){pv.Mark.call(this)};
pv.Area.prototype=pv.extend(pv.Mark).property("width",Number).property("height",Number).property("lineWidth",Number).property("strokeStyle",pv.color).property("fillStyle",pv.color).property("segmented",Boolean).property("interpolate",String).property("tension",Number);pv.Area.prototype.type="area";pv.Area.prototype.defaults=(new pv.Area).extend(pv.Mark.prototype.defaults).lineWidth(1.5).fillStyle(pv.Colors.category20().by(pv.parent)).interpolate("linear").tension(0.7);
pv.Area.prototype.buildImplied=function(b){if(b.height==null)b.height=0;if(b.width==null)b.width=0;pv.Mark.prototype.buildImplied.call(this,b)};pv.Area.fixed={lineWidth:1,lineJoin:1,strokeStyle:1,fillStyle:1,segmented:1,interpolate:1,tension:1};
pv.Area.prototype.bind=function(){pv.Mark.prototype.bind.call(this);var b=this.binds,c=b.required;b=b.optional;for(var d=0,f=b.length;d<f;d++){var g=b[d];g.fixed=g.name in pv.Area.fixed;if(g.name=="segmented"){c.push(g);b.splice(d,1);d--;f--}}this.binds.$required=c;this.binds.$optional=b};
pv.Area.prototype.buildInstance=function(b){var c=this.binds;if(this.index){var d=c.fixed;if(!d){d=c.fixed=[];function f(i){return!i.fixed||(d.push(i),false)}c.required=c.required.filter(f);if(!this.scene[0].segmented)c.optional=c.optional.filter(f)}c=0;for(var g=d.length;c<g;c++){var h=d[c].name;b[h]=this.scene[0][h]}}else{c.required=c.$required;c.optional=c.$optional;c.fixed=null}pv.Mark.prototype.buildInstance.call(this,b)};
pv.Area.prototype.anchor=function(b){return pv.Mark.prototype.anchor.call(this,b).interpolate(function(){return this.scene.target[this.index].interpolate}).eccentricity(function(){return this.scene.target[this.index].eccentricity}).tension(function(){return this.scene.target[this.index].tension})};pv.Bar=function(){pv.Mark.call(this)};
pv.Bar.prototype=pv.extend(pv.Mark).property("width",Number).property("height",Number).property("lineWidth",Number).property("strokeStyle",pv.color).property("fillStyle",pv.color);pv.Bar.prototype.type="bar";pv.Bar.prototype.defaults=(new pv.Bar).extend(pv.Mark.prototype.defaults).lineWidth(1.5).fillStyle(pv.Colors.category20().by(pv.parent));pv.Dot=function(){pv.Mark.call(this)};
pv.Dot.prototype=pv.extend(pv.Mark).property("size",Number).property("radius",Number).property("shape",String).property("angle",Number).property("lineWidth",Number).property("strokeStyle",pv.color).property("fillStyle",pv.color);pv.Dot.prototype.type="dot";pv.Dot.prototype.defaults=(new pv.Dot).extend(pv.Mark.prototype.defaults).size(20).shape("circle").lineWidth(1.5).strokeStyle(pv.Colors.category10().by(pv.parent));
pv.Dot.prototype.anchor=function(b){return pv.Mark.prototype.anchor.call(this,b).left(function(){var c=this.scene.target[this.index];switch(this.name()){case "bottom":case "top":case "center":return c.left;case "left":return null}return c.left+c.radius}).right(function(){var c=this.scene.target[this.index];return this.name()=="left"?c.right+c.radius:null}).top(function(){var c=this.scene.target[this.index];switch(this.name()){case "left":case "right":case "center":return c.top;case "top":return null}return c.top+
c.radius}).bottom(function(){var c=this.scene.target[this.index];return this.name()=="top"?c.bottom+c.radius:null}).textAlign(function(){switch(this.name()){case "left":return"right";case "bottom":case "top":case "center":return"center"}return"left"}).textBaseline(function(){switch(this.name()){case "right":case "left":case "center":return"middle";case "bottom":return"top"}return"bottom"})};
pv.Dot.prototype.buildImplied=function(b){if(b.radius==null)b.radius=Math.sqrt(b.size);else if(b.size==null)b.size=b.radius*b.radius;pv.Mark.prototype.buildImplied.call(this,b)};pv.Label=function(){pv.Mark.call(this)};
pv.Label.prototype=pv.extend(pv.Mark).property("text",String).property("font",String).property("textAngle",Number).property("textStyle",pv.color).property("textAlign",String).property("textBaseline",String).property("textMargin",Number).property("textDecoration",String).property("textShadow",String);pv.Label.prototype.type="label";pv.Label.prototype.defaults=(new pv.Label).extend(pv.Mark.prototype.defaults).events("none").text(pv.identity).font("10px sans-serif").textAngle(0).textStyle("black").textAlign("left").textBaseline("bottom").textMargin(3);
pv.Line=function(){pv.Mark.call(this)};pv.Line.prototype=pv.extend(pv.Mark).property("lineWidth",Number).property("lineJoin",String).property("strokeStyle",pv.color).property("fillStyle",pv.color).property("segmented",Boolean).property("interpolate",String).property("eccentricity",Number).property("tension",Number);a=pv.Line.prototype;a.type="line";a.defaults=(new pv.Line).extend(pv.Mark.prototype.defaults).lineJoin("miter").lineWidth(1.5).strokeStyle(pv.Colors.category10().by(pv.parent)).interpolate("linear").eccentricity(0).tension(0.7);
a.bind=pv.Area.prototype.bind;a.buildInstance=pv.Area.prototype.buildInstance;a.anchor=function(b){return pv.Area.prototype.anchor.call(this,b).textAlign(function(){switch(this.name()){case "left":return"right";case "bottom":case "top":case "center":return"center";case "right":return"left"}}).textBaseline(function(){switch(this.name()){case "right":case "left":case "center":return"middle";case "top":return"bottom";case "bottom":return"top"}})};pv.Rule=function(){pv.Mark.call(this)};
pv.Rule.prototype=pv.extend(pv.Mark).property("width",Number).property("height",Number).property("lineWidth",Number).property("strokeStyle",pv.color);pv.Rule.prototype.type="rule";pv.Rule.prototype.defaults=(new pv.Rule).extend(pv.Mark.prototype.defaults).lineWidth(1).strokeStyle("black").antialias(false);pv.Rule.prototype.anchor=pv.Line.prototype.anchor;
pv.Rule.prototype.buildImplied=function(b){var c=b.left,d=b.right;if(b.width!=null||c==null&&d==null||d!=null&&c!=null)b.height=0;else b.width=0;pv.Mark.prototype.buildImplied.call(this,b)};pv.Panel=function(){pv.Bar.call(this);this.children=[];this.root=this;this.$dom=pv.$&&pv.$.s};pv.Panel.prototype=pv.extend(pv.Bar).property("transform").property("overflow",String).property("canvas",function(b){return typeof b=="string"?document.getElementById(b):b});a=pv.Panel.prototype;a.type="panel";
a.defaults=(new pv.Panel).extend(pv.Bar.prototype.defaults).fillStyle(null).overflow("visible");a.anchor=function(b){b=pv.Bar.prototype.anchor.call(this,b);b.parent=this;return b};a.add=function(b){b=new b;b.parent=this;b.root=this.root;b.childIndex=this.children.length;this.children.push(b);return b};a.bind=function(){pv.Mark.prototype.bind.call(this);for(var b=0;b<this.children.length;b++)this.children[b].bind()};
a.buildInstance=function(b){pv.Bar.prototype.buildInstance.call(this,b);if(b.visible){if(!b.children)b.children=[];var c=this.scale*b.transform.k,d,f=this.children.length;pv.Mark.prototype.index=-1;for(var g=0;g<f;g++){d=this.children[g];d.scene=b.children[g];d.scale=c;d.build()}for(g=0;g<f;g++){d=this.children[g];b.children[g]=d.scene;delete d.scene;delete d.scale}b.children.length=f}};
a.buildImplied=function(b){if(!this.parent){var c=b.canvas;if(c){if(c.$panel!=this)for(c.$panel=this;c.lastChild;)c.removeChild(c.lastChild);var d;if(b.width==null){d=parseFloat(pv.css(c,"width"));b.width=d-b.left-b.right}if(b.height==null){d=parseFloat(pv.css(c,"height"));b.height=d-b.top-b.bottom}}else{d=this.$canvas||(this.$canvas=[]);if(!(c=d[this.index])){c=d[this.index]=document.createElement("span");if(this.$dom)this.$dom.parentNode.insertBefore(c,this.$dom);else{for(d=document.body;d.lastChild&&
d.lastChild.tagName;)d=d.lastChild;if(d!=document.body)d=d.parentNode;d.appendChild(c)}}}b.canvas=c}if(!b.transform)b.transform=pv.Transform.identity;pv.Mark.prototype.buildImplied.call(this,b)};pv.Image=function(){pv.Bar.call(this)};pv.Image.prototype=pv.extend(pv.Bar).property("url",String).property("imageWidth",Number).property("imageHeight",Number);a=pv.Image.prototype;a.type="image";a.defaults=(new pv.Image).extend(pv.Bar.prototype.defaults).fillStyle(null);
a.image=function(b){this.$image=function(){var c=b.apply(this,arguments);return c==null?pv.Color.transparent:typeof c=="string"?pv.color(c):c};return this};a.bind=function(){pv.Bar.prototype.bind.call(this);var b=this.binds,c=this;do b.image=c.$image;while(!b.image&&(c=c.proto))};
a.buildImplied=function(b){pv.Bar.prototype.buildImplied.call(this,b);if(b.visible){if(b.imageWidth==null)b.imageWidth=b.width;if(b.imageHeight==null)b.imageHeight=b.height;if(b.url==null&&this.binds.image){var c=this.$canvas||(this.$canvas=document.createElement("canvas")),d=c.getContext("2d"),f=b.imageWidth,g=b.imageHeight,h=pv.Mark.stack;c.width=f;c.height=g;b=(b.image=d.createImageData(f,g)).data;h.unshift(null,null);for(d=c=0;c<g;c++){h[1]=c;for(var i=0;i<f;i++){h[0]=i;var j=this.binds.image.apply(this,
h);b[d++]=j.r;b[d++]=j.g;b[d++]=j.b;b[d++]=255*j.a}}h.splice(0,2)}}};pv.Wedge=function(){pv.Mark.call(this)};pv.Wedge.prototype=pv.extend(pv.Mark).property("startAngle",Number).property("endAngle",Number).property("angle",Number).property("innerRadius",Number).property("outerRadius",Number).property("lineWidth",Number).property("strokeStyle",pv.color).property("fillStyle",pv.color);a=pv.Wedge.prototype;a.type="wedge";
a.defaults=(new pv.Wedge).extend(pv.Mark.prototype.defaults).startAngle(function(){var b=this.sibling();return b?b.endAngle:-Math.PI/2}).innerRadius(0).lineWidth(1.5).strokeStyle(null).fillStyle(pv.Colors.category20().by(pv.index));a.midRadius=function(){return(this.innerRadius()+this.outerRadius())/2};a.midAngle=function(){return(this.startAngle()+this.endAngle())/2};
a.anchor=function(b){function c(g){return g.innerRadius||g.angle<2*Math.PI}function d(g){return(g.innerRadius+g.outerRadius)/2}function f(g){return(g.startAngle+g.endAngle)/2}return pv.Mark.prototype.anchor.call(this,b).left(function(){var g=this.scene.target[this.index];if(c(g))switch(this.name()){case "outer":return g.left+g.outerRadius*Math.cos(f(g));case "inner":return g.left+g.innerRadius*Math.cos(f(g));case "start":return g.left+d(g)*Math.cos(g.startAngle);case "center":return g.left+d(g)*Math.cos(f(g));
case "end":return g.left+d(g)*Math.cos(g.endAngle)}return g.left}).top(function(){var g=this.scene.target[this.index];if(c(g))switch(this.name()){case "outer":return g.top+g.outerRadius*Math.sin(f(g));case "inner":return g.top+g.innerRadius*Math.sin(f(g));case "start":return g.top+d(g)*Math.sin(g.startAngle);case "center":return g.top+d(g)*Math.sin(f(g));case "end":return g.top+d(g)*Math.sin(g.endAngle)}return g.top}).textAlign(function(){var g=this.scene.target[this.index];if(c(g))switch(this.name()){case "outer":return pv.Wedge.upright(f(g))?
"right":"left";case "inner":return pv.Wedge.upright(f(g))?"left":"right"}return"center"}).textBaseline(function(){var g=this.scene.target[this.index];if(c(g))switch(this.name()){case "start":return pv.Wedge.upright(g.startAngle)?"top":"bottom";case "end":return pv.Wedge.upright(g.endAngle)?"bottom":"top"}return"middle"}).textAngle(function(){var g=this.scene.target[this.index],h=0;if(c(g))switch(this.name()){case "center":case "inner":case "outer":h=f(g);break;case "start":h=g.startAngle;break;case "end":h=
g.endAngle;break}return pv.Wedge.upright(h)?h:h+Math.PI})};pv.Wedge.upright=function(b){b%=2*Math.PI;b=b<0?2*Math.PI+b:b;return b<Math.PI/2||b>=3*Math.PI/2};pv.Wedge.prototype.buildImplied=function(b){if(b.angle==null)b.angle=b.endAngle-b.startAngle;else if(b.endAngle==null)b.endAngle=b.startAngle+b.angle;pv.Mark.prototype.buildImplied.call(this,b)};pv.simulation=function(b){return new pv.Simulation(b)};pv.Simulation=function(b){for(var c=0;c<b.length;c++)this.particle(b[c])};a=pv.Simulation.prototype;
a.particle=function(b){b.next=this.particles;if(isNaN(b.px))b.px=b.x;if(isNaN(b.py))b.py=b.y;if(isNaN(b.fx))b.fx=0;if(isNaN(b.fy))b.fy=0;this.particles=b;return this};a.force=function(b){b.next=this.forces;this.forces=b;return this};a.constraint=function(b){b.next=this.constraints;this.constraints=b;return this};
a.stabilize=function(b){var c;arguments.length||(b=3);for(var d=0;d<b;d++){var f=new pv.Quadtree(this.particles);for(c=this.constraints;c;c=c.next)c.apply(this.particles,f)}for(c=this.particles;c;c=c.next){c.px=c.x;c.py=c.y}return this};
a.step=function(){var b;for(b=this.particles;b;b=b.next){var c=b.px,d=b.py;b.px=b.x;b.py=b.y;b.x+=b.vx=b.x-c+b.fx;b.y+=b.vy=b.y-d+b.fy}c=new pv.Quadtree(this.particles);for(b=this.constraints;b;b=b.next)b.apply(this.particles,c);for(b=this.particles;b;b=b.next)b.fx=b.fy=0;for(b=this.forces;b;b=b.next)b.apply(this.particles,c)};
pv.Quadtree=function(b){function c(l,q,n,p,m,r){if(!(isNaN(q.x)||isNaN(q.y)))if(l.leaf)if(l.p){if(!(Math.abs(l.p.x-q.x)+Math.abs(l.p.y-q.y)<0.01)){var s=l.p;l.p=null;d(l,s,n,p,m,r)}d(l,q,n,p,m,r)}else l.p=q;else d(l,q,n,p,m,r)}function d(l,q,n,p,m,r){var s=(n+m)*0.5,t=(p+r)*0.5,x=q.x>=s,u=q.y>=t;l.leaf=false;switch((u<<1)+x){case 0:l=l.c1||(l.c1=new pv.Quadtree.Node);break;case 1:l=l.c2||(l.c2=new pv.Quadtree.Node);break;case 2:l=l.c3||(l.c3=new pv.Quadtree.Node);break;case 3:l=l.c4||(l.c4=new pv.Quadtree.Node);
break}if(x)n=s;else m=s;if(u)p=t;else r=t;c(l,q,n,p,m,r)}var f,g=Number.POSITIVE_INFINITY,h=g,i=Number.NEGATIVE_INFINITY,j=i;for(f=b;f;f=f.next){if(f.x<g)g=f.x;if(f.y<h)h=f.y;if(f.x>i)i=f.x;if(f.y>j)j=f.y}f=i-g;var k=j-h;if(f>k)j=h+f;else i=g+k;this.xMin=g;this.yMin=h;this.xMax=i;this.yMax=j;this.root=new pv.Quadtree.Node;for(f=b;f;f=f.next)c(this.root,f,g,h,i,j)};pv.Quadtree.Node=function(){this.leaf=true;this.p=this.c4=this.c3=this.c2=this.c1=null};pv.Force={};
pv.Force.charge=function(b){function c(l){function q(m){c(m);l.cn+=m.cn;n+=m.cn*m.cx;p+=m.cn*m.cy}var n=0,p=0;l.cn=0;if(!l.leaf){l.c1&&q(l.c1);l.c2&&q(l.c2);l.c3&&q(l.c3);l.c4&&q(l.c4)}if(l.p){l.cn+=b;n+=b*l.p.x;p+=b*l.p.y}l.cx=n/l.cn;l.cy=p/l.cn}function d(l,q,n,p,m,r){var s=l.cx-q.x,t=l.cy-q.y,x=1/Math.sqrt(s*s+t*t);if(l.leaf&&l.p!=q||(m-n)*x<j){if(!(x<i)){if(x>g)x=g;l=l.cn*x*x*x;s=s*l;t=t*l;q.fx+=s;q.fy+=t}}else if(!l.leaf){var u=(n+m)*0.5,o=(p+r)*0.5;l.c1&&d(l.c1,q,n,p,u,o);l.c2&&d(l.c2,q,u,p,
m,o);l.c3&&d(l.c3,q,n,o,u,r);l.c4&&d(l.c4,q,u,o,m,r);if(!(x<i)){if(x>g)x=g;if(l.p&&l.p!=q){l=b*x*x*x;s=s*l;t=t*l;q.fx+=s;q.fy+=t}}}}var f=2,g=1/f,h=500,i=1/h,j=0.9,k={};arguments.length||(b=-40);k.constant=function(l){if(arguments.length){b=Number(l);return k}return b};k.domain=function(l,q){if(arguments.length){f=Number(l);g=1/f;h=Number(q);i=1/h;return k}return[f,h]};k.theta=function(l){if(arguments.length){j=Number(l);return k}return j};k.apply=function(l,q){c(q.root);for(l=l;l;l=l.next)d(q.root,
l,q.xMin,q.yMin,q.xMax,q.yMax)};return k};pv.Force.drag=function(b){var c={};arguments.length||(b=0.1);c.constant=function(d){if(arguments.length){b=d;return c}return b};c.apply=function(d){if(b)for(d=d;d;d=d.next){d.fx-=b*d.vx;d.fy-=b*d.vy}};return c};
pv.Force.spring=function(b){var c=0.1,d=20,f,g,h={};arguments.length||(b=0.1);h.links=function(i){if(arguments.length){f=i;g=i.map(function(j){return 1/Math.sqrt(Math.max(j.sourceNode.linkDegree,j.targetNode.linkDegree))});return h}return f};h.constant=function(i){if(arguments.length){b=Number(i);return h}return b};h.damping=function(i){if(arguments.length){c=Number(i);return h}return c};h.length=function(i){if(arguments.length){d=Number(i);return h}return d};h.apply=function(){for(var i=0;i<f.length;i++){var j=
f[i].sourceNode,k=f[i].targetNode,l=j.x-k.x,q=j.y-k.y,n=Math.sqrt(l*l+q*q),p=n?1/n:1;p=(b*g[i]*(n-d)+c*g[i]*(l*(j.vx-k.vx)+q*(j.vy-k.vy))*p)*p;l=-p*(n?l:0.01*(0.5-Math.random()));q=-p*(n?q:0.01*(0.5-Math.random()));j.fx+=l;j.fy+=q;k.fx-=l;k.fy-=q}};return h};pv.Constraint={};
pv.Constraint.collision=function(b){function c(l,q,n,p,m,r){if(!l.leaf){var s=(n+m)*0.5,t=(p+r)*0.5,x=t<j,u=s>g,o=s<i;if(t>h){l.c1&&u&&c(l.c1,q,n,p,s,t);l.c2&&o&&c(l.c2,q,s,p,m,t)}if(x){l.c3&&u&&c(l.c3,q,n,t,s,r);l.c4&&o&&c(l.c4,q,s,t,m,r)}}if(l.p&&l.p!=q){n=q.x-l.p.x;p=q.y-l.p.y;m=Math.sqrt(n*n+p*p);r=f+b(l.p);if(m<r){m=(m-r)/m*0.5;n*=m;p*=m;q.x-=n;q.y-=p;l.p.x+=n;l.p.y+=p}}}var d=1,f,g,h,i,j,k={};arguments.length||(f=10);k.repeat=function(l){if(arguments.length){d=Number(l);return k}return d};k.apply=
function(l,q){var n,p,m=-Infinity;for(n=l;n;n=n.next){p=b(n);if(p>m)m=p}for(var r=0;r<d;r++)for(n=l;n;n=n.next){p=(f=b(n))+m;g=n.x-p;i=n.x+p;h=n.y-p;j=n.y+p;c(q.root,n,q.xMin,q.yMin,q.xMax,q.yMax)}};return k};pv.Constraint.position=function(b){var c=1,d={};arguments.length||(b=function(f){return f.fix});d.alpha=function(f){if(arguments.length){c=Number(f);return d}return c};d.apply=function(f){for(f=f;f;f=f.next){var g=b(f);if(g){f.x+=(g.x-f.x)*c;f.y+=(g.y-f.y)*c;f.fx=f.fy=f.vx=f.vy=0}}};return d};
pv.Constraint.bound=function(){var b={},c,d;b.x=function(f,g){if(arguments.length){c={min:Math.min(f,g),max:Math.max(f,g)};return this}return c};b.y=function(f,g){if(arguments.length){d={min:Math.min(f,g),max:Math.max(f,g)};return this}return d};b.apply=function(f){if(c)for(var g=f;g;g=g.next)g.x=g.x<c.min?c.min:g.x>c.max?c.max:g.x;if(d)for(g=f;g;g=g.next)g.y=g.y<d.min?d.min:g.y>d.max?d.max:g.y};return b};pv.Layout=function(){pv.Panel.call(this)};pv.Layout.prototype=pv.extend(pv.Panel);
pv.Layout.prototype.property=function(b,c){if(!this.hasOwnProperty("properties"))this.properties=pv.extend(this.properties);this.properties[b]=true;this.propertyMethod(b,false,pv.Mark.cast[b]=c);return this};
pv.Layout.Network=function(){pv.Layout.call(this);var b=this;this.$id=pv.id();(this.node=(new pv.Mark).data(function(){return b.nodes()}).strokeStyle("#1f77b4").fillStyle("#fff").left(function(c){return c.x}).top(function(c){return c.y})).parent=this;this.link=(new pv.Mark).extend(this.node).data(function(c){return[c.sourceNode,c.targetNode]}).fillStyle(null).lineWidth(function(c,d){return d.linkValue*1.5}).strokeStyle("rgba(0,0,0,.2)");this.link.add=function(c){return b.add(pv.Panel).data(function(){return b.links()}).add(c).extend(this)};
(this.label=(new pv.Mark).extend(this.node).textMargin(7).textBaseline("middle").text(function(c){return c.nodeName||c.nodeValue}).textAngle(function(c){c=c.midAngle;return pv.Wedge.upright(c)?c:c+Math.PI}).textAlign(function(c){return pv.Wedge.upright(c.midAngle)?"left":"right"})).parent=this};
pv.Layout.Network.prototype=pv.extend(pv.Layout).property("nodes",function(b){return b.map(function(c,d){if(typeof c!="object")c={nodeValue:c};c.index=d;return c})}).property("links",function(b){return b.map(function(c){if(isNaN(c.linkValue))c.linkValue=isNaN(c.value)?1:c.value;return c})});pv.Layout.Network.prototype.reset=function(){this.$id=pv.id();return this};pv.Layout.Network.prototype.buildProperties=function(b,c){if((b.$id||0)<this.$id)pv.Layout.prototype.buildProperties.call(this,b,c)};
pv.Layout.Network.prototype.buildImplied=function(b){pv.Layout.prototype.buildImplied.call(this,b);if(b.$id>=this.$id)return true;b.$id=this.$id;b.nodes.forEach(function(c){c.linkDegree=0});b.links.forEach(function(c){var d=c.linkValue;(c.sourceNode||(c.sourceNode=b.nodes[c.source])).linkDegree+=d;(c.targetNode||(c.targetNode=b.nodes[c.target])).linkDegree+=d})};pv.Layout.Hierarchy=function(){pv.Layout.Network.call(this);this.link.strokeStyle("#ccc")};pv.Layout.Hierarchy.prototype=pv.extend(pv.Layout.Network);
pv.Layout.Hierarchy.prototype.buildImplied=function(b){if(!b.links)b.links=pv.Layout.Hierarchy.links.call(this);pv.Layout.Network.prototype.buildImplied.call(this,b)};pv.Layout.Hierarchy.links=function(){return this.nodes().filter(function(b){return b.parentNode}).map(function(b){return{sourceNode:b,targetNode:b.parentNode,linkValue:1}})};
pv.Layout.Hierarchy.NodeLink={buildImplied:function(b){function c(m){return m.parentNode?m.depth*(n-q)+q:0}function d(m){return m.parentNode?(m.breadth-0.25)*2*Math.PI:0}function f(m){switch(i){case "left":return m.depth*k;case "right":return k-m.depth*k;case "top":return m.breadth*k;case "bottom":return k-m.breadth*k;case "radial":return k/2+c(m)*Math.cos(m.midAngle)}}function g(m){switch(i){case "left":return m.breadth*l;case "right":return l-m.breadth*l;case "top":return m.depth*l;case "bottom":return l-
m.depth*l;case "radial":return l/2+c(m)*Math.sin(m.midAngle)}}var h=b.nodes,i=b.orient,j=/^(top|bottom)$/.test(i),k=b.width,l=b.height;if(i=="radial"){var q=b.innerRadius,n=b.outerRadius;if(q==null)q=0;if(n==null)n=Math.min(k,l)/2}for(b=0;b<h.length;b++){var p=h[b];p.midAngle=i=="radial"?d(p):j?Math.PI/2:0;p.x=f(p);p.y=g(p);if(p.firstChild)p.midAngle+=Math.PI}}};
pv.Layout.Hierarchy.Fill={constructor:function(){this.node.strokeStyle("#fff").fillStyle("#ccc").width(function(b){return b.dx}).height(function(b){return b.dy}).innerRadius(function(b){return b.innerRadius}).outerRadius(function(b){return b.outerRadius}).startAngle(function(b){return b.startAngle}).angle(function(b){return b.angle});this.label.textAlign("center").left(function(b){return b.x+b.dx/2}).top(function(b){return b.y+b.dy/2});delete this.link},buildImplied:function(b){function c(o,v){return(o+
v)/(1+v)}function d(o){switch(n){case "left":return c(o.minDepth,s)*m;case "right":return(1-c(o.maxDepth,s))*m;case "top":return o.minBreadth*m;case "bottom":return(1-o.maxBreadth)*m;case "radial":return m/2}}function f(o){switch(n){case "left":return o.minBreadth*r;case "right":return(1-o.maxBreadth)*r;case "top":return c(o.minDepth,s)*r;case "bottom":return(1-c(o.maxDepth,s))*r;case "radial":return r/2}}function g(o){switch(n){case "left":case "right":return(o.maxDepth-o.minDepth)/(1+s)*m;case "top":case "bottom":return(o.maxBreadth-
o.minBreadth)*m;case "radial":return o.parentNode?(o.innerRadius+o.outerRadius)*Math.cos(o.midAngle):0}}function h(o){switch(n){case "left":case "right":return(o.maxBreadth-o.minBreadth)*r;case "top":case "bottom":return(o.maxDepth-o.minDepth)/(1+s)*r;case "radial":return o.parentNode?(o.innerRadius+o.outerRadius)*Math.sin(o.midAngle):0}}function i(o){return Math.max(0,c(o.minDepth,s/2))*(x-t)+t}function j(o){return c(o.maxDepth,s/2)*(x-t)+t}function k(o){return(o.parentNode?o.minBreadth-0.25:0)*
2*Math.PI}function l(o){return(o.parentNode?o.maxBreadth-o.minBreadth:1)*2*Math.PI}var q=b.nodes,n=b.orient,p=/^(top|bottom)$/.test(n),m=b.width,r=b.height,s=-q[0].minDepth;if(n=="radial"){var t=b.innerRadius,x=b.outerRadius;if(t==null)t=0;if(t)s*=2;if(x==null)x=Math.min(m,r)/2}for(b=0;b<q.length;b++){var u=q[b];u.x=d(u);u.y=f(u);if(n=="radial"){u.innerRadius=i(u);u.outerRadius=j(u);u.startAngle=k(u);u.angle=l(u);u.midAngle=u.startAngle+u.angle/2}else u.midAngle=p?-Math.PI/2:0;u.dx=g(u);u.dy=h(u)}}};
pv.Layout.Grid=function(){pv.Layout.call(this);var b=this;(this.cell=(new pv.Mark).data(function(){return b.scene[b.index].$grid}).width(function(){return b.width()/b.cols()}).height(function(){return b.height()/b.rows()}).left(function(){return this.width()*(this.index%b.cols())}).top(function(){return this.height()*Math.floor(this.index/b.cols())})).parent=this};pv.Layout.Grid.prototype=pv.extend(pv.Layout).property("rows").property("cols");pv.Layout.Grid.prototype.defaults=(new pv.Layout.Grid).extend(pv.Layout.prototype.defaults).rows(1).cols(1);
pv.Layout.Grid.prototype.buildImplied=function(b){pv.Layout.prototype.buildImplied.call(this,b);var c=b.rows,d=b.cols;if(typeof d=="object")c=pv.transpose(d);if(typeof c=="object"){b.$grid=pv.blend(c);b.rows=c.length;b.cols=c[0]?c[0].length:0}else b.$grid=pv.repeat([b.data],c*d)};
pv.Layout.Stack=function(){function b(i){return function(){return f[i](this.parent.index,this.index)}}pv.Layout.call(this);var c=this,d=function(){return null},f={t:d,l:d,r:d,b:d,w:d,h:d},g,h=c.buildImplied;this.buildImplied=function(i){h.call(this,i);var j=i.layers,k=j.length,l,q=i.orient,n=/^(top|bottom)\b/.test(q),p=this.parent[n?"height":"width"](),m=[],r=[],s=[],t=pv.Mark.stack,x={parent:{parent:this}};t.unshift(null);g=[];for(var u=0;u<k;u++){s[u]=[];r[u]=[];x.parent.index=u;t[0]=j[u];g[u]=
this.$values.apply(x.parent,t);if(!u)l=g[u].length;t.unshift(null);for(var o=0;o<l;o++){t[0]=g[u][o];x.index=o;u||(m[o]=this.$x.apply(x,t));s[u][o]=this.$y.apply(x,t)}t.shift()}t.shift();switch(i.order){case "inside-out":var v=s.map(function(A){return pv.max.index(A)});x=pv.range(k).sort(function(A,D){return v[A]-v[D]});j=s.map(function(A){return pv.sum(A)});var w=t=0,y=[],z=[];for(u=0;u<k;u++){o=x[u];if(t<w){t+=j[o];y.push(o)}else{w+=j[o];z.push(o)}}j=z.reverse().concat(y);break;case "reverse":j=
pv.range(k-1,-1,-1);break;default:j=pv.range(k);break}switch(i.offset){case "silohouette":for(o=0;o<l;o++){for(u=x=0;u<k;u++)x+=s[u][o];r[j[0]][o]=(p-x)/2}break;case "wiggle":for(u=x=0;u<k;u++)x+=s[u][0];r[j[0]][0]=x=(p-x)/2;for(o=1;o<l;o++){t=p=0;w=m[o]-m[o-1];for(u=0;u<k;u++)p+=s[u][o];for(u=0;u<k;u++){y=(s[j[u]][o]-s[j[u]][o-1])/(2*w);for(i=0;i<u;i++)y+=(s[j[i]][o]-s[j[i]][o-1])/w;t+=y*s[j[u]][o]}r[j[0]][o]=x-=p?t/p*w:0}break;case "expand":for(o=0;o<l;o++){for(u=i=r[j[0]][o]=0;u<k;u++)i+=s[u][o];
if(i){i=p/i;for(u=0;u<k;u++)s[u][o]*=i}else{i=p/k;for(u=0;u<k;u++)s[u][o]=i}}break;default:for(o=0;o<l;o++)r[j[0]][o]=0;break}for(o=0;o<l;o++){x=r[j[0]][o];for(u=1;u<k;u++){x+=s[j[u-1]][o];r[j[u]][o]=x}}u=q.indexOf("-");k=n?"h":"w";n=u<0?n?"l":"b":q.charAt(u+1);q=q.charAt(0);for(var C in f)f[C]=d;f[n]=function(A,D){return m[D]};f[q]=function(A,D){return r[A][D]};f[k]=function(A,D){return s[A][D]}};this.layer=(new pv.Mark).data(function(){return g[this.parent.index]}).top(b("t")).left(b("l")).right(b("r")).bottom(b("b")).width(b("w")).height(b("h"));
this.layer.add=function(i){return c.add(pv.Panel).data(function(){return c.layers()}).add(i).extend(this)}};pv.Layout.Stack.prototype=pv.extend(pv.Layout).property("orient",String).property("offset",String).property("order",String).property("layers");a=pv.Layout.Stack.prototype;a.defaults=(new pv.Layout.Stack).extend(pv.Layout.prototype.defaults).orient("bottom-left").offset("zero").layers([[]]);a.$x=pv.Layout.Stack.prototype.$y=function(){return 0};a.x=function(b){this.$x=pv.functor(b);return this};
a.y=function(b){this.$y=pv.functor(b);return this};a.$values=pv.identity;a.values=function(b){this.$values=pv.functor(b);return this};
pv.Layout.Treemap=function(){pv.Layout.Hierarchy.call(this);this.node.strokeStyle("#fff").fillStyle("rgba(31, 119, 180, .25)").width(function(b){return b.dx}).height(function(b){return b.dy});this.label.visible(function(b){return!b.firstChild}).left(function(b){return b.x+b.dx/2}).top(function(b){return b.y+b.dy/2}).textAlign("center").textAngle(function(b){return b.dx>b.dy?0:-Math.PI/2});(this.leaf=(new pv.Mark).extend(this.node).fillStyle(null).strokeStyle(null).visible(function(b){return!b.firstChild})).parent=
this;delete this.link};pv.Layout.Treemap.prototype=pv.extend(pv.Layout.Hierarchy).property("round",Boolean).property("paddingLeft",Number).property("paddingRight",Number).property("paddingTop",Number).property("paddingBottom",Number).property("mode",String).property("order",String);a=pv.Layout.Treemap.prototype;a.defaults=(new pv.Layout.Treemap).extend(pv.Layout.Hierarchy.prototype.defaults).mode("squarify").order("ascending");a.padding=function(b){return this.paddingLeft(b).paddingRight(b).paddingTop(b).paddingBottom(b)};
a.$size=function(b){return Number(b.nodeValue)};a.size=function(b){this.$size=pv.functor(b);return this};
a.buildImplied=function(b){function c(r,s,t,x,u,o,v){for(var w=0,y=0;w<r.length;w++){var z=r[w];if(t){z.x=x+y;z.y=u;y+=z.dx=p(o*z.size/s);z.dy=v}else{z.x=x;z.y=u+y;z.dx=o;y+=z.dy=p(v*z.size/s)}}if(z)if(t)z.dx+=o-y;else z.dy+=v-y}function d(r,s){for(var t=-Infinity,x=Infinity,u=0,o=0;o<r.length;o++){var v=r[o].size;if(v<x)x=v;if(v>t)t=v;u+=v}u*=u;s*=s;return Math.max(s*t/u,u/(s*x))}function f(r,s){function t(A){var D=o==y,G=pv.sum(A,n),E=y?p(G/y):0;c(A,G,D,x,u,D?o:E,D?E:v);if(D){u+=E;v-=E}else{x+=
E;o-=E}y=Math.min(o,v);return D}var x=r.x+j,u=r.y+l,o=r.dx-j-k,v=r.dy-l-q;if(m!="squarify")c(r.childNodes,r.size,m=="slice"?true:m=="dice"?false:s&1,x,u,o,v);else{var w=[];s=Infinity;var y=Math.min(o,v),z=o*v/r.size;if(!(r.size<=0)){r.visitBefore(function(A){A.size*=z});for(r=r.childNodes.slice();r.length;){var C=r[r.length-1];if(C.size){w.push(C);z=d(w,y);if(z<=s){r.pop();s=z}else{w.pop();t(w);w.length=0;s=Infinity}}else r.pop()}if(t(w))for(s=0;s<w.length;s++)w[s].dy+=v;else for(s=0;s<w.length;s++)w[s].dx+=
o}}}if(!pv.Layout.Hierarchy.prototype.buildImplied.call(this,b)){var g=this,h=b.nodes[0],i=pv.Mark.stack,j=b.paddingLeft,k=b.paddingRight,l=b.paddingTop,q=b.paddingBottom,n=function(r){return r.size},p=b.round?Math.round:Number,m=b.mode;i.unshift(null);h.visitAfter(function(r,s){r.depth=s;r.x=r.y=r.dx=r.dy=0;r.size=r.firstChild?pv.sum(r.childNodes,function(t){return t.size}):g.$size.apply(g,(i[0]=r,i))});i.shift();switch(b.order){case "ascending":h.sort(function(r,s){return r.size-s.size});break;
case "descending":h.sort(function(r,s){return s.size-r.size});break;case "reverse":h.reverse();break}h.x=0;h.y=0;h.dx=b.width;h.dy=b.height;h.visitBefore(f)}};pv.Layout.Tree=function(){pv.Layout.Hierarchy.call(this)};pv.Layout.Tree.prototype=pv.extend(pv.Layout.Hierarchy).property("group",Number).property("breadth",Number).property("depth",Number).property("orient",String);pv.Layout.Tree.prototype.defaults=(new pv.Layout.Tree).extend(pv.Layout.Hierarchy.prototype.defaults).group(1).breadth(15).depth(60).orient("top");
pv.Layout.Tree.prototype.buildImplied=function(b){function c(o){var v,w,y;if(o.firstChild){v=o.firstChild;w=o.lastChild;for(var z=y=v;z;z=z.nextSibling){c(z);y=f(z,y)}j(o);w=0.5*(v.prelim+w.prelim);if(v=o.previousSibling){o.prelim=v.prelim+l(o.depth,true);o.mod=o.prelim-w}else o.prelim=w}else if(v=o.previousSibling)o.prelim=v.prelim+l(o.depth,true)}function d(o,v,w){o.breadth=o.prelim+v;v+=o.mod;for(o=o.firstChild;o;o=o.nextSibling)d(o,v,w)}function f(o,v){var w=o.previousSibling;if(w){var y=o,z=
o,C=w;w=o.parentNode.firstChild;var A=y.mod,D=z.mod,G=C.mod,E=w.mod;C=h(C);for(y=g(y);C&&y;){C=C;y=y;w=g(w);z=h(z);z.ancestor=o;var B=C.prelim+G-(y.prelim+A)+l(C.depth,false);if(B>0){i(k(C,o,v),o,B);A+=B;D+=B}G+=C.mod;A+=y.mod;E+=w.mod;D+=z.mod;C=h(C);y=g(y)}if(C&&!h(z)){z.thread=C;z.mod+=G-D}if(y&&!g(w)){w.thread=y;w.mod+=A-E;v=o}}return v}function g(o){return o.firstChild||o.thread}function h(o){return o.lastChild||o.thread}function i(o,v,w){var y=v.number-o.number;v.change-=w/y;v.shift+=w;o.change+=
w/y;v.prelim+=w;v.mod+=w}function j(o){var v=0,w=0;for(o=o.lastChild;o;o=o.previousSibling){o.prelim+=v;o.mod+=v;w+=o.change;v+=o.shift+w}}function k(o,v,w){return o.ancestor.parentNode==v.parentNode?o.ancestor:w}function l(o,v){return(v?1:t+1)/(m=="radial"?o:1)}function q(o){return m=="radial"?o.breadth/r:0}function n(o){switch(m){case "left":return o.depth;case "right":return x-o.depth;case "top":case "bottom":return o.breadth+x/2;case "radial":return x/2+o.depth*Math.cos(q(o))}}function p(o){switch(m){case "left":case "right":return o.breadth+
u/2;case "top":return o.depth;case "bottom":return u-o.depth;case "radial":return u/2+o.depth*Math.sin(q(o))}}if(!pv.Layout.Hierarchy.prototype.buildImplied.call(this,b)){var m=b.orient,r=b.depth,s=b.breadth,t=b.group,x=b.width,u=b.height;b=b.nodes[0];b.visitAfter(function(o,v){o.ancestor=o;o.prelim=0;o.mod=0;o.change=0;o.shift=0;o.number=o.previousSibling?o.previousSibling.number+1:0;o.depth=v});c(b);d(b,-b.prelim,0);b.visitAfter(function(o){o.breadth*=s;o.depth*=r;o.midAngle=q(o);o.x=n(o);o.y=p(o);
if(o.firstChild)o.midAngle+=Math.PI;delete o.breadth;delete o.depth;delete o.ancestor;delete o.prelim;delete o.mod;delete o.change;delete o.shift;delete o.number;delete o.thread})}};pv.Layout.Indent=function(){pv.Layout.Hierarchy.call(this);this.link.interpolate("step-after")};pv.Layout.Indent.prototype=pv.extend(pv.Layout.Hierarchy).property("depth",Number).property("breadth",Number);pv.Layout.Indent.prototype.defaults=(new pv.Layout.Indent).extend(pv.Layout.Hierarchy.prototype.defaults).depth(15).breadth(15);
pv.Layout.Indent.prototype.buildImplied=function(b){function c(i,j,k){i.x=g+k++*f;i.y=h+j++*d;i.midAngle=0;for(i=i.firstChild;i;i=i.nextSibling)j=c(i,j,k);return j}if(!pv.Layout.Hierarchy.prototype.buildImplied.call(this,b)){var d=b.breadth,f=b.depth,g=0,h=0;c(b.nodes[0],1,1)}};pv.Layout.Pack=function(){pv.Layout.Hierarchy.call(this);this.node.radius(function(b){return b.radius}).strokeStyle("rgb(31, 119, 180)").fillStyle("rgba(31, 119, 180, .25)");this.label.textAlign("center");delete this.link};
pv.Layout.Pack.prototype=pv.extend(pv.Layout.Hierarchy).property("spacing",Number).property("order",String);pv.Layout.Pack.prototype.defaults=(new pv.Layout.Pack).extend(pv.Layout.Hierarchy.prototype.defaults).spacing(1).order("ascending");pv.Layout.Pack.prototype.$radius=function(){return 1};pv.Layout.Pack.prototype.size=function(b){this.$radius=typeof b=="function"?function(){return Math.sqrt(b.apply(this,arguments))}:(b=Math.sqrt(b),function(){return b});return this};
pv.Layout.Pack.prototype.buildImplied=function(b){function c(n){var p=pv.Mark.stack;p.unshift(null);for(var m=0,r=n.length;m<r;m++){var s=n[m];if(!s.firstChild)s.radius=i.$radius.apply(i,(p[0]=s,p))}p.shift()}function d(n){var p=[];for(n=n.firstChild;n;n=n.nextSibling){if(n.firstChild)n.radius=d(n);n.n=n.p=n;p.push(n)}switch(b.order){case "ascending":p.sort(function(m,r){return m.radius-r.radius});break;case "descending":p.sort(function(m,r){return r.radius-m.radius});break;case "reverse":p.reverse();
break}return f(p)}function f(n){function p(B){t=Math.min(B.x-B.radius,t);x=Math.max(B.x+B.radius,x);u=Math.min(B.y-B.radius,u);o=Math.max(B.y+B.radius,o)}function m(B,F){var H=B.n;B.n=F;F.p=B;F.n=H;H.p=F}function r(B,F){B.n=F;F.p=B}function s(B,F){var H=F.x-B.x,I=F.y-B.y;B=B.radius+F.radius;return B*B-H*H-I*I>0.0010}var t=Infinity,x=-Infinity,u=Infinity,o=-Infinity,v,w,y,z,C;v=n[0];v.x=-v.radius;v.y=0;p(v);if(n.length>1){w=n[1];w.x=w.radius;w.y=0;p(w);if(n.length>2){y=n[2];g(v,w,y);p(y);m(v,y);v.p=
y;m(y,w);w=v.n;for(var A=3;A<n.length;A++){g(v,w,y=n[A]);var D=0,G=1,E=1;for(z=w.n;z!=w;z=z.n,G++)if(s(z,y)){D=1;break}if(D==1)for(C=v.p;C!=z.p;C=C.p,E++)if(s(C,y)){if(E<G){D=-1;z=C}break}if(D==0){m(v,y);w=y;p(y)}else if(D>0){r(v,z);w=z;A--}else if(D<0){r(z,w);v=z;A--}}}}v=(t+x)/2;w=(u+o)/2;for(A=y=0;A<n.length;A++){z=n[A];z.x-=v;z.y-=w;y=Math.max(y,z.radius+Math.sqrt(z.x*z.x+z.y*z.y))}return y+b.spacing}function g(n,p,m){var r=p.radius+m.radius,s=n.radius+m.radius,t=p.x-n.x;p=p.y-n.y;var x=Math.sqrt(t*
t+p*p),u=(s*s+x*x-r*r)/(2*s*x);r=Math.acos(u);u=u*s;s=Math.sin(r)*s;t/=x;p/=x;m.x=n.x+u*t+s*p;m.y=n.y+u*p-s*t}function h(n,p,m,r){for(var s=n.firstChild;s;s=s.nextSibling){s.x+=n.x;s.y+=n.y;h(s,p,m,r)}n.x=p+r*n.x;n.y=m+r*n.y;n.radius*=r}if(!pv.Layout.Hierarchy.prototype.buildImplied.call(this,b)){var i=this,j=b.nodes,k=j[0];c(j);k.x=0;k.y=0;k.radius=d(k);j=this.width();var l=this.height(),q=1/Math.max(2*k.radius/j,2*k.radius/l);h(k,j/2,l/2,q)}};
pv.Layout.Force=function(){pv.Layout.Network.call(this);this.link.lineWidth(function(b,c){return Math.sqrt(c.linkValue)*1.5});this.label.textAlign("center")};
pv.Layout.Force.prototype=pv.extend(pv.Layout.Network).property("bound",Boolean).property("iterations",Number).property("dragConstant",Number).property("chargeConstant",Number).property("chargeMinDistance",Number).property("chargeMaxDistance",Number).property("chargeTheta",Number).property("springConstant",Number).property("springDamping",Number).property("springLength",Number);pv.Layout.Force.prototype.defaults=(new pv.Layout.Force).extend(pv.Layout.Network.prototype.defaults).dragConstant(0.1).chargeConstant(-40).chargeMinDistance(2).chargeMaxDistance(500).chargeTheta(0.9).springConstant(0.1).springDamping(0.3).springLength(20);
pv.Layout.Force.prototype.buildImplied=function(b){function c(q){return q.fix?1:q.vx*q.vx+q.vy*q.vy}if(pv.Layout.Network.prototype.buildImplied.call(this,b)){if(b=b.$force){b.next=this.binds.$force;this.binds.$force=b}}else{for(var d=this,f=b.nodes,g=b.links,h=b.iterations,i=b.width,j=b.height,k=0,l;k<f.length;k++){l=f[k];if(isNaN(l.x))l.x=i/2+40*Math.random()-20;if(isNaN(l.y))l.y=j/2+40*Math.random()-20}l=pv.simulation(f);l.force(pv.Force.drag(b.dragConstant));l.force(pv.Force.charge(b.chargeConstant).domain(b.chargeMinDistance,
b.chargeMaxDistance).theta(b.chargeTheta));l.force(pv.Force.spring(b.springConstant).damping(b.springDamping).length(b.springLength).links(g));l.constraint(pv.Constraint.position());b.bound&&l.constraint(pv.Constraint.bound().x(6,i-6).y(6,j-6));if(h==null){l.step();l.step();b.$force=this.binds.$force={next:this.binds.$force,nodes:f,min:1.0E-4*(g.length+1),sim:l};if(!this.$timer)this.$timer=setInterval(function(){for(var q=false,n=d.binds.$force;n;n=n.next)if(pv.max(n.nodes,c)>n.min){n.sim.step();
q=true}q&&d.render()},42)}else for(k=0;k<h;k++)l.step()}};pv.Layout.Cluster=function(){pv.Layout.Hierarchy.call(this);var b,c=this.buildImplied;this.buildImplied=function(d){c.call(this,d);b=/^(top|bottom)$/.test(d.orient)?"step-before":/^(left|right)$/.test(d.orient)?"step-after":"linear"};this.link.interpolate(function(){return b})};
pv.Layout.Cluster.prototype=pv.extend(pv.Layout.Hierarchy).property("group",Number).property("orient",String).property("innerRadius",Number).property("outerRadius",Number);pv.Layout.Cluster.prototype.defaults=(new pv.Layout.Cluster).extend(pv.Layout.Hierarchy.prototype.defaults).group(0).orient("top");
pv.Layout.Cluster.prototype.buildImplied=function(b){if(!pv.Layout.Hierarchy.prototype.buildImplied.call(this,b)){var c=b.nodes[0],d=b.group,f,g,h=0,i=0.5-d/2,j=undefined;c.visitAfter(function(k){if(k.firstChild)k.depth=1+pv.max(k.childNodes,function(l){return l.depth});else{if(d&&j!=k.parentNode){j=k.parentNode;h+=d}h++;k.depth=0}});f=1/h;g=1/c.depth;j=undefined;c.visitAfter(function(k){if(k.firstChild)k.breadth=pv.mean(k.childNodes,function(l){return l.breadth});else{if(d&&j!=k.parentNode){j=k.parentNode;
i+=d}k.breadth=f*i++}k.depth=1-k.depth*g});c.visitAfter(function(k){k.minBreadth=k.firstChild?k.firstChild.minBreadth:k.breadth-f/2;k.maxBreadth=k.firstChild?k.lastChild.maxBreadth:k.breadth+f/2});c.visitBefore(function(k){k.minDepth=k.parentNode?k.parentNode.maxDepth:0;k.maxDepth=k.parentNode?k.depth+c.depth:k.minDepth+2*c.depth});c.minDepth=-g;pv.Layout.Hierarchy.NodeLink.buildImplied.call(this,b)}};pv.Layout.Cluster.Fill=function(){pv.Layout.Cluster.call(this);pv.Layout.Hierarchy.Fill.constructor.call(this)};
pv.Layout.Cluster.Fill.prototype=pv.extend(pv.Layout.Cluster);pv.Layout.Cluster.Fill.prototype.buildImplied=function(b){pv.Layout.Cluster.prototype.buildImplied.call(this,b)||pv.Layout.Hierarchy.Fill.buildImplied.call(this,b)};pv.Layout.Partition=function(){pv.Layout.Hierarchy.call(this)};pv.Layout.Partition.prototype=pv.extend(pv.Layout.Hierarchy).property("order",String).property("orient",String).property("innerRadius",Number).property("outerRadius",Number);
pv.Layout.Partition.prototype.defaults=(new pv.Layout.Partition).extend(pv.Layout.Hierarchy.prototype.defaults).orient("top");pv.Layout.Partition.prototype.$size=function(){return 1};pv.Layout.Partition.prototype.size=function(b){this.$size=b;return this};
pv.Layout.Partition.prototype.buildImplied=function(b){if(!pv.Layout.Hierarchy.prototype.buildImplied.call(this,b)){var c=this,d=b.nodes[0],f=pv.Mark.stack,g=0;f.unshift(null);d.visitAfter(function(i,j){if(j>g)g=j;i.size=i.firstChild?pv.sum(i.childNodes,function(k){return k.size}):c.$size.apply(c,(f[0]=i,f))});f.shift();switch(b.order){case "ascending":d.sort(function(i,j){return i.size-j.size});break;case "descending":d.sort(function(i,j){return j.size-i.size});break}var h=1/g;d.minBreadth=0;d.breadth=
0.5;d.maxBreadth=1;d.visitBefore(function(i){for(var j=i.minBreadth,k=i.maxBreadth-j,l=i.firstChild;l;l=l.nextSibling){l.minBreadth=j;l.maxBreadth=j+=l.size/i.size*k;l.breadth=(j+l.minBreadth)/2}});d.visitAfter(function(i,j){i.minDepth=(j-1)*h;i.maxDepth=i.depth=j*h});pv.Layout.Hierarchy.NodeLink.buildImplied.call(this,b)}};pv.Layout.Partition.Fill=function(){pv.Layout.Partition.call(this);pv.Layout.Hierarchy.Fill.constructor.call(this)};pv.Layout.Partition.Fill.prototype=pv.extend(pv.Layout.Partition);
pv.Layout.Partition.Fill.prototype.buildImplied=function(b){pv.Layout.Partition.prototype.buildImplied.call(this,b)||pv.Layout.Hierarchy.Fill.buildImplied.call(this,b)};pv.Layout.Arc=function(){pv.Layout.Network.call(this);var b,c,d,f=this.buildImplied;this.buildImplied=function(g){f.call(this,g);c=g.directed;b=g.orient=="radial"?"linear":"polar";d=g.orient=="right"||g.orient=="top"};this.link.data(function(g){var h=g.sourceNode;g=g.targetNode;return d!=(c||h.breadth<g.breadth)?[h,g]:[g,h]}).interpolate(function(){return b})};
pv.Layout.Arc.prototype=pv.extend(pv.Layout.Network).property("orient",String).property("directed",Boolean);pv.Layout.Arc.prototype.defaults=(new pv.Layout.Arc).extend(pv.Layout.Network.prototype.defaults).orient("bottom");pv.Layout.Arc.prototype.sort=function(b){this.$sort=b;return this};
pv.Layout.Arc.prototype.buildImplied=function(b){function c(m){switch(h){case "top":return-Math.PI/2;case "bottom":return Math.PI/2;case "left":return Math.PI;case "right":return 0;case "radial":return(m-0.25)*2*Math.PI}}function d(m){switch(h){case "top":case "bottom":return m*k;case "left":return 0;case "right":return k;case "radial":return k/2+q*Math.cos(c(m))}}function f(m){switch(h){case "top":return 0;case "bottom":return l;case "left":case "right":return m*l;case "radial":return l/2+q*Math.sin(c(m))}}
if(!pv.Layout.Network.prototype.buildImplied.call(this,b)){var g=b.nodes,h=b.orient,i=this.$sort,j=pv.range(g.length),k=b.width,l=b.height,q=Math.min(k,l)/2;i&&j.sort(function(m,r){return i(g[m],g[r])});for(b=0;b<g.length;b++){var n=g[j[b]],p=n.breadth=(b+0.5)/g.length;n.x=d(p);n.y=f(p);n.midAngle=c(p)}}};
pv.Layout.Horizon=function(){pv.Layout.call(this);var b=this,c,d,f,g,h,i,j=this.buildImplied;this.buildImplied=function(k){j.call(this,k);c=k.bands;d=k.mode;f=Math.round((d=="color"?0.5:1)*k.height);g=k.backgroundStyle;h=pv.ramp(g,k.negativeStyle).domain(0,c);i=pv.ramp(g,k.positiveStyle).domain(0,c)};c=(new pv.Panel).data(function(){return pv.range(c*2)}).overflow("hidden").height(function(){return f}).top(function(k){return d=="color"?(k&1)*f:0}).fillStyle(function(k){return k?null:g});this.band=
(new pv.Mark).top(function(k,l){return d=="mirror"&&l&1?(l+1>>1)*f:null}).bottom(function(k,l){return d=="mirror"?l&1?null:(l+1>>1)*-f:(l&1||-1)*(l+1>>1)*f}).fillStyle(function(k,l){return(l&1?h:i)((l>>1)+1)});this.band.add=function(k){return b.add(pv.Panel).extend(c).add(k).extend(this)}};pv.Layout.Horizon.prototype=pv.extend(pv.Layout).property("bands",Number).property("mode",String).property("backgroundStyle",pv.color).property("positiveStyle",pv.color).property("negativeStyle",pv.color);
pv.Layout.Horizon.prototype.defaults=(new pv.Layout.Horizon).extend(pv.Layout.prototype.defaults).bands(2).mode("offset").backgroundStyle("white").positiveStyle("#1f77b4").negativeStyle("#d62728");
pv.Layout.Rollup=function(){pv.Layout.Network.call(this);var b=this,c,d,f=b.buildImplied;this.buildImplied=function(g){f.call(this,g);c=g.$rollup.nodes;d=g.$rollup.links};this.node.data(function(){return c}).size(function(g){return g.nodes.length*20});this.link.interpolate("polar").eccentricity(0.8);this.link.add=function(g){return b.add(pv.Panel).data(function(){return d}).add(g).extend(this)}};pv.Layout.Rollup.prototype=pv.extend(pv.Layout.Network).property("directed",Boolean);
pv.Layout.Rollup.prototype.x=function(b){this.$x=pv.functor(b);return this};pv.Layout.Rollup.prototype.y=function(b){this.$y=pv.functor(b);return this};
pv.Layout.Rollup.prototype.buildImplied=function(b){function c(r){return i[r]+","+j[r]}if(!pv.Layout.Network.prototype.buildImplied.call(this,b)){var d=b.nodes,f=b.links,g=b.directed,h=d.length,i=[],j=[],k=0,l={},q={},n=pv.Mark.stack,p={parent:this};n.unshift(null);for(var m=0;m<h;m++){p.index=m;n[0]=d[m];i[m]=this.$x.apply(p,n);j[m]=this.$y.apply(p,n)}n.shift();for(m=0;m<d.length;m++){h=c(m);n=l[h];if(!n){n=l[h]=pv.extend(d[m]);n.index=k++;n.x=i[m];n.y=j[m];n.nodes=[]}n.nodes.push(d[m])}for(m=0;m<
f.length;m++){k=f[m].targetNode;d=l[c(f[m].sourceNode.index)];k=l[c(k.index)];h=!g&&d.index>k.index?k.index+","+d.index:d.index+","+k.index;(n=q[h])||(n=q[h]={sourceNode:d,targetNode:k,linkValue:0,links:[]});n.links.push(f[m]);n.linkValue+=f[m].linkValue}b.$rollup={nodes:pv.values(l),links:pv.values(q)}}};
pv.Layout.Matrix=function(){pv.Layout.Network.call(this);var b,c,d,f,g,h=this.buildImplied;this.buildImplied=function(i){h.call(this,i);b=i.nodes.length;c=i.width/b;d=i.height/b;f=i.$matrix.labels;g=i.$matrix.pairs};this.link.data(function(){return g}).left(function(){return c*(this.index%b)}).top(function(){return d*Math.floor(this.index/b)}).width(function(){return c}).height(function(){return d}).lineWidth(1.5).strokeStyle("#fff").fillStyle(function(i){return i.linkValue?"#555":"#eee"}).parent=
this;delete this.link.add;this.label.data(function(){return f}).left(function(){return this.index&1?c*((this.index>>1)+0.5):0}).top(function(){return this.index&1?0:d*((this.index>>1)+0.5)}).textMargin(4).textAlign(function(){return this.index&1?"left":"right"}).textAngle(function(){return this.index&1?-Math.PI/2:0});delete this.node};pv.Layout.Matrix.prototype=pv.extend(pv.Layout.Network).property("directed",Boolean);pv.Layout.Matrix.prototype.sort=function(b){this.$sort=b;return this};
pv.Layout.Matrix.prototype.buildImplied=function(b){if(!pv.Layout.Network.prototype.buildImplied.call(this,b)){var c=b.nodes,d=b.links,f=this.$sort,g=c.length,h=pv.range(g),i=[],j=[],k={};b.$matrix={labels:i,pairs:j};f&&h.sort(function(m,r){return f(c[m],c[r])});for(var l=0;l<g;l++)for(var q=0;q<g;q++){var n=h[l],p=h[q];j.push(k[n+"."+p]={row:l,col:q,sourceNode:c[n],targetNode:c[p],linkValue:0})}for(l=0;l<g;l++){n=h[l];i.push(c[n],c[n])}for(l=0;l<d.length;l++){i=d[l];g=i.sourceNode.index;h=i.targetNode.index;
i=i.linkValue;k[g+"."+h].linkValue+=i;b.directed||(k[h+"."+g].linkValue+=i)}}};
pv.Layout.Bullet=function(){pv.Layout.call(this);var b=this,c=b.buildImplied,d=b.x=pv.Scale.linear(),f,g,h,i,j;this.buildImplied=function(k){c.call(this,j=k);f=k.orient;g=/^left|right$/.test(f);h=pv.ramp("#bbb","#eee").domain(0,Math.max(1,j.ranges.length-1));i=pv.ramp("steelblue","lightsteelblue").domain(0,Math.max(1,j.measures.length-1))};(this.range=new pv.Mark).data(function(){return j.ranges}).reverse(true).left(function(){return f=="left"?0:null}).top(function(){return f=="top"?0:null}).right(function(){return f==
"right"?0:null}).bottom(function(){return f=="bottom"?0:null}).width(function(k){return g?d(k):null}).height(function(k){return g?null:d(k)}).fillStyle(function(){return h(this.index)}).antialias(false).parent=b;(this.measure=new pv.Mark).extend(this.range).data(function(){return j.measures}).left(function(){return f=="left"?0:g?null:this.parent.width()/3.25}).top(function(){return f=="top"?0:g?this.parent.height()/3.25:null}).right(function(){return f=="right"?0:g?null:this.parent.width()/3.25}).bottom(function(){return f==
"bottom"?0:g?this.parent.height()/3.25:null}).fillStyle(function(){return i(this.index)}).parent=b;(this.marker=new pv.Mark).data(function(){return j.markers}).left(function(k){return f=="left"?d(k):g?null:this.parent.width()/2}).top(function(k){return f=="top"?d(k):g?this.parent.height()/2:null}).right(function(k){return f=="right"?d(k):null}).bottom(function(k){return f=="bottom"?d(k):null}).strokeStyle("black").shape("bar").angle(function(){return g?0:Math.PI/2}).parent=b;(this.tick=new pv.Mark).data(function(){return d.ticks(7)}).left(function(k){return f==
"left"?d(k):null}).top(function(k){return f=="top"?d(k):null}).right(function(k){return f=="right"?d(k):g?null:-6}).bottom(function(k){return f=="bottom"?d(k):g?-8:null}).height(function(){return g?6:null}).width(function(){return g?null:6}).parent=b};pv.Layout.Bullet.prototype=pv.extend(pv.Layout).property("orient",String).property("ranges").property("markers").property("measures").property("maximum",Number);pv.Layout.Bullet.prototype.defaults=(new pv.Layout.Bullet).extend(pv.Layout.prototype.defaults).orient("left").ranges([]).markers([]).measures([]);
pv.Layout.Bullet.prototype.buildImplied=function(b){pv.Layout.prototype.buildImplied.call(this,b);var c=this.parent[/^left|right$/.test(b.orient)?"width":"height"]();b.maximum=b.maximum||pv.max([].concat(b.ranges,b.markers,b.measures));this.x.domain(0,b.maximum).range(0,c)};pv.Behavior={};
pv.Behavior.drag=function(){function b(k){g=this.index;f=this.scene;var l=this.mouse();i=((h=k).fix=pv.vector(k.x,k.y)).minus(l);j={x:this.parent.width()-(k.dx||0),y:this.parent.height()-(k.dy||0)};f.mark.context(f,g,function(){this.render()});pv.Mark.dispatch("dragstart",f,g)}function c(){if(f){f.mark.context(f,g,function(){var k=this.mouse();h.x=h.fix.x=Math.max(0,Math.min(i.x+k.x,j.x));h.y=h.fix.y=Math.max(0,Math.min(i.y+k.y,j.y));this.render()});pv.Mark.dispatch("drag",f,g)}}function d(){if(f){h.fix=
null;f.mark.context(f,g,function(){this.render()});pv.Mark.dispatch("dragend",f,g);f=null}}var f,g,h,i,j;pv.listen(window,"mousemove",c);pv.listen(window,"mouseup",d);return b};
pv.Behavior.point=function(b){function c(l,q){l=l[q];q={cost:Infinity};for(var n=0,p=l.visible&&l.children.length;n<p;n++){var m=l.children[n],r=m.mark,s;if(r.type=="panel"){r.scene=m;for(var t=0,x=m.length;t<x;t++){r.index=t;s=c(m,t);if(s.cost<q.cost)q=s}delete r.scene;delete r.index}else if(r.$handlers.point){r=r.mouse();t=0;for(x=m.length;t<x;t++){var u=m[t];s=r.x-u.left-(u.width||0)/2;u=r.y-u.top-(u.height||0)/2;var o=i*s*s+j*u*u;if(o<q.cost){q.distance=s*s+u*u;q.cost=o;q.scene=m;q.index=t}}}}return q}
function d(){var l=c(this.scene,this.index);if(l.cost==Infinity||l.distance>k)l=null;if(g){if(l&&g.scene==l.scene&&g.index==l.index)return;pv.Mark.dispatch("unpoint",g.scene,g.index)}if(g=l){pv.Mark.dispatch("point",l.scene,l.index);pv.listen(this.root.canvas(),"mouseout",f)}}function f(l){if(g&&!pv.ancestor(this,l.relatedTarget)){pv.Mark.dispatch("unpoint",g.scene,g.index);g=null}}var g,h=null,i=1,j=1,k=arguments.length?b*b:900;d.collapse=function(l){if(arguments.length){h=String(l);switch(h){case "y":i=
1;j=0;break;case "x":i=0;j=1;break;default:j=i=1;break}return d}return h};return d};
pv.Behavior.select=function(){function b(j){g=this.index;f=this.scene;i=this.mouse();h=j;h.x=i.x;h.y=i.y;h.dx=h.dy=0;pv.Mark.dispatch("selectstart",f,g)}function c(){if(f){f.mark.context(f,g,function(){var j=this.mouse();h.x=Math.max(0,Math.min(i.x,j.x));h.y=Math.max(0,Math.min(i.y,j.y));h.dx=Math.min(this.width(),Math.max(j.x,i.x))-h.x;h.dy=Math.min(this.height(),Math.max(j.y,i.y))-h.y;this.render()});pv.Mark.dispatch("select",f,g)}}function d(){if(f){pv.Mark.dispatch("selectend",f,g);f=null}}var f,
g,h,i;pv.listen(window,"mousemove",c);pv.listen(window,"mouseup",d);return b};
pv.Behavior.resize=function(b){function c(k){h=this.index;g=this.scene;j=this.mouse();i=k;switch(b){case "left":j.x=i.x+i.dx;break;case "right":j.x=i.x;break;case "top":j.y=i.y+i.dy;break;case "bottom":j.y=i.y;break}pv.Mark.dispatch("resizestart",g,h)}function d(){if(g){g.mark.context(g,h,function(){var k=this.mouse();i.x=Math.max(0,Math.min(j.x,k.x));i.y=Math.max(0,Math.min(j.y,k.y));i.dx=Math.min(this.parent.width(),Math.max(k.x,j.x))-i.x;i.dy=Math.min(this.parent.height(),Math.max(k.y,j.y))-i.y;
this.render()});pv.Mark.dispatch("resize",g,h)}}function f(){if(g){pv.Mark.dispatch("resizeend",g,h);g=null}}var g,h,i,j;pv.listen(window,"mousemove",d);pv.listen(window,"mouseup",f);return c};
pv.Behavior.pan=function(){function b(){g=this.index;f=this.scene;i=pv.vector(pv.event.pageX,pv.event.pageY);h=this.transform();j=1/(h.k*this.scale);if(k)k={x:(1-h.k)*this.width(),y:(1-h.k)*this.height()}}function c(){if(f){f.mark.context(f,g,function(){var l=h.translate((pv.event.pageX-i.x)*j,(pv.event.pageY-i.y)*j);if(k){l.x=Math.max(k.x,Math.min(0,l.x));l.y=Math.max(k.y,Math.min(0,l.y))}this.transform(l).render()});pv.Mark.dispatch("pan",f,g)}}function d(){f=null}var f,g,h,i,j,k;b.bound=function(l){if(arguments.length){k=
Boolean(l);return this}return Boolean(k)};pv.listen(window,"mousemove",c);pv.listen(window,"mouseup",d);return b};
pv.Behavior.zoom=function(b){function c(){var f=this.mouse(),g=pv.event.wheel*b;f=this.transform().translate(f.x,f.y).scale(g<0?1E3/(1E3-g):(1E3+g)/1E3).translate(-f.x,-f.y);if(d){f.k=Math.max(1,f.k);f.x=Math.max((1-f.k)*this.width(),Math.min(0,f.x));f.y=Math.max((1-f.k)*this.height(),Math.min(0,f.y))}this.transform(f).render();pv.Mark.dispatch("zoom",this.scene,this.index)}var d;arguments.length||(b=1/48);c.bound=function(f){if(arguments.length){d=Boolean(f);return this}return Boolean(d)};return c};
pv.Geo=function(){};
pv.Geo.projections={mercator:{project:function(b){return{x:b.lng/180,y:b.lat>85?1:b.lat<-85?-1:Math.log(Math.tan(Math.PI/4+pv.radians(b.lat)/2))/Math.PI}},invert:function(b){return{lng:b.x*180,lat:pv.degrees(2*Math.atan(Math.exp(b.y*Math.PI))-Math.PI/2)}}},"gall-peters":{project:function(b){return{x:b.lng/180,y:Math.sin(pv.radians(b.lat))}},invert:function(b){return{lng:b.x*180,lat:pv.degrees(Math.asin(b.y))}}},sinusoidal:{project:function(b){return{x:pv.radians(b.lng)*Math.cos(pv.radians(b.lat))/Math.PI,
y:b.lat/90}},invert:function(b){return{lng:pv.degrees(b.x*Math.PI/Math.cos(b.y*Math.PI/2)),lat:b.y*90}}},aitoff:{project:function(b){var c=pv.radians(b.lng);b=pv.radians(b.lat);var d=Math.acos(Math.cos(b)*Math.cos(c/2));return{x:2*(d?Math.cos(b)*Math.sin(c/2)*d/Math.sin(d):0)/Math.PI,y:2*(d?Math.sin(b)*d/Math.sin(d):0)/Math.PI}},invert:function(b){var c=b.y*Math.PI/2;return{lng:pv.degrees(b.x*Math.PI/2/Math.cos(c)),lat:pv.degrees(c)}}},hammer:{project:function(b){var c=pv.radians(b.lng);b=pv.radians(b.lat);
var d=Math.sqrt(1+Math.cos(b)*Math.cos(c/2));return{x:2*Math.SQRT2*Math.cos(b)*Math.sin(c/2)/d/3,y:Math.SQRT2*Math.sin(b)/d/1.5}},invert:function(b){var c=b.x*3;b=b.y*1.5;var d=Math.sqrt(1-c*c/16-b*b/4);return{lng:pv.degrees(2*Math.atan2(d*c,2*(2*d*d-1))),lat:pv.degrees(Math.asin(d*b))}}},identity:{project:function(b){return{x:b.lng/180,y:b.lat/90}},invert:function(b){return{lng:b.x*180,lat:b.y*90}}}};
pv.Geo.scale=function(b){function c(m){if(!n||m.lng!=n.lng||m.lat!=n.lat){n=m;m=d(m);p={x:k(m.x),y:l(m.y)}}return p}function d(m){return j.project({lng:m.lng-q.lng,lat:m.lat})}function f(m){m=j.invert(m);m.lng+=q.lng;return m}var g={x:0,y:0},h={x:1,y:1},i=[],j=pv.Geo.projections.identity,k=pv.Scale.linear(-1,1).range(0,1),l=pv.Scale.linear(-1,1).range(1,0),q={lng:0,lat:0},n,p;c.x=function(m){return c(m).x};c.y=function(m){return c(m).y};c.ticks={lng:function(m){var r;if(i.length>1){var s=pv.Scale.linear();
if(m==undefined)m=10;r=s.domain(i,function(t){return t.lat}).ticks(m);m=s.domain(i,function(t){return t.lng}).ticks(m)}else{r=pv.range(-80,81,10);m=pv.range(-180,181,10)}return m.map(function(t){return r.map(function(x){return{lat:x,lng:t}})})},lat:function(m){return pv.transpose(c.ticks.lng(m))}};c.invert=function(m){return f({x:k.invert(m.x),y:l.invert(m.y)})};c.domain=function(m,r){if(arguments.length){i=m instanceof Array?arguments.length>1?pv.map(m,r):m:Array.prototype.slice.call(arguments);
if(i.length>1){var s=i.map(function(x){return x.lng}),t=i.map(function(x){return x.lat});q={lng:(pv.max(s)+pv.min(s))/2,lat:(pv.max(t)+pv.min(t))/2};s=i.map(d);k.domain(s,function(x){return x.x});l.domain(s,function(x){return x.y})}else{q={lng:0,lat:0};k.domain(-1,1);l.domain(-1,1)}n=null;return this}return i};c.range=function(m,r){if(arguments.length){if(typeof m=="object"){g={x:Number(m.x),y:Number(m.y)};h={x:Number(r.x),y:Number(r.y)}}else{g={x:0,y:0};h={x:Number(m),y:Number(r)}}k.range(g.x,h.x);
l.range(h.y,g.y);n=null;return this}return[g,h]};c.projection=function(m){if(arguments.length){j=typeof m=="string"?pv.Geo.projections[m]||pv.Geo.projections.identity:m;return this.domain(i)}return m};c.by=function(m){function r(){return c(m.apply(this,arguments))}for(var s in c)r[s]=c[s];return r};arguments.length&&c.projection(b);return c};
File diff suppressed because one or more lines are too long
+16 -28
View File
@@ -1,4 +1,4 @@
from ..utility.utility import tensor2pil, pil2tensor, normalize_bboxes, BBOX_TYPES
from ..utility.utility import tensor2pil, pil2tensor
from PIL import Image, ImageDraw, ImageFilter
import numpy as np
import torch
@@ -161,7 +161,7 @@ class BatchUncrop:
"required": {
"original_images": ("IMAGE",),
"cropped_images": ("IMAGE",),
"bboxes": (BBOX_TYPES,),
"bboxes": ("BBOX",),
"border_blending": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01}, ),
"crop_rescale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}),
"border_top": ("BOOLEAN", {"default": True}),
@@ -193,8 +193,6 @@ class BatchUncrop:
if len(original_images) != len(cropped_images):
raise ValueError(f"The number of original_images ({len(original_images)}) and cropped_images ({len(cropped_images)}) should be the same")
bboxes = normalize_bboxes(bboxes)
# Ensure there are enough bboxes, but drop the excess if there are more bboxes than images
if len(bboxes) > len(original_images):
print(f"Warning: Dropping excess bounding boxes. Expected {len(original_images)}, but got {len(bboxes)}")
@@ -544,14 +542,14 @@ class BatchUncropAdvanced:
"cropped_images": ("IMAGE",),
"cropped_masks": ("MASK",),
"combined_crop_mask": ("MASK",),
"bboxes": (BBOX_TYPES,),
"bboxes": ("BBOX",),
"border_blending": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01}, ),
"crop_rescale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}),
"use_combined_mask": ("BOOLEAN", {"default": False}),
"use_square_mask": ("BOOLEAN", {"default": True}),
},
"optional": {
"combined_bounding_box": (BBOX_TYPES, {"default": None}),
"combined_bounding_box": ("BBOX", {"default": None}),
},
}
@@ -573,10 +571,6 @@ class BatchUncropAdvanced:
if len(original_images) != len(cropped_images):
raise ValueError(f"The number of original_images ({len(original_images)}) and cropped_images ({len(cropped_images)}) should be the same")
bboxes = normalize_bboxes(bboxes)
if combined_bounding_box is not None:
combined_bounding_box = normalize_bboxes(combined_bounding_box)
# Ensure there are enough bboxes, but drop the excess if there are more bboxes than images
if len(bboxes) > len(original_images):
print(f"Warning: Dropping excess bounding boxes. Expected {len(original_images)}, but got {len(bboxes)}")
@@ -647,7 +641,7 @@ class SplitBboxes:
def INPUT_TYPES(cls):
return {
"required": {
"bboxes": (BBOX_TYPES,),
"bboxes": ("BBOX",),
"index": ("INT", {"default": 0,"min": 0, "max": 99999999, "step": 1}),
},
}
@@ -661,7 +655,6 @@ Splits the specified bbox list at the given index into two lists.
"""
def splitbbox(self, bboxes, index):
bboxes = normalize_bboxes(bboxes)
bboxes_a = bboxes[:index] # Sub-list from the start of bboxes up to (but not including) the index
bboxes_b = bboxes[index:] # Sub-list from the index to the end of bboxes
@@ -673,7 +666,7 @@ class BboxToInt:
def INPUT_TYPES(cls):
return {
"required": {
"bboxes": (BBOX_TYPES,),
"bboxes": ("BBOX",),
"index": ("INT", {"default": 0,"min": 0, "max": 99999999, "step": 1}),
},
}
@@ -686,7 +679,7 @@ class BboxToInt:
Returns selected index from bounding box list as integers.
"""
def bboxtoint(self, bboxes, index):
x_min, y_min, width, height = normalize_bboxes(bboxes)[index]
x_min, y_min, width, height = bboxes[index]
center_x = int(x_min + width / 2)
center_y = int(y_min + height / 2)
@@ -699,7 +692,7 @@ class BboxVisualize:
return {
"required": {
"images": ("IMAGE",),
"bboxes": (BBOX_TYPES,),
"bboxes": ("BBOX",),
"line_width": ("INT", {"default": 1,"min": 1, "max": 10, "step": 1}),
"bbox_format": (["xywh", "xyxy"], {"default": "xywh"}),
},
@@ -716,20 +709,15 @@ Visualizes the specified bbox on the image.
def visualizebbox(self, bboxes, images, line_width, bbox_format):
image_list = []
for image, bbox in zip(images, normalize_bboxes(bboxes, bbox_format)):
# Ensure bbox is a sequence of 4 values
if isinstance(bbox, (list, tuple, np.ndarray)) and len(bbox) == 4:
if bbox_format == "xywh":
x_min, y_min, width, height = bbox
elif bbox_format == "xyxy":
x_min, y_min, x_max, y_max = bbox
width = x_max - x_min
height = y_max - y_min
else:
raise ValueError(f"Unknown bbox_format: {bbox_format}")
for image, bbox in zip(images, bboxes):
if bbox_format == "xywh":
x_min, y_min, width, height = bbox
elif bbox_format == "xyxy":
x_min, y_min, x_max, y_max = bbox
width = x_max - x_min
height = y_max - y_min
else:
print("Invalid bbox:", bbox)
continue
raise ValueError(f"Unknown bbox_format: {bbox_format}")
# Ensure bbox coordinates are integers
x_min = int(x_min)
-162
View File
@@ -1,162 +0,0 @@
import inspect
import math
from comfy_api.latest import io
try:
import comfy.context_windows as context_windows
except ImportError:
context_windows = None
SCHEDULE_OPTIONS = ["standard_static", "standard_static_balanced", "standard_uniform", "looped_uniform", "batched", "batched_shifted"]
FUSE_OPTIONS = ["pyramid", "relative", "flat", "overlap-linear", "hann", "gaussian"]
def _ordered_halving(val):
# low-discrepancy fraction in [0, 1) from bit-reversal of the step index (core's ordered_halving)
return int(f"{int(val):064b}"[::-1], 2) / (1 << 64)
def create_windows_batched_shifted(num_frames, handler, model_options=None):
# zero-overlap partition whose seams roll each step (no fixed boundary error); ~1.0x compute
F = int(num_frames)
L = int(handler.context_length)
if F <= L:
return [list(range(F))]
offset = round(F * _ordered_halving(handler._step)) % L # 0 at step 0 (plain batched)
windows = []
start = 0
if offset > 0:
windows.append(list(range(0, offset))) # leading partial
start = offset
while start < F:
end = min(start + L, F)
windows.append(list(range(start, end))) # trailing partial when unaligned
start = end
return windows
def create_windows_static_balanced(num_frames, handler, model_options=None):
# standard_static window count, but spread evenly so every seam is uniform (±1 frame)
F = int(num_frames)
L = int(handler.context_length)
overlap = int(handler.context_overlap)
if F <= L:
return [list(range(F))]
stride_target = max(1, L - overlap)
N = -(-(F - L) // stride_target) + 1 # ceil((F-L)/stride_target)+1; stride <= L, no gaps
windows = []
for i in range(N):
start = math.floor(i * (F - L) / (N - 1) + 0.5)
windows.append(list(range(start, start + L)))
return windows
def create_weights_hann(length, full_length=None, idxs=None, handler=None, **kwargs):
# smooth overlap-linear: flat interior, raised-cosine ramps in the overlaps (sum to 1)
weights = [1.0] * length
overlap = min(max(int(getattr(handler, "context_overlap", 0) or 0), 0), length)
if overlap >= 1 and idxs:
denom = max(overlap - 1, 1)
if min(idxs) > 0: # left ramp, skipped on first window
for j in range(overlap):
weights[j] = max(0.5 * (1 - math.cos(math.pi * j / denom)), 1e-37)
if full_length is not None and max(idxs) < full_length - 1: # right ramp, skipped on last
for j in range(overlap):
weights[length - overlap + j] = max(0.5 * (1 - math.cos(math.pi * (denom - j) / denom)), 1e-37)
return weights
def create_weights_gaussian(length, **kwargs):
# smooth bell over the window (smooth twin of pyramid)
if length <= 1:
return [1.0] * length
c = (length - 1) / 2.0
std = max(length / 4.0, 1e-6)
return [math.exp(-0.5 * ((i - c) / std) ** 2) for i in range(length)]
LOCAL_FUSE = {"hann": create_weights_hann, "gaussian": create_weights_gaussian}
LOCAL_SCHEDULES = {
"standard_static_balanced": create_windows_static_balanced,
"batched_shifted": create_windows_batched_shifted,
}
class ContextWindowsVisualizerKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ContextWindowsVisualizerKJ",
display_name="Context Windows Visualizer (KJ)",
category="KJNodes/misc",
description="Interactive visualization of ComfyUI context windows: window placement, overlap, "
"fuse weights and per-frame blending, updated live. Optionally applies the settings "
"to a model like the core Context Windows (Manual) node.",
is_experimental=True,
inputs=[
io.Model.Input("model", optional=True, tooltip="Optional model to apply the context windows to during sampling."),
io.Combo.Input("frame_units", options=["pixel", "latent"], default="pixel",
tooltip="Units for the frame counts below. 'pixel' converts to latent via ((n - 1) // temporal_downscale) + 1."),
io.Int.Input("dim", default=2, min=0, max=5, tooltip="Latent temporal dimension: 2 for WAN-like, 0 for AnimateDiff-style. Model path only.", advanced=True),
io.Int.Input("temporal_downscale", default=4, min=1, max=16,
tooltip="VAE temporal compression for pixel->latent. WAN/Hunyuan/CogVideoX=4, Mochi=6, LTXV=8."),
io.Int.Input("num_frames", default=161, min=1, max=100000, tooltip="Total frames to visualize. Ignored on the model path (the real latent length is used)."),
io.Int.Input("context_length", default=81, min=1, max=100000, tooltip="Window length."),
io.Int.Input("context_overlap", default=30, min=0, max=100000, tooltip="Overlap between windows."),
io.Combo.Input("context_schedule", options=SCHEDULE_OPTIONS, tooltip="Window placement. standard_static_balanced: static with windows spread evenly (uniform seams, any frame count). batched_shifted: zero-overlap windows whose seams roll each step (~1.0x compute, pairs with freenoise). Uniform schedules also shift per step."),
io.Int.Input("context_stride", default=1, min=1, max=32, tooltip="Max stride power for uniform schedules (adds dilated windows: 1, 2, 4...)."),
io.Boolean.Input("closed_loop", default=False, tooltip="Wrap looped-schedule windows around the end back to frame 0."),
io.Combo.Input("fuse_method", options=FUSE_OPTIONS, default="pyramid", tooltip="How overlapping windows are blended."),
io.Boolean.Input("causal_window_fix", default=False, tooltip="Prepend an anchor frame to non-0 windows, stripped after the model call.", advanced=True),
io.Boolean.Input("freenoise", default=True, tooltip="FreeNoise noise shuffling for smoother blending. Model path only.", advanced=True),
io.String.Input("cond_retain_index_list", default="", tooltip="Comma-separated window-relative indices that keep their original conditioning, e.g. '0' reuses the start frame's cond per window. No viz effect; model path only.", advanced=True),
],
outputs=[
io.Model.Output(display_name="model", tooltip="Model with context windows applied; only valid when a model is connected."),
],
)
@classmethod
def execute(cls, frame_units, temporal_downscale, num_frames, context_length, context_overlap, context_schedule, context_stride,
closed_loop, fuse_method, dim, causal_window_fix, freenoise, cond_retain_index_list="", model=None) -> io.NodeOutput:
if model is None:
return io.NodeOutput(None)
if context_windows is None:
raise Exception("comfy.context_windows not available in this ComfyUI version.")
if frame_units == "pixel":
f = max(int(temporal_downscale), 1)
context_length = max(((context_length - 1) // f) + 1, 1)
context_overlap = max(((context_overlap - 1) // f) + 1, 0)
# build local schedules/fuse methods (not in core) as Context* objects
if context_schedule in LOCAL_SCHEDULES:
schedule = context_windows.ContextSchedule(context_schedule, LOCAL_SCHEDULES[context_schedule])
else:
schedule = context_windows.get_matching_context_schedule(context_schedule)
if fuse_method in LOCAL_FUSE:
fuse = context_windows.ContextFuseMethod(fuse_method, LOCAL_FUSE[fuse_method])
else:
fuse = context_windows.get_matching_fuse_method(fuse_method)
model = model.clone()
handler_kwargs = dict(
context_schedule=schedule,
fuse_method=fuse,
context_length=context_length,
context_overlap=context_overlap,
context_stride=context_stride,
closed_loop=closed_loop,
dim=dim,
freenoise=freenoise,
causal_window_fix=causal_window_fix,
cond_retain_index_list=cond_retain_index_list,
)
# drop kwargs this ComfyUI version's handler doesn't accept
sig = inspect.signature(context_windows.IndexListContextHandler.__init__)
handler_kwargs = {k: v for k, v in handler_kwargs.items() if k in sig.parameters}
handler = context_windows.IndexListContextHandler(**handler_kwargs)
model.model_options["context_handler"] = handler
# scope the vram estimate to the context window length
context_windows.create_prepare_sampling_wrapper(model)
if freenoise and hasattr(context_windows, "create_sampler_sample_wrapper"):
context_windows.create_sampler_sample_wrapper(model)
return io.NodeOutput(model)
+60 -66
View File
@@ -1,13 +1,14 @@
import torch
from torchvision import transforms
import json
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from PIL import Image, ImageDraw, ImageFont, ImageColor, ImageFilter, ImageChops
import numpy as np
from ..utility.utility import pil2tensor, tensor2pil
import folder_paths
import io
import base64
from io import BytesIO
from comfy.utils import common_upscale
def parse_color(color):
if isinstance(color, str) and ',' in color:
@@ -93,11 +94,7 @@ def plot_coordinates_to_tensor(coordinates, height, width, bbox_height, bbox_wid
color=color,
mutation_scale=20))
canvas.draw()
try:
image_np = np.frombuffer(canvas.tostring_rgb(), dtype='uint8').reshape(int(height), int(width), 3).copy()
except AttributeError:
image_np = np.frombuffer(canvas.tostring_argb(), dtype='uint8').reshape(int(height), int(width), 4)
image_np = image_np[:, :, [1, 2, 3]] # Convert ARGB to RGB
image_np = np.frombuffer(canvas.tostring_rgb(), dtype='uint8').reshape(int(height), int(width), 3).copy()
image_tensor = torch.from_numpy(image_np).float() / 255.0
image_tensor = image_tensor.unsqueeze(0)
image_batch.append(image_tensor)
@@ -141,15 +138,15 @@ Plots coordinates to sequence of images using Matplotlib.
plot_image_tensor = plot_coordinates_to_tensor(coordinates, height, width, bbox_height, bbox_width, size_multiplier, text)
return (plot_image_tensor, width, height, bbox_width, bbox_height)
class SplineEditor:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"points_store": ("STRING", {"multiline": False, "advanced": True}),
"coordinates": ("STRING", {"multiline": False, "advanced": True}),
"points_store": ("STRING", {"multiline": False}),
"coordinates": ("STRING", {"multiline": False}),
"mask_width": ("INT", {"default": 512, "min": 8, "max": 4096, "step": 8}),
"mask_height": ("INT", {"default": 512, "min": 8, "max": 4096, "step": 8}),
"points_to_sample": ("INT", {"default": 16, "min": 2, "max": 1000, "step": 1}),
@@ -164,7 +161,7 @@ class SplineEditor:
"default": 'time'
}),
"interpolation": (
[
[
'cardinal',
'monotone',
'basis',
@@ -173,7 +170,6 @@ class SplineEditor:
'step-after',
'polar',
'polar-reverse',
'bezier',
],
{
"default": 'cardinal'
@@ -181,7 +177,7 @@ class SplineEditor:
"tension": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"repeat_output": ("INT", {"default": 1, "min": 1, "max": 4096, "step": 1}),
"float_output_type": (
[
[
'list',
'pandas series',
'tensor',
@@ -259,7 +255,7 @@ output types:
# Handle nested list structure if present
all_normalized = []
all_normalized_y_values = []
# Check if we have a nested list structure
if isinstance(coordinates, list) and len(coordinates) > 0 and isinstance(coordinates[0], list):
# Process each list of coordinates in the nested structure
@@ -267,14 +263,12 @@ output types:
else:
# If not nested, treat as a single list of coordinates
coordinate_sets = [coordinates]
first_spline = coordinate_sets[0] if coordinate_sets else []
# Process each set of coordinates
for coord_set in coordinate_sets:
normalized = []
normalized_y_values = []
for coord in coord_set:
coord['x'] = int(round(coord['x']))
coord['y'] = int(round(coord['y']))
@@ -282,48 +276,48 @@ output types:
norm_y = (1.0 - (coord['y'] / mask_height) - 0.0) * (max_value - min_value) + min_value
normalized_y_values.append(norm_y)
normalized.append({'x':norm_x, 'y':norm_y})
all_normalized.extend(normalized)
all_normalized_y_values.extend(normalized_y_values)
# Use the combined normalized values for output
if float_output_type == 'list':
out_floats = all_normalized_y_values * repeat_output
elif float_output_type == 'pandas series':
try:
import pandas as pd
except ImportError as e:
raise ImportError("MaskOrImageToWeight: pandas is not installed. Please install pandas to use this output_type") from e
except:
raise Exception("MaskOrImageToWeight: pandas is not installed. Please install pandas to use this output_type")
out_floats = pd.Series(all_normalized_y_values * repeat_output),
elif float_output_type == 'tensor':
out_floats = torch.tensor(all_normalized_y_values * repeat_output, dtype=torch.float32)
# Create a color map for grayscale intensities
color_map = lambda y: torch.full((mask_height, mask_width, 3), y, dtype=torch.float32)
y_values_for_masks = normalized_y_values if normalized_y_values else [min_value]
mask_tensors = [color_map(y) for y in y_values_for_masks]
# Create image tensors for each normalized y value
mask_tensors = [color_map(y) for y in all_normalized_y_values]
masks_out = torch.stack(mask_tensors)
masks_out = masks_out.repeat(repeat_output, 1, 1, 1)
masks_out = masks_out.mean(dim=-1)
single_spline_count = len(first_spline)
if bg_image is None:
return (masks_out, json.dumps(coordinates if len(coordinates) > 1 else coordinates[0]), out_floats, single_spline_count, json.dumps(all_normalized))
return (masks_out, json.dumps(coordinates if len(coordinates) > 1 else coordinates[0]), out_floats, len(out_floats), json.dumps(all_normalized))
else:
transform = transforms.ToPILImage()
image = transform(bg_image[0].permute(2, 0, 1))
buffered = BytesIO()
buffered = io.BytesIO()
image.save(buffered, format="JPEG", quality=75)
# Encode the image bytes to a Base64 string
img_bytes = buffered.getvalue()
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
return {
"ui": {"bg_image": [img_base64]},
"result": (masks_out, json.dumps(coordinates if len(coordinates) > 1 else coordinates[0]), out_floats, single_spline_count, json.dumps(all_normalized))
"result": (masks_out, json.dumps(coordinates if len(coordinates) > 1 else coordinates[0]), out_floats, len(out_floats), json.dumps(all_normalized))
}
class CreateShapeMaskOnPath:
@@ -601,7 +595,7 @@ Locations are center locations.
# Draw the text
try:
draw.text(text_position, line, fill=color, font=current_font, features=['-liga'])
except Exception:
except:
draw.text(text_position, line, fill=color, font=current_font)
image = pil2tensor(image)
@@ -761,7 +755,7 @@ and returns that as the selected output type.
for image in images:
mean_values.append(image.mean().item())
elif masks is not None and images is not None:
raise ValueError("MaskOrImageToWeight: Use either mask or image input only.")
raise Exception("MaskOrImageToWeight: Use either mask or image input only.")
# Convert mean_values to the specified output_type
if output_type == 'list':
@@ -769,8 +763,8 @@ and returns that as the selected output type.
elif output_type == 'pandas series':
try:
import pandas as pd
except ImportError as e:
raise ImportError("MaskOrImageToWeight: pandas is not installed. Please install pandas to use this output_type") from e
except:
raise Exception("MaskOrImageToWeight: pandas is not installed. Please install pandas to use this output_type")
out = pd.Series(mean_values),
elif output_type == 'tensor':
out = torch.tensor(mean_values, dtype=torch.float32),
@@ -1405,13 +1399,13 @@ class PointsEditor:
def INPUT_TYPES(cls):
return {
"required": {
"points_store": ("STRING", {"multiline": False, "advanced": True}),
"coordinates": ("STRING", {"multiline": False, "socketless": True, "advanced": True}),
"neg_coordinates": ("STRING", {"multiline": False, "socketless": True, "advanced": True}),
"bbox_store": ("STRING", {"multiline": False, "advanced": True}),
"bboxes": ("STRING", {"multiline": False, "socketless": True, "advanced": True}),
"points_store": ("STRING", {"multiline": False}),
"coordinates": ("STRING", {"multiline": False}),
"neg_coordinates": ("STRING", {"multiline": False}),
"bbox_store": ("STRING", {"multiline": False}),
"bboxes": ("STRING", {"multiline": False}),
"bbox_format": (
[
[
'xyxy',
'xywh',
],
@@ -1430,32 +1424,29 @@ class PointsEditor:
FUNCTION = "pointdata"
CATEGORY = "KJNodes/experimental"
DESCRIPTION = """
# WORK IN PROGRESS
Do not count on this as part of your workflow yet,
probably contains lots of bugs and stability is not
guaranteed!!
# WORK IN PROGRESS
Do not count on this as part of your workflow yet,
probably contains lots of bugs and stability is not
guaranteed!!
## Graphical editor to create coordinates
**Shift + click** to add a positive (green) point.
**Shift + right click** to add a negative (red) point.
**Right click on a point** to delete it.
**Ctrl + click** to draw a bounding box.
**Drag bbox corners** to resize, **drag inside** to move.
**Right click on bbox** to delete it.
**Ctrl + click** to draw a box.
**Right click on a point** to delete it.
Note that you can't delete from start/end of the points array.
To add an image select the node and copy/paste or drag in the image.
Or from the bg_image input on queue (first frame of the batch).
To add an image select the node and copy/paste or drag in the image.
Or from the bg_image input on queue (first frame of the batch).
**THE IMAGE IS SAVED TO THE NODE AND WORKFLOW METADATA**
you can clear the image from the context menu by right clicking on the canvas
**THE IMAGE IS SAVED TO THE NODE AND WORKFLOW METADATA**
you can clear the image from the context menu by right clicking on the canvas
"""
def pointdata(self, points_store, bbox_store, width, height, coordinates, neg_coordinates, normalize, bboxes, bbox_format="xyxy", bg_image=None):
coordinates = json.loads(coordinates)
if not coordinates:
raise ValueError("No points on the canvas. Use Shift+click to add positive points or Shift+right-click to add negative points before executing.")
pos_coordinates = []
for coord in coordinates:
coord['x'] = int(round(coord['x']))
@@ -1483,6 +1474,7 @@ you can clear the image from the context menu by right clicking on the canvas
# Create a blank mask
mask = np.zeros((height, width), dtype=np.uint8)
bboxes = json.loads(bboxes)
print(bboxes)
valid_bboxes = []
for bbox in bboxes:
if (bbox.get("startX") is None or
@@ -1522,22 +1514,24 @@ you can clear the image from the context menu by right clicking on the canvas
if bg_image is not None and len(valid_bboxes) > 0:
x_min, y_min, x_max, y_max = bboxes[0]
cropped_image = bg_image[:, y_min:y_max, x_min:x_max, :]
elif bg_image is not None:
cropped_image = bg_image
else:
cropped_image = torch.zeros(1, height, width, 3, dtype=torch.float32)
if bg_image is None:
return (json.dumps(pos_coordinates), json.dumps(neg_coordinates), bboxes, mask_tensor, cropped_image)
return (json.dumps(pos_coordinates), json.dumps(neg_coordinates), bboxes, mask_tensor)
else:
transform = transforms.ToPILImage()
image = transform(bg_image[0].permute(2, 0, 1))
buffered = BytesIO()
buffered = io.BytesIO()
image.save(buffered, format="JPEG", quality=75)
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
# Step 3: Encode the image bytes to a Base64 string
img_bytes = buffered.getvalue()
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
return {
"ui": {"bg_image": [img_base64]},
"ui": {"bg_image": [img_base64]},
"result": (json.dumps(pos_coordinates), json.dumps(neg_coordinates), bboxes, mask_tensor, cropped_image)
}
-317
View File
@@ -1,317 +0,0 @@
"""FLUX.3 prompt builder.
Same visual editor as the Ideogram 4 builder (web/js/ideogram4_prompt_builder.js, spec in
web/js/flux3_prompt_builder.js). FLUX.3 reads layout boxes as part of the prompt: the prompt text, one
space, then a JSON list of rows with [y_min, x_min, y_max, x_max] on a 0-1000 grid. With reference images
each row says where its object is in a reference (src_bbox) and where it goes in the output (tgt_bbox).
The prompt upsampler keeps boxes only in its box mode (t2i_bbox / i2i_bbox system prompts) with the user
turn from _upsampler_input(); Flux3BoxReconcileKJ then re-imposes the user's boxes on its answer.
"""
import json
import math
import re
from comfy_api.latest import io
from .ideogram4_nodes import _render_preview, _parse_json_list, _norm_bbox, _seed_boxes, _bbox_outputs, _bg_image
MAX_REFS = 10
ID_RE = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
LOCKED_HEADER = ("LOCKED BOUNDING-BOX ANNOTATIONS ([y_min, x_min, y_max, x_max] on a 0-1000 canvas, with (0, 0) at "
"the top-left). This is an authoritative JSON list of dictionaries; preserve every supplied identity, "
"provenance value, null, and coordinate exactly:")
I2I_TURN = ("Reference images follow in order (the first image, the second image, ...).\n"
"User request: {instruction}\n"
"Output the caption.")
I2I_BBOX_TURN = ("Reference images follow in attachment order: ref_image_0, ref_image_1, ...\n"
"User editing instruction: {instruction}\n"
"Decide the edit, imagine the target image, and output the annotation record.")
def _upsampler_input(prompt, rows, has_refs):
# The upsampler's user turn: box mode when there are rows (pair with the t2i_bbox / i2i_bbox system prompt)
if rows:
text = f"ORIGINAL PROMPT:\n{prompt.strip()}\n\n{LOCKED_HEADER}\n{json.dumps(rows, ensure_ascii=False)}"
return I2I_BBOX_TURN.format(instruction=text) if has_refs else text
return I2I_TURN.format(instruction=prompt) if has_refs else prompt
def _ref_slots(references):
# {"ref_3": IMAGE, ...} -> connected slot numbers, sorted = the order the model receives the references
slots = []
for name, ref in (references or {}).items():
k = name.rsplit("_", 1)[-1]
if ref is not None and k.isdigit():
slots.append(int(k))
return sorted(slots)
def _rows(boxes, slots):
# Editor boxes (one per id and canvas; view 0 = output, view k = reference input ref_k) -> FLUX.3 rows.
# Mirrored by rows() in flux3_prompt_builder.js.
rank = {k: i for i, k in enumerate(slots)}
order, src, tgt = [], {}, {}
for b in boxes:
if not isinstance(b, dict) or b.get("nobbox"):
continue
view = int(b.get("view") or 0)
if view and view not in rank: # its reference is no longer connected
continue
rid = str(b.get("id") or "")
if rid not in src and rid not in tgt:
order.append(rid)
(src if view else tgt).setdefault(rid, b)
rows = []
for rid in order:
s, t = src.get(rid), tgt.get(rid)
desc = (t or {}).get("desc") or (s or {}).get("desc") or ""
if slots:
rows.append({"id": rid, "from": f"ref_image_{rank[int(s['view'])]}" if s else "target",
"src_bbox": _norm_bbox(s) if s else None, "tgt_bbox": _norm_bbox(t) if t else None, "desc": desc})
elif t:
rows.append({"id": rid, "bbox": _norm_bbox(t), "desc": desc})
return rows
def _split_caption(text):
# "prompt [rows]" -> (prompt, rows): the last "[" from which the rest parses as a JSON list
for i in range(len(text) - 1, -1, -1):
if text[i] != "[":
continue
try:
rows = json.loads(text[i:])
except json.JSONDecodeError:
continue
if isinstance(rows, list):
return text[:i].rstrip(), rows
return text.strip(), []
def _grid_box(bb):
# [y_min, x_min, y_max, x_max] on the 0-1000 grid -> editor {x, y, w, h} fractions, or None
if not isinstance(bb, (list, tuple)) or len(bb) != 4:
return None
try:
y1, x1, y2, x2 = (float(v) / 1000 for v in bb)
except (TypeError, ValueError):
return None
return {"x": min(x1, x2), "y": min(y1, y2), "w": abs(x2 - x1), "h": abs(y2 - y1)}
def _rows_to_boxes(rows):
# FLUX.3 rows -> editor boxes; ref_image_N goes to the canvas of input ref_{N+1}
boxes = []
for i, r in enumerate(rows):
if not isinstance(r, dict):
continue
base = {"id": str(r.get("id") or f"obj_{i + 1}"), "desc": str(r.get("desc") or "")}
origin = str(r.get("from") or "")
src = _grid_box(r.get("src_bbox"))
if src and origin.startswith("ref_image_") and origin[10:].isdigit():
boxes.append({**src, **base, "view": int(origin[10:]) + 1})
tgt = _grid_box(r["tgt_bbox"] if "tgt_bbox" in r else r.get("bbox"))
if tgt:
boxes.append({**tgt, **base, "view": 0})
return boxes
def _valid_box(box):
# A well-formed box on the 0-1000 grid, else None (all values <= 1 = a 0-1 box, not the grid)
if not isinstance(box, (list, tuple)) or len(box) != 4:
return None
if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in box):
return None
y1, x1, y2, x2 = box
if y1 < 0 or x1 < 0 or y2 <= y1 or x2 <= x1 or y2 > 1000 or x2 > 1000 or max(box) <= 1:
return None
return list(box)
def _iou(a, b):
inter = max(0, min(a[2], b[2]) - max(a[0], b[0])) * max(0, min(a[3], b[3]) - max(a[1], b[1]))
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
return inter / union if union > 0 else 0.0
def _row_box(row):
return row.get("bbox") or row.get("tgt_bbox") or row.get("src_bbox")
def _valid_added(row, caption):
# A row the upsampler added: kept only when well formed and cited in the caption as <id>
if not isinstance(row, dict):
return None
rid, desc = row.get("id"), row.get("desc")
if not isinstance(rid, str) or not ID_RE.fullmatch(rid) or f"<{rid}>" not in caption or not isinstance(desc, str) or not desc.strip():
return None
box = _valid_box(row.get("bbox"))
if box is not None:
return {"id": rid, "bbox": box, "desc": desc.strip()}
origin, src, tgt = row.get("from"), row.get("src_bbox"), row.get("tgt_bbox")
src_box = _valid_box(src) if src is not None else None
tgt_box = _valid_box(tgt) if tgt is not None else None
if (not isinstance(origin, str) or not re.fullmatch(r"ref_image_\d+|target", origin)
or (src is not None and src_box is None) or (tgt is not None and tgt_box is None)
or (src_box is None and tgt_box is None) or (origin == "target") == (src_box is not None)):
return None
return {"id": rid, "from": origin, "src_bbox": src_box, "tgt_bbox": tgt_box, "desc": desc.strip()}
def _reconcile(text, locked):
# The upsampler's answer with the user's rows re-imposed: a row re-planning a locked box under a new id
# (IoU >= 0.9, e.g. balloon_1 for obj_1) takes the locked id in the rows and the caption's <id> citations;
# locked rows are kept verbatim with the upsampler's desc, then its own valid rows follow.
caption, rows = _split_caption(text)
locked_ids = {r["id"] for r in locked}
row_ids = {r.get("id") for r in rows if isinstance(r, dict)}
renamed = {}
for lock in locked:
lock_box = _row_box(lock)
if lock["id"] in row_ids or lock_box is None:
continue
for row in rows:
if not isinstance(row, dict) or row.get("id") in renamed or row.get("id") in locked_ids:
continue
box = _valid_box(_row_box(row))
if box is not None and _iou(box, lock_box) >= 0.9:
renamed[row["id"]] = lock["id"]
break
for row in rows:
if isinstance(row, dict) and row.get("id") in renamed:
row["id"] = renamed[row["id"]]
for old, new in renamed.items():
caption = caption.replace(f"<{old}>", f"<{new}>")
by_id = {r.get("id"): r for r in rows if isinstance(r, dict) and isinstance(r.get("id"), str)}
out = []
for lock in locked:
desc = by_id.get(lock["id"], {}).get("desc")
out.append({**lock, "desc": desc.strip() if isinstance(desc, str) and desc.strip() else lock.get("desc", "")})
for row in rows:
added = _valid_added(row, caption)
if added is not None and added["id"] not in {r["id"] for r in out}:
out.append(added)
return f"{caption} {json.dumps(out, ensure_ascii=False)}"
class Flux3PromptBuilderKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="Flux3PromptBuilderKJ",
display_name="FLUX.3 Prompt Builder KJ",
category="KJNodes/text",
search_aliases=["flux3", "flux 3", "bbox", "layout", "prompt builder"],
is_experimental=True,
description="""
Visual layout builder for FLUX.3: boxes are appended to the prompt as a JSON table on a 0-1000 grid.
Draw boxes on the output canvas; give each an id and a description. Cite an id as <id> in the
prompt to tie words to its box.
With reference images (same order as the references given to the model, ref_1 first): each
reference gets its own canvas (toolbar view selector). A box drawn on a reference marks where an
object is in it; select it, switch to output and draw again to place that object there. A box only
on a reference = removed, only on the output = new.
Canvas controls are the same as in the Ideogram 4 builder; right-click lists the boxes of the
current canvas.""",
inputs=[
io.Int.Input("width", default=1024, min=64, max=16384, step=16,
tooltip="Output canvas width (the aspect the boxes are drawn on). Multiples of 16."),
io.Int.Input("height", default=1024, min=64, max=16384, step=16,
tooltip="Output canvas height (the aspect the boxes are drawn on). Multiples of 16."),
io.String.Input("prompt", multiline=True, default="",
tooltip="The prompt text; the box table is appended after it. Cite boxes as <id>."),
io.Image.Input("image", optional=True,
tooltip="Optional background for the output canvas (and behind the preview)."),
io.Autogrow.Input("references", template=io.Autogrow.TemplateNames(
io.Image.Input("image"), names=[f"ref_{i}" for i in range(1, MAX_REFS + 1)], min=0),
tooltip="The reference images, in the order the model gets them (ref_1 = ref_image_0). "
"Each gets its own canvas in the editor."),
io.String.Input("import_caption", default="", optional=True, force_input=True,
tooltip="Optional: a FLUX.3 caption (prompt + box table), e.g. the prompt upsampler's "
"output. When connected, it loads into the editor and drives the output per 'import_mode'."),
io.String.Input("elements_data", default="", socketless=True, advanced=True,
tooltip="Serialized boxes from the editor (managed by the node UI)."),
io.Int.Input("bg_brightness", default=25, min=0, max=100, socketless=True, advanced=True,
tooltip="Background image brightness % (managed by the node UI slider)."),
io.Combo.Input("import_mode", options=["when empty", "always"], default="when empty",
tooltip="How a wired import_caption is used: 'when empty' only seeds the editor while "
"it has no boxes (then the editor wins, so you can edit); 'always' makes "
"the wired caption authoritative."),
io.BoundingBox.Input("bboxes", optional=True, force_input=True,
tooltip="Optional pixel-space boxes ({x, y, width, height}) used to seed the output "
"canvas when the editor has no boxes."),
],
outputs=[
io.String.Output(display_name="prompt"),
io.Image.Output(display_name="preview"),
io.BoundingBox.Output(display_name="bboxes"),
io.Int.Output(display_name="width"),
io.Int.Output(display_name="height"),
io.String.Output(display_name="upsampler_input",
tooltip="The prompt upsampler's user turn. With boxes: box mode, use the t2i_bbox (or with "
"references i2i_bbox) system prompt, then FLUX.3 Box Reconcile KJ on its answer."),
],
)
@classmethod
def execute(cls, width, height, prompt="", elements_data="", import_caption="", import_mode="when empty",
bboxes=None, image=None, bg_brightness=25, references=None) -> io.NodeOutput:
if import_mode not in ("when empty", "always"):
import_mode = "when empty"
boxes = _parse_json_list(elements_data)
boxes_seeded = False
if not boxes and bboxes:
boxes = [{**b, "id": f"obj_{i + 1}", "view": 0} for i, b in enumerate(_seed_boxes(bboxes, width, height, {"desc": ""}))]
boxes_seeded = bool(boxes)
used_import = bool(import_caption and import_caption.strip()) and (import_mode == "always" or not boxes)
if used_import:
prompt, imported_rows = _split_caption(import_caption)
boxes = _rows_to_boxes(imported_rows)
slots = _ref_slots(references)
rows = _rows(boxes, slots)
caption = f"{prompt.rstrip()} {json.dumps(rows, ensure_ascii=False)}" if rows else prompt
out_boxes = [b for b in boxes if isinstance(b, dict) and not b.get("view")]
preview = _render_preview(out_boxes, width, height, _bg_image(image), bg_brightness)
ui = {"dims": [width, height]}
if boxes_seeded:
ui["boxes"] = [json.dumps(boxes)]
if used_import: # mirror the import in the editor (only when used)
ui["caption"] = [import_caption]
return io.NodeOutput(caption, preview, _bbox_outputs(out_boxes, width, height), width, height,
_upsampler_input(prompt, rows, bool(slots)), ui=ui)
class Flux3BoxReconcileKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="Flux3BoxReconcileKJ",
display_name="FLUX.3 Box Reconcile KJ",
category="KJNodes/text",
search_aliases=["flux3", "flux 3", "bbox", "upsampler", "reconcile"],
is_experimental=True,
description="Puts the user's boxes back into the prompt upsampler's box-mode answer: boxes it re-planned "
"under a new id get the original id back (also in the caption's <id> citations), the user's "
"rows keep their exact coordinates with the upsampler's descriptions, and the rows it added "
"follow when they are well formed and cited in the caption.",
inputs=[
io.String.Input("caption", force_input=True,
tooltip="The prompt upsampler's answer (TextGenerate text output)."),
io.String.Input("prompt", force_input=True,
tooltip="The FLUX.3 Prompt Builder's prompt output: its box table is the locked rows."),
],
outputs=[io.String.Output(display_name="prompt")],
)
@classmethod
def execute(cls, caption, prompt) -> io.NodeOutput:
locked = [r for r in _split_caption(prompt)[1] if isinstance(r, dict) and isinstance(r.get("id"), str)]
return io.NodeOutput(_reconcile(caption.strip(), locked) if locked else caption.strip())
-166
View File
@@ -1,166 +0,0 @@
import os
import random
import torch
from PIL import Image
from comfy_api.latest import io
import comfy.model_management as mm
import folder_paths
# LogC3 constants (ARRI LogC3, EI 800). Kept in sync with the WebGL shader in web/js/hdr_preview.js.
LC_A = 5.555556
LC_B = 0.052272
LC_C = 0.247190
LC_D = 0.385537
LC_E = 5.367655
LC_F = 0.092809
LC_CUT = 0.010591
LC_CUT_LOG = LC_E * LC_CUT + LC_F # ~0.14966
def _logc3_decompress(logc: torch.Tensor) -> torch.Tensor:
logc = logc.clamp(0.0, 1.0)
lin_from_log = (10.0 ** ((logc - LC_D) / LC_C) - LC_B) / LC_A
lin_from_lin = (logc - LC_F) / LC_E
return torch.where(logc >= LC_CUT_LOG, lin_from_log, lin_from_lin)
def _linear_to_srgb(x: torch.Tensor) -> torch.Tensor:
cutoff = 0.0031308
return torch.where(
x <= cutoff,
12.92 * x,
1.055 * torch.pow(x.clamp(min=cutoff), 1.0 / 2.4) - 0.055,
).clamp_(0.0, 1.0)
def _srgb_to_linear(x: torch.Tensor) -> torch.Tensor:
cutoff = 0.04045
return torch.where(
x <= cutoff,
x / 12.92,
((x.clamp(min=0.0) + 0.055) / 1.055) ** 2.4,
)
class HDRPreviewKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="HDRPreviewKJ",
display_name="HDR Preview KJ",
category="KJNodes/image",
is_output_node=True,
is_experimental=True,
description=(
"Realtime-exposure preview for HDR-compressed images.\n\n"
"Input: LogC3-compressed [0,1] image/video batch (e.g. the VAE-decoded output "
"of an HDR IC-LoRA workflow, prior to HDR decompression).\n\n"
"Decompression + exposure + saturation + Reinhard tonemap + sRGB runs in a WebGL "
"fragment shader in the browser for realtime slider feedback, and the same math "
"runs server-side to produce the baked sRGB IMAGE output. Slider changes update "
"the preview immediately; the IMAGE output only updates when the workflow is re-queued."
),
inputs=[
io.Image.Input("image",
tooltip="LogC3-compressed HDR image/video in [0,1], or linear HDR if 'input_space' is 'linear'."),
io.Float.Input("exposure", default=0.0, min=-10.0, max=10.0, step=0.01,
tooltip="Exposure in EV stops. 0 = no change, +1 = 2x brighter."),
io.Float.Input("saturation", default=1.0, min=0.0, max=2.0, step=0.01,
tooltip="Saturation multiplier. 0 = grayscale, 1 = unchanged, 2 = 2x."),
io.Float.Input("fps", default=24.0, min=1.0, max=120.0, step=0.1, optional=True,
tooltip="Playback frame rate for video (batch) inputs."),
io.Combo.Input("input_space", options=["logc3", "linear", "srgb"], default="logc3", optional=True,
tooltip="Color space of input. 'logc3' = ARRI LogC3 compressed HDR; 'linear' = linear HDR directly; 'srgb' = already-graded sRGB image (skips Reinhard tonemap)."),
],
outputs=[
io.Image.Output(display_name="image",
tooltip="Tonemapped sRGB image, ready for preview/save."),
],
)
@classmethod
def execute(cls, image: torch.Tensor, exposure: float = 0.0, saturation: float = 1.0, fps: float = 24.0, input_space: str = "logc3") -> io.NodeOutput:
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
prefix = f"hdrprv_{random.randint(0, 0xFFFFFF):06x}"
B, H, W, _ = image.shape
device = mm.get_torch_device()
exposure_mul = 2.0 ** exposure
luma_weights = torch.tensor([0.2126, 0.7152, 0.0722], device=device)
bytes_per_frame = H * W * 3 * 4
chunk_size = max(1, min(B, int(1_000_000_000 // max(bytes_per_frame * 10, 1))))
# For linear input we need the global max across all frames to normalize previews.
norm_scale = 1.0
if input_space == "linear":
max_val = float(image[..., :3].max().item())
norm_scale = max_val if max_val > 1.0 else 1.0
filenames = []
srgb_chunks = []
for start in range(0, B, chunk_size):
end = min(start + chunk_size, B)
image_rgb = image[start:end, ..., :3].float().to(device, non_blocking=True)
# --- Preview frames (8-bit PNG, always in the "raw" pre-exposure space) ---
if input_space == "linear":
preview = (image_rgb / norm_scale).clamp_(0.0, 1.0)
else:
preview = image_rgb.clamp(0.0, 1.0)
preview_np = preview.mul_(255.0).add_(0.5).clamp_(0.0, 255.0).to(torch.uint8).cpu().numpy()
del preview
for i in range(end - start):
fname = f"{prefix}_{start + i:05d}.png"
Image.fromarray(preview_np[i], mode="RGB").save(
os.path.join(temp_dir, fname),
format="PNG",
compress_level=1,
)
filenames.append(fname)
del preview_np
# --- Baked sRGB output (same math as the shader in hdr_preview.js) ---
if input_space == "logc3":
hdr = _logc3_decompress(image_rgb).clamp_(min=0.0)
elif input_space == "srgb":
hdr = _srgb_to_linear(image_rgb).clamp_(min=0.0)
else:
hdr = image_rgb.clamp(min=0.0)
del image_rgb
exposed = hdr.mul_(exposure_mul)
luma = (exposed * luma_weights.to(exposed.dtype)).sum(dim=-1, keepdim=True)
saturated = (luma + (exposed - luma) * saturation).clamp_(min=0.0)
del luma, exposed
if input_space == "srgb":
# Already display-ready linear; skip Reinhard, just clip over-exposed highlights.
tonemapped = saturated.clamp_(0.0, 1.0)
else:
tonemapped = saturated / (1.0 + saturated)
del saturated
srgb_chunks.append(_linear_to_srgb(tonemapped).cpu())
del tonemapped
srgb = torch.cat(srgb_chunks, dim=0)
del srgb_chunks
data = {
"frames": [{"filename": f, "type": "temp"} for f in filenames],
"width": int(W),
"height": int(H),
"fps": float(fps),
"input_space": input_space,
"linear_scale": float(norm_scale),
"frame_count": int(B),
"exposure": float(exposure),
"saturation": float(saturation),
}
return io.NodeOutput(srgb, ui={"hdr_preview_data": [data]})
-462
View File
@@ -1,462 +0,0 @@
"""Ideogram 4 prompt builder.
A single self-contained node with a visual bbox editor: draw regions on a blank
canvas, set each region's type/desc/text/color palette, and assemble the Ideogram 4 JSON caption prompt.
"""
import json
import os
import re
import logging
import numpy as np
import torch
from PIL import Image, ImageDraw, ImageFont, ImageEnhance
from comfy_api.latest import io
_FONT_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts", "FreeMono.ttf")
def _hex_rgb(h):
h = h.lstrip("#")
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) if len(h) == 6 else (255, 255, 255)
def _readable(rgb):
# Lighten toward white if too dark, so box-colored text stays legible on the dark canvas.
r, g, b = rgb
lum = 0.299 * r + 0.587 * g + 0.114 * b
if lum < 130:
t = (130 - lum) / (255 - lum)
r, g, b = round(r + (255 - r) * t), round(g + (255 - g) * t), round(b + (255 - b) * t)
return (r, g, b)
def _font(size):
try:
return ImageFont.truetype(_FONT_PATH, size)
except Exception:
try:
return ImageFont.load_default(size)
except Exception:
return ImageFont.load_default()
def _wrap(draw, text, font, max_w):
lines = []
for para in text.split("\n"):
line = ""
for word in para.split():
test = word if not line else line + " " + word
if line and draw.textlength(test, font=font) > max_w:
lines.append(line)
line = word
else:
line = test
lines.append(line)
return lines
def _render_preview(boxes, width, height, bg=None, brightness=50, refs=None):
# Render the regions + prompts over the reference image (or a black canvas).
# refs: optional (IMAGE, MASK) at canvas size, pasted un-dimmed where the mask is set (per-box reference images).
if bg is not None:
iw, ih = bg.size
long_edge = max(iw, ih)
scale = min(1.0, 1024 / long_edge) if long_edge > 0 else 1.0
rw, rh = max(1, round(iw * scale)), max(1, round(ih * scale))
base = bg.convert("RGB").resize((rw, rh), Image.LANCZOS)
if brightness < 100: # dim to match the editor's brightness slider
base = ImageEnhance.Brightness(base).enhance(max(0.0, brightness / 100.0))
img = base.convert("RGBA")
else:
long_edge = max(width, height)
scale = min(1.0, 1024 / long_edge) if long_edge > 0 else 1.0
rw = max(1, round(width * scale))
rh = max(1, round(height * scale))
img = Image.new("RGBA", (rw, rh), (0, 0, 0, 255)) # black so the overlay composites cleanly
if refs is not None:
rimg = Image.fromarray((refs[0][0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8)).resize((rw, rh), Image.LANCZOS)
rmask = Image.fromarray((refs[1][0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8), "L").resize((rw, rh), Image.BILINEAR)
img = Image.composite(rimg.convert("RGBA"), img, rmask)
overlay = Image.new("RGBA", (rw, rh), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
fs = max(10, round(rh / 64))
font = _font(fs)
tag_font = _font(max(9, fs - 2))
lh = fs + 2
for i, box in enumerate(boxes):
if not isinstance(box, dict) or box.get("nobbox"):
continue # skip unplaced elements (no real location)
palette = [c for c in (box.get("palette") or []) if c]
r, g, b = _hex_rgb(palette[0]) if palette else (140, 140, 140) # box = first palette color, else grey
x1 = max(0, min(rw, round(box.get("x", 0) * rw)))
y1 = max(0, min(rh, round(box.get("y", 0) * rh)))
x2 = max(0, min(rw, round((box.get("x", 0) + box.get("w", 0)) * rw)))
y2 = max(0, min(rh, round((box.get("y", 0) + box.get("h", 0)) * rh)))
if x2 < x1:
x1, x2 = x2, x1
if y2 < y1:
y1, y2 = y2, y1
draw.rectangle([x1, y1, x2, y2], outline=(r, g, b, 255), width=2)
pal5 = palette[:5] # palette shown as a strip along the top edge
if pal5 and (x2 - x1) > 2:
sh = max(5, fs // 2)
seg = (x2 - x1) / len(pal5)
for p, hexc in enumerate(pal5):
sx = x1 + round(p * seg)
draw.rectangle([sx, y1, x1 + round((p + 1) * seg), y1 + sh], fill=_hex_rgb(hexc))
etype = "text" if box.get("type") == "text" else "obj"
tag = str(i + 1).zfill(2)
tw = draw.textlength(tag, font=tag_font)
draw.rectangle([x1, y1, x1 + tw + 6, y1 + fs + 2], fill=(r, g, b, 255)) # tag chip = box color
tagfill = (0, 0, 0, 255) if (0.299 * r + 0.587 * g + 0.114 * b) > 140 else (255, 255, 255, 255)
draw.text((x1 + 3, y1 + 1), tag, fill=tagfill, font=tag_font)
body = box.get("desc", "") or ""
if etype == "text" and box.get("text"):
body = '"%s"%s' % (box["text"], " — " + body if body else "")
if body and (x2 - x1) > 8:
ty = y1 + fs + 5
for line in _wrap(draw, body, font, x2 - x1 - 8):
if ty > y2:
break
draw.text((x1 + 4, ty), line, fill=_readable((r, g, b)) + (255,), font=font)
ty += lh
img = Image.alpha_composite(img, overlay).convert("RGB")
arr = np.asarray(img, dtype=np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def _norm_bbox(box, sx=1000, sy=1000, order="yx"):
# {x, y, w, h} fractions (0-1) -> bbox; x scaled by sx, y by sy.
# Default sx=sy=1000 is the Ideogram-standard 0-1000 grid; pass (width, height) for absolute pixels.
# order="yx" -> [ymin, xmin, ymax, xmax] (Ideogram); "xy" -> [xmin, ymin, xmax, ymax] (Qwen/standard).
def cx(v):
return max(0, min(sx, round(v * sx)))
def cy(v):
return max(0, min(sy, round(v * sy)))
x, y, w, h = box.get("x", 0.0), box.get("y", 0.0), box.get("w", 0.0), box.get("h", 0.0)
ymin, xmin, ymax, xmax = cy(y), cx(x), cy(y + h), cx(x + w)
if ymin > ymax:
ymin, ymax = ymax, ymin
if xmin > xmax:
xmin, xmax = xmax, xmin
return [xmin, ymin, xmax, ymax] if order == "xy" else [ymin, xmin, ymax, xmax]
def _palette(colors):
# ["#rrggbb", ...] (or autogrow dict) -> ["#RRGGBB", ...] in order, dropping empties.
if isinstance(colors, dict):
colors = colors.values()
return [c.upper() for c in colors if c]
def _dumps(v, lvl=0, ind=" "):
# Like json.dumps(ensure_ascii=False, indent=ind), but scalar arrays stay on one line.
pad, end = ind * (lvl + 1), ind * lvl
if isinstance(v, str):
return json.dumps(v, ensure_ascii=False)
if isinstance(v, list):
if not v:
return "[]"
if all(not isinstance(x, (dict, list)) for x in v):
return "[" + ", ".join(_dumps(x, lvl, ind) for x in v) + "]"
return "[\n" + ",\n".join(pad + _dumps(x, lvl + 1, ind) for x in v) + "\n" + end + "]"
if isinstance(v, dict):
if not v:
return "{}"
items = [pad + json.dumps(k, ensure_ascii=False) + ": " + _dumps(val, lvl + 1, ind) for k, val in v.items()]
return "{\n" + ",\n".join(items) + "\n" + end + "}"
return json.dumps(v, ensure_ascii=False)
def _parse_json_list(s):
if s:
try:
v = json.loads(s)
if isinstance(v, list):
return v
except json.JSONDecodeError:
pass
return []
def _repair_json(s):
# Slice out the outermost {...} (drops ``` fences / prose), then strip trailing commas before
# } or ] — the leading "(...)" alt matches whole strings first, so quoted commas are untouched.
t = s.strip()
if t.startswith('"'): # bare "key": ... list (outer braces lost in a copy)
bare = re.sub(r'"(?:[^"\\]|\\.)*"', "", t)
t = "{" + t + ("}" if bare.count("{") >= bare.count("}") else "")
i, j = t.find("{"), t.rfind("}")
t = t[i:j + 1] if (i != -1 and j > i) else t
return re.sub(r'("(?:[^"\\]|\\.)*")|,(\s*[}\]])', lambda m: m.group(1) or m.group(2), t)
def _loads_caption(s, tag="Ideogram4PromptBuilderKJ"):
# Parse a caption dict; on failure retry once with the lenient repair. Returns dict or None.
for cand in ((s, _repair_json(s)) if s and s.strip() else ()):
try:
v = json.loads(cand)
if isinstance(v, dict):
if cand is not s:
logging.warning(f"[{tag}] import_json had errors; recovered with lenient parse")
return v
except (json.JSONDecodeError, TypeError):
continue
return None
def _seed_boxes(bboxes, width, height, blank):
# BoundingBox input (pixel {x, y, width, height}) -> editor boxes carrying the model's blank fields.
if isinstance(bboxes, dict): # a single BoundingBox is a bare {x,y,width,height} dict
frame = [bboxes]
elif bboxes and isinstance(bboxes[0], (list, tuple)):
frame = bboxes[0] # per-frame nesting: [[box, ...], ...]
else:
frame = bboxes or [] # flat list of boxes
return [{"x": bb.get("x", 0) / width, "y": bb.get("y", 0) / height,
"w": bb.get("width", 0) / width, "h": bb.get("height", 0) / height, **blank}
for bb in frame if isinstance(bb, dict)]
def _bbox_outputs(boxes, width, height):
# Pixel-space bboxes ({x, y, width, height}) for SAM3 / BoundingBox consumers, in the canonical
# per-frame nesting (list[list[dict]]) that SAM3 / crop nodes expect (bboxes[frame] -> list of boxes).
out = []
for box in boxes:
if not isinstance(box, dict) or box.get("nobbox"):
continue
x, y = box.get("x", 0.0), box.get("y", 0.0)
bw, bh = box.get("w", 0.0), box.get("h", 0.0)
if bw < 0:
x += bw
bw = -bw
if bh < 0:
y += bh
bh = -bh
out.append({"x": round(x * width), "y": round(y * height),
"width": round(bw * width), "height": round(bh * height)})
return [out] if out else []
def _bg_image(image):
# First frame of the optional reference image as PIL (preview background), else None.
if image is None:
return None
try:
return Image.fromarray((image[0].detach().cpu().numpy() * 255).clip(0, 255).astype(np.uint8))
except Exception:
return None
def _caption_to_boxes(cap):
# Caption dict -> editor box list ({x,y,w,h, type, text, desc, palette}) for preview/bboxes.
cd = cap.get("compositional_deconstruction") or {}
boxes = []
for el in (cd.get("elements") or []):
if not isinstance(el, dict):
continue
box = {"type": "text" if el.get("type") == "text" else "obj",
"text": el.get("text", "") or "", "desc": el.get("desc", "") or "",
"palette": list(el.get("color_palette") or [])}
bb = el.get("bbox")
if isinstance(bb, (list, tuple)) and len(bb) == 4:
ymin, xmin, ymax, xmax = bb
box.update(x=xmin / 1000.0, y=ymin / 1000.0,
w=(xmax - xmin) / 1000.0, h=(ymax - ymin) / 1000.0)
else: # no bbox: unplaced placeholder
box.update(x=0.03, y=0.03, w=0.22, h=0.14, nobbox=True)
boxes.append(box)
return boxes
class Ideogram4PromptBuilderKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="Ideogram4PromptBuilderKJ",
display_name="Ideogram 4 Prompt Builder KJ",
category="KJNodes/text",
search_aliases=["ideogram", "caption", "bbox", "prompt builder", "json prompt"],
is_experimental=True,
description="""
Visual prompt builder for Ideogram 4's structured JSON caption format.
Drag on the canvas to draw regions; select a region to set its type (obj/text),
description, text, and color palette. Set the background and optional style fields
as widgets. Outputs the assembled caption JSON string.
bbox is normalized to a 0-1000 grid as [ymin, xmin, ymax, xmax]; width/height set
the canvas aspect ratio.
Canvas controls:
- Drag: draw a new region
- Ctrl/Cmd-drag: force-draw a new region even on top of an existing one
- Click: select a region · Alt-click: cycle overlapping regions
- Double-click: edit the description inline
- Right-click: region list (select / delete / duplicate / reorder, top = front)
- Del / Backspace: remove the selected region
- Ctrl/Cmd + C / V / D: copy / paste / duplicate the selected region
- bbox fields (px / out) next to obj/text are editable
Color swatches:
- Click: edit · Drag: reorder · Right-click: remove
- Hover + Ctrl/Cmd + C / V: copy / paste the hex
- "+": add a color (uses the clipboard color if it is one)
Toolbar:
- Live: use the live sampling preview as the background (and grab the final result)
- Grab BG / Clear BG: use the last generated image as the background
- brightness slider, token estimate, and Copy / Paste / Clear all""",
inputs=[
io.Int.Input("width", default=1024, min=64, max=16384, step=16,
tooltip="Canvas aspect width (also the pixel grid the bbox is measured in). Ideogram 4 needs multiples of 16."),
io.Int.Input("height", default=1024, min=64, max=16384, step=16,
tooltip="Canvas aspect height (also the pixel grid the bbox is measured in). Ideogram 4 needs multiples of 16."),
io.String.Input("high_level_description", multiline=True, default="",
tooltip="Optional one-line overview of the whole image (blank = omitted)."),
io.String.Input("background", multiline=True, default="",
tooltip="Required scene background description."),
io.DynamicCombo.Input("style", options=[
io.DynamicCombo.Option("none", []),
io.DynamicCombo.Option("photo", [
io.String.Input("photo", default=""),
]),
io.DynamicCombo.Option("art_style", [
io.String.Input("art_style", default=""),
]),
]),
io.String.Input("aesthetics", default="", tooltip="Style descriptor (blank = omitted)."),
io.String.Input("lighting", default="", tooltip="Style descriptor (blank = omitted)."),
io.String.Input("medium", default="", tooltip="Style descriptor (blank = omitted)."),
io.Image.Input("image", optional=True,
tooltip="Optional reference image shown as the editor background (and behind the preview)."),
io.String.Input("import_json", default="", optional=True, force_input=True,
tooltip="Optional: a full caption JSON. When connected, it loads into the editor "
"and drives the output per 'import_mode'."),
io.String.Input("style_palette_data", default="", socketless=True, advanced=True,
tooltip="Serialized style color palette from the editor (managed by the node UI)."),
io.String.Input("elements_data", default="", socketless=True, advanced=True,
tooltip="Serialized regions from the editor (managed by the node UI)."),
io.Int.Input("bg_brightness", default=25, min=0, max=100, socketless=True, advanced=True,
tooltip="Background image brightness % (managed by the node UI slider)."),
io.Combo.Input("import_mode", options=["when empty", "always"], default="when empty",
tooltip="How a wired import_json is used: 'when empty' only seeds the editor while "
"it has no regions (then the editor wins, so you can edit); 'always' makes "
"the wired JSON authoritative so its changes always propagate to the output."),
io.String.Input("output_format", default="compact", socketless=True, advanced=True,
tooltip="Output JSON formatting (set via the editor toolbar): 'compact' (default, what "
"Ideogram 4 expects) or 'pretty' (indented, for readability)."),
io.String.Input("coord_mode", default="normalized", socketless=True, advanced=True,
tooltip="bbox coordinate space (set via the editor toolbar): 'normalized' (default, the "
"0-1000 grid Ideogram 4 expects) or 'absolute' (pixels, scaled by width/height "
"— NON-STANDARD for Ideogram, for other tools that want pixel coords)."),
io.String.Input("bbox_order", default="yx", socketless=True, advanced=True,
tooltip="bbox axis order (set via the editor toolbar): 'yx' (default, Ideogram's "
"[ymin,xmin,ymax,xmax]) or 'xy' ([xmin,ymin,xmax,ymax], the standard x1,y1,x2,y2 "
"that Qwen-VL and most detectors use)."),
io.BoundingBox.Input("bboxes", optional=True, force_input=True,
tooltip="Optional pixel-space boxes ({x, y, width, height}) used to seed the "
"editor's regions when it has none. Ignored once regions exist."),
],
outputs=[
io.String.Output(display_name="prompt"),
io.Image.Output(display_name="preview"),
io.BoundingBox.Output(display_name="bboxes"),
io.Int.Output(display_name="width"),
io.Int.Output(display_name="height"),
],
)
@classmethod
def execute(cls, width, height, background, style,
high_level_description="", aesthetics="", lighting="", medium="",
style_palette_data="", elements_data="", import_json="", import_mode="when empty",
output_format="pretty", coord_mode="normalized", bbox_order="yx", bboxes=None, image=None, bg_brightness=25) -> io.NodeOutput:
if import_mode not in ("when empty", "always"): # old workflows saved before this widget existed
import_mode = "when empty"
dump = _dumps if output_format == "pretty" else (lambda v: json.dumps(v, ensure_ascii=False, separators=(",", ":")))
# bbox scale: 0-1000 grid (Ideogram-standard) or absolute pixels (width/height) when coord_mode=="absolute".
# bbox axis order: "yx" (Ideogram) or "xy" (Qwen/standard x1,y1,x2,y2).
bsx, bsy = (width, height) if coord_mode == "absolute" else (1000, 1000)
border = "xy" if bbox_order == "xy" else "yx"
boxes = _parse_json_list(elements_data)
boxes_seeded = False
if not boxes and bboxes:
boxes = _seed_boxes(bboxes, width, height, {"type": "obj", "text": "", "desc": "", "palette": []})
boxes_seeded = bool(boxes)
imported = _loads_caption(import_json) # strict parse, then a lenient repair fallback
kind = style["style"] # "none" | "photo" | "art_style"
# Use the wired import_json directly per import_mode: "always" -> authoritative (its changes
# always propagate); "when empty" -> only seed the editor while it has no regions, then the
# editor wins so manual edits stick. The editor mirrors it via ui when used.
used_import = imported is not None and (import_mode == "always" or not boxes)
if used_import:
caption = imported
boxes = _caption_to_boxes(imported)
else:
caption = {}
if high_level_description.strip():
caption["high_level_description"] = high_level_description
if kind != "none":
# The verifier requires every style key present (in order) once a style is
# chosen; only color_palette is conditional. Emit blanks rather than omit.
sd = {"aesthetics": aesthetics, "lighting": lighting}
# photo: ...photo, medium... | art_style: ...medium, art_style... (key order)
if kind == "photo":
sd["photo"] = style.get("photo", "")
sd["medium"] = medium
else:
sd["medium"] = medium
sd["art_style"] = style.get("art_style", "")
palette = _palette(_parse_json_list(style_palette_data))
if palette:
sd["color_palette"] = palette
caption["style_description"] = sd
elements = []
for box in boxes:
if not isinstance(box, dict):
continue
etype = "text" if box.get("type") == "text" else "obj"
elem = {"type": etype} # key order matters
if not box.get("nobbox"): # unplaced elements omit bbox
elem["bbox"] = _norm_bbox(box, bsx, bsy, border)
if etype == "text":
elem["text"] = box.get("text", "")
elem["desc"] = box.get("desc", "")
palette = _palette(box.get("palette", []))
if palette:
elem["color_palette"] = palette[:5]
elements.append(elem)
caption["compositional_deconstruction"] = {
"background": background,
"elements": elements,
}
# composite over the input image, else black
preview = _render_preview(boxes, width, height, _bg_image(image), bg_brightness)
# ui: send the resolved width/height so the editor canvas can follow connected
# inputs; import_json (if wired) loads into the editor (output reflects editor only).
ui = {"dims": [width, height]}
if boxes_seeded:
ui["boxes"] = [json.dumps(boxes)]
if used_import: # mirror the import in the editor (only when used)
ui["caption"] = [_dumps(imported)]
return io.NodeOutput(dump(caption), preview, _bbox_outputs(boxes, width, height), width, height, ui=ui)
+546 -1913
View File
File diff suppressed because it is too large Load Diff
-542
View File
@@ -1,542 +0,0 @@
import numpy as np
import torch
import random
import math
import os
import json
from PIL import Image
from comfy.utils import common_upscale
from comfy_api.latest import io
import folder_paths
from nodes import MAX_RESOLUTION
from ..utility.utility import string_to_color, normalize_bboxes, bbox_to_bounding_box
def _upscale_mask(mask, width, height, method, crop):
if method == "lanczos":
return common_upscale(mask.unsqueeze(1).repeat(1, 3, 1, 1), width, height, method, crop).movedim(1, -1)[:, :, :, 0]
return common_upscale(mask.unsqueeze(1), width, height, method, crop).squeeze(1)
def _resize_single_channel(tensor, width, height):
"""Resize a 3D (B,H,W) tensor using bilinear interpolation."""
return common_upscale(tensor.unsqueeze(1), width, height, "bilinear", "disabled").squeeze(1)
def _pad_inputs():
"""Shared pad_top/bottom/left/right input definitions for extra_padding options."""
return [
io.Int.Input("pad_top", default=0, min=0, max=MAX_RESOLUTION, step=1, tooltip="Padding pixels on top."),
io.Int.Input("pad_bottom", default=0, min=0, max=MAX_RESOLUTION, step=1, tooltip="Padding pixels on bottom."),
io.Int.Input("pad_left", default=0, min=0, max=MAX_RESOLUTION, step=1, tooltip="Padding pixels on left."),
io.Int.Input("pad_right", default=0, min=0, max=MAX_RESOLUTION, step=1, tooltip="Padding pixels on right."),
]
def _apply_padding(tensor, pad_top, pad_bottom, pad_left, pad_right, mode, edge_mode="clamp", fill_rgb=None):
"""Apply padding to a BHWC tensor. Returns the padded tensor.
mode: 'color' or 'edge'
edge_mode: 'clamp', 'repeat', 'mirror' (only used when mode='edge')
fill_rgb: list of [r, g, b] float values 0-1 (only used when mode='color')
"""
h, w = tensor.shape[1], tensor.shape[2]
new_h = h + pad_top + pad_bottom
new_w = w + pad_left + pad_right
if mode == "color":
fill = fill_rgb or [0.0, 0.0, 0.0]
padded = torch.zeros(tensor.shape[0], new_h, new_w, tensor.shape[3], device=tensor.device, dtype=tensor.dtype)
for c in range(min(3, tensor.shape[3])):
padded[:, :, :, c] = fill[c]
padded[:, pad_top:pad_top+h, pad_left:pad_left+w, :] = tensor
return padded
# mode == "edge"
if edge_mode == "clamp":
padded = torch.zeros(tensor.shape[0], new_h, new_w, tensor.shape[3], device=tensor.device, dtype=tensor.dtype)
padded[:, pad_top:pad_top+h, pad_left:pad_left+w, :] = tensor
if pad_top > 0:
padded[:, :pad_top, pad_left:pad_left+w, :] = tensor[:, 0:1, :, :].expand(-1, pad_top, -1, -1)
if pad_bottom > 0:
padded[:, pad_top+h:, pad_left:pad_left+w, :] = tensor[:, -1:, :, :].expand(-1, pad_bottom, -1, -1)
if pad_left > 0:
padded[:, :, :pad_left, :] = padded[:, :, pad_left:pad_left+1, :].expand(-1, -1, pad_left, -1)
if pad_right > 0:
padded[:, :, pad_left+w:, :] = padded[:, :, pad_left+w-1:pad_left+w, :].expand(-1, -1, pad_right, -1)
return padded
elif edge_mode == "repeat":
tiles_x = (new_w + w - 1) // w + 1
tiles_y = (new_h + h - 1) // h + 1
tiled = tensor.repeat(1, tiles_y, tiles_x, 1)
# Offset so original content lands at (pad_top, pad_left) in output
off_x = (w - pad_left % w) % w
off_y = (h - pad_top % h) % h
return tiled[:, off_y:off_y+new_h, off_x:off_x+new_w, :]
elif edge_mode == "mirror":
flipped_h = tensor.flip(2)
flipped_v = tensor.flip(1)
flipped_hv = tensor.flip(1).flip(2)
mirror_block = torch.cat([
torch.cat([tensor, flipped_h], dim=2),
torch.cat([flipped_v, flipped_hv], dim=2),
], dim=1)
mb_h, mb_w = mirror_block.shape[1], mirror_block.shape[2]
tiles_x = (new_w + mb_w - 1) // mb_w + 1
tiles_y = (new_h + mb_h - 1) // mb_h + 1
tiled = mirror_block.repeat(1, tiles_y, tiles_x, 1)
# Offset so original content lands at (pad_top, pad_left) in output
off_x = (mb_w - pad_left % mb_w) % mb_w
off_y = (mb_h - pad_top % mb_h) % mb_h
return tiled[:, off_y:off_y+new_h, off_x:off_x+new_w, :]
return tensor
class ImageTransformKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ImageTransformKJ",
display_name="Image Transform KJ",
category="KJNodes/image",
search_aliases=["resize", "crop", "pad", "upscale", "keep proportion", "bbox", "bounding box", "transform", "rotate", "mirror"],
is_experimental=True,
description="""
Interactive image transform node: crop, resize, pad, and rotate.
Connect an image input — the preview appears automatically.
Cropping:
Click + drag to draw a crop region.
Drag inside to move, drag edges/corners to resize.
Right-click to delete a region.
Ctrl to snap to grid.
Shift + resize to constrain aspect ratio.
Alt + resize to resize symmetrically.
Padding:
Shift + drag to adjust padding position.
Rotate button enables rotation cross (drag to rotate, right-click to reset).
Set target_width/height to resize output (0 = keep original).
Use keep_proportion to control how the image fits the target.
Use extra_padding to add padding with color or edge fill (clamp/repeat/mirror).""",
inputs=[
io.MatchType.Input("image", io.MatchType.Template("img_or_mask", [io.Image, io.Mask]), tooltip="The image or mask to transform."),
io.Mask.Input("mask", optional=True, tooltip="Optional mask to transform alongside the image."),
io.Int.Input("target_width", default=0, min=0, max=MAX_RESOLUTION, step=1, tooltip="Target output width. 0 = keep original dimensions."),
io.Int.Input("target_height", default=0, min=0, max=MAX_RESOLUTION, step=1, tooltip="Target output height. 0 = keep original dimensions."),
io.Combo.Input("upscale_method", options=["nearest-exact", "bilinear", "area", "bicubic", "lanczos"], default="lanczos", tooltip="Interpolation method for resizing."),
io.DynamicCombo.Input("keep_proportion", options=[
io.DynamicCombo.Option(key="keep_long_edge", inputs=[]),
io.DynamicCombo.Option(key="keep_short_edge", inputs=[]),
io.DynamicCombo.Option(key="total_pixels", inputs=[]),
io.DynamicCombo.Option(key="stretch", inputs=[]),
io.DynamicCombo.Option(key="crop", inputs=[]),
io.DynamicCombo.Option(key="pad_color", inputs=[
io.Float.Input("pad_x", default=0.5, min=0.0, max=1.0, step=0.01,
tooltip="Horizontal position of content within padding (0=left, 0.5=center, 1=right). Shift+drag content in preview to adjust."),
io.Float.Input("pad_y", default=0.5, min=0.0, max=1.0, step=0.01,
tooltip="Vertical position of content within padding (0=top, 0.5=center, 1=bottom). Shift+drag content in preview to adjust."),
]),
io.DynamicCombo.Option(key="pad_edge", inputs=[
io.Combo.Input("edge_mode", options=["clamp", "repeat", "mirror"], default="clamp",
tooltip="clamp: extend edge pixels. repeat: tile the image. mirror: tile with mirroring."),
io.Float.Input("pad_x", default=0.5, min=0.0, max=1.0, step=0.01,
tooltip="Horizontal position of content within padding (0=left, 0.5=center, 1=right). Shift+drag content in preview to adjust."),
io.Float.Input("pad_y", default=0.5, min=0.0, max=1.0, step=0.01,
tooltip="Vertical position of content within padding (0=top, 0.5=center, 1=bottom). Shift+drag content in preview to adjust."),
]),
io.DynamicCombo.Option(key="multiplier", inputs=[
io.Float.Input("width_mult", default=1.0, min=0.01, max=16.0, step=0.05,
tooltip="Multiply the crop width by this factor."),
io.Float.Input("height_mult", default=1.0, min=0.01, max=16.0, step=0.05,
tooltip="Multiply the crop height by this factor."),
]),
]),
io.Int.Input("divisible_by", default=2, min=0, max=512, step=1),
io.DynamicCombo.Input("extra_padding", options=[
io.DynamicCombo.Option(key="disabled", inputs=[]),
io.DynamicCombo.Option(key="pad_color", inputs=_pad_inputs()),
io.DynamicCombo.Option(key="pad_edge", inputs=_pad_inputs() + [
io.Combo.Input("edge_mode", options=["clamp", "repeat", "mirror"], default="clamp",
tooltip="clamp: extend edge pixels. repeat: tile the image. mirror: tile with mirroring."),
]),
io.DynamicCombo.Option(key="pad_crop_color", inputs=_pad_inputs()),
io.DynamicCombo.Option(key="pad_crop_edge", inputs=_pad_inputs() + [
io.Combo.Input("edge_mode", options=["clamp", "repeat", "mirror"], default="clamp",
tooltip="clamp: extend edge pixels. repeat: tile the image. mirror: tile with mirroring."),
]),
]),
io.DynamicCombo.Input("invert_crop", options=[
io.DynamicCombo.Option(key="disabled", inputs=[]),
io.DynamicCombo.Option(key="enabled", inputs=[]),
]),
io.String.Input("bboxes", default="", socketless=True, advanced=True),
],
outputs=[
io.MatchType.Output(io.MatchType.Template("img_or_mask", [io.Image, io.Mask]), id="cropped", display_name="output", is_output_list=True),
io.Mask.Output("cropped_mask", display_name="output_mask", is_output_list=True),
io.BBOX.Output("bbox", display_name="bbox", is_output_list=True),
io.Mask.Output("bbox_mask", display_name="bbox_mask", is_output_list=True),
io.Int.Output("width", display_name="width", tooltip="Width of the output image."),
io.Int.Output("height", display_name="height", tooltip="Height of the output image."),
],
)
@classmethod
def execute(cls, image, target_width, target_height, upscale_method, keep_proportion, divisible_by,
extra_padding, invert_crop, bboxes, mask=None):
# Unpack DynamicCombos
edge_mode = keep_proportion.get("edge_mode", "clamp")
pad_x = keep_proportion.get("pad_x", 0.5)
pad_y = keep_proportion.get("pad_y", 0.5)
width_mult = keep_proportion.get("width_mult", 1.0)
height_mult = keep_proportion.get("height_mult", 1.0)
keep_proportion = keep_proportion["keep_proportion"]
extra_top = extra_padding.get("pad_top", 0)
extra_bottom = extra_padding.get("pad_bottom", 0)
extra_left = extra_padding.get("pad_left", 0)
extra_right = extra_padding.get("pad_right", 0)
extra_edge_mode = extra_padding.get("edge_mode", "clamp")
extra_pad_mode = extra_padding.get("extra_padding", "disabled")
invert_crop = invert_crop["invert_crop"]
# Parse fill color from bboxes JSON (shared color picker)
fill_color_rgb = [0, 0, 0]
if bboxes:
try:
_parsed_tmp = json.loads(bboxes)
if isinstance(_parsed_tmp, dict) and "fillColor" in _parsed_tmp:
fill_color_rgb = string_to_color(_parsed_tmp["fillColor"])
except (json.JSONDecodeError, Exception):
pass
fill_rgb = [c / 255.0 for c in fill_color_rgb[:3]]
# Handle mask input (3D) by converting to image-like 4D tensor
input_is_mask = image.ndim == 3
if input_is_mask:
image = image.unsqueeze(-1).repeat(1, 1, 1, 3)
# Save input image as temp preview file for JS canvas
temp_dir = folder_paths.get_temp_directory()
pil_img = Image.fromarray((image[0].cpu().numpy() * 255).astype(np.uint8))
preview_filename = f"crop_preview_{random.randint(0, 0xFFFFFF):06x}.webp"
pil_img.save(os.path.join(temp_dir, preview_filename), format="WEBP", quality=80)
preview_ui = {"preview_filename": [preview_filename]}
img_height = image.shape[1]
img_width = image.shape[2]
# Parse bboxes and rotation
bbox_list = []
rotation = 0.0
if bboxes:
try:
parsed = json.loads(bboxes)
# New format: { bboxes: [...], rotation: N }
if isinstance(parsed, dict):
bbox_list = [b for b in parsed.get("bboxes", []) if b and all(k in b for k in ("startX", "startY", "endX", "endY"))]
rotation = parsed.get("rotation", 0.0)
# Legacy format: [bbox, bbox, ...]
elif isinstance(parsed, list):
bbox_list = [b for b in parsed if b and all(k in b for k in ("startX", "startY", "endX", "endY"))]
except json.JSONDecodeError:
pass
# Content mask tracks which pixels are actual image content (1=content, 0=fill)
content_mask = torch.ones(1, img_height, img_width, device=image.device)
# Apply rotation before cropping
if rotation != 0:
from torchvision.transforms.functional import rotate as tv_rotate
import torch.nn.functional as F
# Use shared fill color for rotation corners (unless edge mode)
rot_fill = fill_rgb
is_edge_mode = extra_pad_mode in ("pad_edge", "pad_crop_edge") or keep_proportion == "pad_edge"
if is_edge_mode:
h, w = image.shape[1], image.shape[2]
pad_amt = max(h, w)
img_chw = image.movedim(-1, 1)
img_padded = F.pad(img_chw, [pad_amt, pad_amt, pad_amt, pad_amt], mode='replicate')
img_rotated = tv_rotate(img_padded, -rotation, expand=False, fill=rot_fill)
ch, cw = img_rotated.shape[2], img_rotated.shape[3]
cy, cx = ch // 2, cw // 2
image = img_rotated[:, :, cy - h // 2:cy - h // 2 + h, cx - w // 2:cx - w // 2 + w].movedim(1, -1)
if mask is not None:
mask_padded = F.pad(mask.unsqueeze(1), [pad_amt, pad_amt, pad_amt, pad_amt], mode='replicate')
mask_rotated = tv_rotate(mask_padded, -rotation, expand=False, fill=[0.0])
mask = mask_rotated[:, :, cy - h // 2:cy - h // 2 + h, cx - w // 2:cx - w // 2 + w].squeeze(1)
# Content mask: rotate the same way (no padding — just rotate and crop)
cm_padded = F.pad(content_mask.unsqueeze(1), [pad_amt, pad_amt, pad_amt, pad_amt], mode='constant', value=0)
cm_rotated = tv_rotate(cm_padded, -rotation, expand=False, fill=[0.0])
content_mask = cm_rotated[:, :, cy - h // 2:cy - h // 2 + h, cx - w // 2:cx - w // 2 + w].squeeze(1)
else:
image = tv_rotate(image.movedim(-1, 1), -rotation, expand=True, fill=rot_fill).movedim(1, -1)
if mask is not None:
mask = tv_rotate(mask.unsqueeze(1), -rotation, expand=True, fill=[0.0]).squeeze(1)
# Content mask: rotate with expand, fill=0
content_mask = tv_rotate(content_mask.unsqueeze(1), -rotation, expand=True, fill=[0.0]).squeeze(1)
img_height = image.shape[1]
img_width = image.shape[2]
# Normalize mask dimensions to match image
if mask is not None:
if mask.shape[-2] != img_height or mask.shape[-1] != img_width:
if mask.shape[-2] == img_width and mask.shape[-1] == img_height:
mask = mask.transpose(-2, -1)
else:
mask = _resize_single_channel(mask, img_width, img_height)
# "Pad first" modes: apply extra padding to the full image before cropping
# Skip for keep_proportion pad modes — those handle extra padding via target subtraction
is_pad_first = extra_pad_mode in ("pad_color", "pad_edge")
kp_is_pad_mode = keep_proportion in ("pad_color", "pad_edge")
if is_pad_first and not kp_is_pad_mode and (extra_top > 0 or extra_bottom > 0 or extra_left > 0 or extra_right > 0):
pad_mode = "color" if extra_pad_mode == "pad_color" else "edge"
padded_img = _apply_padding(image, extra_top, extra_bottom, extra_left, extra_right, pad_mode, extra_edge_mode, fill_rgb)
image = padded_img
img_height = image.shape[1]
img_width = image.shape[2]
# Expand content mask and user mask
cm_new = torch.zeros(1, img_height, img_width, device=content_mask.device)
cm_new[:, extra_top:extra_top+content_mask.shape[1], extra_left:extra_left+content_mask.shape[2]] = content_mask
content_mask = cm_new
if mask is not None:
m_new = torch.zeros(mask.shape[0], img_height, img_width, device=mask.device, dtype=mask.dtype)
m_new[:, extra_top:extra_top+mask.shape[1], extra_left:extra_left+mask.shape[2]] = mask
mask = m_new
# If no bboxes, treat the full image as a single bbox
if not bbox_list:
bbox_list = [None]
all_cropped = []
all_cropped_masks = []
all_bbox_tuples = []
all_bbox_masks = []
for bbox_data in bbox_list:
has_bbox = bbox_data is not None
if has_bbox:
preview_width = bbox_data.get("previewWidth", 0)
preview_height = bbox_data.get("previewHeight", 0)
sx = img_width / preview_width if preview_width > 0 else 1.0
sy = img_height / preview_height if preview_height > 0 else 1.0
x_min = int(min(bbox_data["startX"], bbox_data["endX"]) * sx)
y_min = int(min(bbox_data["startY"], bbox_data["endY"]) * sy)
x_max = int(max(bbox_data["startX"], bbox_data["endX"]) * sx)
y_max = int(max(bbox_data["startY"], bbox_data["endY"]) * sy)
x_min = max(0, min(x_min, img_width - 1))
y_min = max(0, min(y_min, img_height - 1))
x_max = max(x_min + 1, min(x_max, img_width))
y_max = max(y_min + 1, min(y_max, img_height))
cropped = image[:, y_min:y_max, x_min:x_max, :]
cropped_content_mask = content_mask[:, y_min:y_max, x_min:x_max]
all_bbox_tuples.append((x_min, y_min, x_max - x_min, y_max - y_min))
bm = torch.zeros(1, img_height, img_width)
bm[0, y_min:y_max, x_min:x_max] = 1.0
all_bbox_masks.append(bm)
cropped_mask = mask[:, y_min:y_max, x_min:x_max] if mask is not None else None
else:
cropped = image
cropped_content_mask = content_mask
all_bbox_tuples.append((0, 0, img_width, img_height))
all_bbox_masks.append(torch.ones(1, img_height, img_width))
cropped_mask = mask
x_min, y_min, x_max, y_max = 0, 0, img_width, img_height
# Multiplier mode: compute target from crop dims * multiplier
if keep_proportion == "multiplier":
crop_h, crop_w = cropped.shape[1], cropped.shape[2]
tw = round(crop_w * width_mult)
th = round(crop_h * height_mult)
target_width = tw
target_height = th
# Resize cropped image if target dimensions are set
if target_width > 0 or target_height > 0:
crop_h, crop_w = cropped.shape[1], cropped.shape[2]
tw = target_width if target_width > 0 else crop_w
th = target_height if target_height > 0 else crop_h
# Subtract extra padding from target so content + padding = original target
# For pad-first + non-pad keep_proportion, padding is on the source (don't subtract)
# For pad modes or pad-crop, subtract so padding is in the output
has_extra = extra_top > 0 or extra_bottom > 0 or extra_left > 0 or extra_right > 0
kp_is_pad = keep_proportion in ("pad_color", "pad_edge")
if has_extra and (kp_is_pad or not is_pad_first):
if target_width > 0:
tw = max(1, tw - extra_left - extra_right)
if target_height > 0:
th = max(1, th - extra_top - extra_bottom)
if keep_proportion == "keep_long_edge":
ratio = min(tw / crop_w, th / crop_h)
tw = round(crop_w * ratio)
th = round(crop_h * ratio)
elif keep_proportion == "keep_short_edge":
ratio = max(tw / crop_w, th / crop_h)
tw = round(crop_w * ratio)
th = round(crop_h * ratio)
elif keep_proportion == "total_pixels":
total_pixels = tw * th
aspect_ratio = crop_w / crop_h
th = int(math.sqrt(total_pixels / aspect_ratio))
tw = int(math.sqrt(total_pixels * aspect_ratio))
elif keep_proportion == "crop":
ratio = max(tw / crop_w, th / crop_h)
scale_w = round(crop_w * ratio)
scale_h = round(crop_h * ratio)
samples = common_upscale(cropped.movedim(-1, 1), scale_w, scale_h, upscale_method, "center")
cropped = samples.movedim(1, -1)
if cropped_mask is not None:
cropped_mask = _upscale_mask(cropped_mask, scale_w, scale_h, upscale_method, "center")
cropped_content_mask = _resize_single_channel(cropped_content_mask, scale_w, scale_h)
cx = (scale_w - tw) // 2
cy = (scale_h - th) // 2
cropped = cropped[:, cy:cy+th, cx:cx+tw, :]
if cropped_mask is not None:
cropped_mask = cropped_mask[:, cy:cy+th, cx:cx+tw]
cropped_content_mask = cropped_content_mask[:, cy:cy+th, cx:cx+tw]
elif keep_proportion in ("pad_color", "pad_edge"):
ratio = min(tw / crop_w, th / crop_h)
scale_w = round(crop_w * ratio)
scale_h = round(crop_h * ratio)
samples = common_upscale(cropped.movedim(-1, 1), scale_w, scale_h, upscale_method, "disabled")
resized = samples.movedim(1, -1)
# pad_x/pad_y position across full target (not just content area)
full_tw = target_width if target_width > 0 else crop_w
full_th = target_height if target_height > 0 else crop_h
pad_left = round((full_tw - scale_w) * pad_x)
pad_top = round((full_th - scale_h) * pad_y)
pad_right = full_tw - pad_left - scale_w
pad_bottom = full_th - pad_top - scale_h
tw = full_tw
th = full_th
pad_mode = "edge" if keep_proportion == "pad_edge" else "color"
cropped = _apply_padding(resized, pad_top, pad_bottom, pad_left, pad_right, pad_mode, edge_mode, fill_rgb)
if cropped_mask is not None:
mask_resized = _upscale_mask(cropped_mask, scale_w, scale_h, upscale_method, "disabled")
mask_padded = torch.zeros(mask_resized.shape[0], th, tw, device=mask_resized.device, dtype=mask_resized.dtype)
mask_padded[:, pad_top:pad_top+scale_h, pad_left:pad_left+scale_w] = mask_resized
cropped_mask = mask_padded
# Update content mask for padding area
cm_resized = _resize_single_channel(cropped_content_mask, scale_w, scale_h)
cm_padded = torch.zeros(1, th, tw, device=cropped_content_mask.device)
cm_padded[:, pad_top:pad_top+scale_h, pad_left:pad_left+scale_w] = cm_resized
cropped_content_mask = cm_padded
if divisible_by > 1:
tw = tw - (tw % divisible_by)
th = th - (th % divisible_by)
if tw > 0 and th > 0:
if keep_proportion in ("stretch", "keep_long_edge", "keep_short_edge", "total_pixels", "multiplier"):
cropped = common_upscale(cropped.movedim(-1, 1), tw, th, upscale_method, "disabled").movedim(1, -1)
if cropped_mask is not None:
cropped_mask = _upscale_mask(cropped_mask, tw, th, upscale_method, "disabled")
cropped_content_mask = _resize_single_channel(cropped_content_mask, tw, th)
else:
cropped = cropped[:, :th, :tw, :]
if cropped_mask is not None:
cropped_mask = cropped_mask[:, :th, :tw]
cropped_content_mask = cropped_content_mask[:, :th, :tw]
# Enforce divisible_by even when no target dimensions are set
elif divisible_by > 1:
final_w = cropped.shape[2] - (cropped.shape[2] % divisible_by)
final_h = cropped.shape[1] - (cropped.shape[1] % divisible_by)
if final_w != cropped.shape[2] or final_h != cropped.shape[1]:
cropped = cropped[:, :final_h, :final_w, :]
if cropped_mask is not None:
cropped_mask = cropped_mask[:, :final_h, :final_w]
cropped_content_mask = cropped_content_mask[:, :final_h, :final_w]
# Apply extra padding (skip for pad-first and keep_proportion pad modes which handle it above)
kp_handles_ep = keep_proportion in ("pad_color", "pad_edge")
if not is_pad_first and not kp_handles_ep and (extra_top > 0 or extra_bottom > 0 or extra_left > 0 or extra_right > 0):
h_cur, w_cur = cropped.shape[1], cropped.shape[2]
pad_mode = "edge" if extra_pad_mode == "pad_crop_edge" else "color"
cropped = _apply_padding(cropped, extra_top, extra_bottom, extra_left, extra_right, pad_mode, extra_edge_mode, fill_rgb)
new_h, new_w = cropped.shape[1], cropped.shape[2]
if cropped_mask is not None:
padded_mask = torch.zeros(cropped_mask.shape[0], new_h, new_w, device=cropped_mask.device, dtype=cropped_mask.dtype)
padded_mask[:, extra_top:extra_top+h_cur, extra_left:extra_left+w_cur] = cropped_mask
cropped_mask = padded_mask
cm_h, cm_w = cropped_content_mask.shape[-2], cropped_content_mask.shape[-1]
if cm_h != h_cur or cm_w != w_cur:
cropped_content_mask = _resize_single_channel(cropped_content_mask, w_cur, h_cur)
cm_ep = torch.zeros(1, new_h, new_w, device=cropped_content_mask.device)
cm_ep[:, extra_top:extra_top+h_cur, extra_left:extra_left+w_cur] = cropped_content_mask
cropped_content_mask = cm_ep
# If no mask was provided, output a zeros mask matching the cropped image
if cropped_mask is None:
cropped_mask = torch.zeros(1, cropped.shape[1], cropped.shape[2])
# Apply fill mask — marks filled/padded areas as 1 in the output mask
# Combines with incoming mask: 1 where either input mask is 1 OR area is filled
if cropped_content_mask is not None:
out_h, out_w = cropped_mask.shape[1], cropped_mask.shape[2]
cm_h, cm_w = cropped_content_mask.shape[1], cropped_content_mask.shape[2]
if cm_h != out_h or cm_w != out_w:
cropped_content_mask = _resize_single_channel(cropped_content_mask, out_w, out_h)
# fill_mask: 1 where filled, 0 where content
fill_mask = 1.0 - cropped_content_mask.clamp(0, 1)
# Combine: output mask is max of incoming mask and fill mask
cropped_mask = torch.max(cropped_mask, fill_mask)
# Invert crop: output area outside the bbox instead of inside
if invert_crop == "enabled" and has_bbox:
inverted = image.clone()
for c in range(min(3, inverted.shape[3])):
inverted[:, y_min:y_max, x_min:x_max, c] = fill_rgb[c]
cropped = inverted
# Convert back to mask if input was a mask
if input_is_mask:
cropped = cropped[:, :, :, 0]
all_cropped.append(cropped)
all_cropped_masks.append(cropped_mask)
width, height = all_cropped[0].shape[2], all_cropped[0].shape[1]
return io.NodeOutput(all_cropped, all_cropped_masks, all_bbox_tuples, all_bbox_masks, width, height, ui=preview_ui)
class BBOXToBoundingBoxKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="BBOXToBoundingBoxKJ",
display_name="BBOX to Bounding Box KJ",
category="KJNodes/image",
search_aliases=["bbox", "bounding box", "convert", "crop region"],
description="Convert a KJNodes BBOX to the native BOUNDING_BOX dict used by core nodes such as Crop Image.",
inputs=[
io.BBOX.Input("bbox", tooltip="BBOX from any KJNodes node. Single boxes and lists of boxes are both accepted."),
io.Combo.Input("bbox_format", options=["xywh", "xyxy"], default="xywh",
tooltip="Layout of the incoming values."),
io.Int.Input("index", default=0, min=0, max=4096,
tooltip="When the input holds multiple boxes, pick which one to output. Clamped to the last box."),
],
outputs=[
io.BoundingBox.Output("bounding_box"),
],
)
@classmethod
def execute(cls, bbox, bbox_format, index) -> io.NodeOutput:
boxes = normalize_bboxes(bbox, bbox_format)
return io.NodeOutput(bbox_to_bounding_box(boxes[min(index, len(boxes) - 1)], bbox_format))
+115
View File
@@ -0,0 +1,115 @@
import folder_paths
import os
import torch
import torch.nn.functional as F
from comfy.utils import ProgressBar, load_torch_file
import comfy.sample
from nodes import CLIPTextEncode
script_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
folder_paths.add_model_folder_path("intrinsic_loras", os.path.join(script_directory, "intrinsic_loras"))
class Intrinsic_lora_sampling:
def __init__(self):
self.loaded_lora = None
@classmethod
def INPUT_TYPES(s):
return {"required": { "model": ("MODEL",),
"lora_name": (folder_paths.get_filename_list("intrinsic_loras"), ),
"task": (
[
'depth map',
'surface normals',
'albedo',
'shading',
],
{
"default": 'depth map'
}),
"text": ("STRING", {"multiline": True, "default": ""}),
"clip": ("CLIP", ),
"vae": ("VAE", ),
"per_batch": ("INT", {"default": 16, "min": 1, "max": 4096, "step": 1}),
},
"optional": {
"image": ("IMAGE",),
"optional_latent": ("LATENT",),
},
}
RETURN_TYPES = ("IMAGE", "LATENT",)
FUNCTION = "onestepsample"
CATEGORY = "KJNodes"
DESCRIPTION = """
Sampler to use the intrinsic loras:
https://github.com/duxiaodan/intrinsic-lora
These LoRAs are tiny and thus included
with this node pack.
"""
def onestepsample(self, model, lora_name, clip, vae, text, task, per_batch, image=None, optional_latent=None):
pbar = ProgressBar(3)
if optional_latent is None:
image_list = []
for start_idx in range(0, image.shape[0], per_batch):
sub_pixels = vae.vae_encode_crop_pixels(image[start_idx:start_idx+per_batch])
image_list.append(vae.encode(sub_pixels[:,:,:,:3]))
sample = torch.cat(image_list, dim=0)
else:
sample = optional_latent["samples"]
noise = torch.zeros(sample.size(), dtype=sample.dtype, layout=sample.layout, device="cpu")
prompt = task + "," + text
positive, = CLIPTextEncode.encode(self, clip, prompt)
negative = positive #negative shouldn't do anything in this scenario
pbar.update(1)
#custom model sampling to pass latent through as it is
class X0_PassThrough(comfy.model_sampling.EPS):
def calculate_denoised(self, sigma, model_output, model_input):
return model_output
def calculate_input(self, sigma, noise):
return noise
sampling_base = comfy.model_sampling.ModelSamplingDiscrete
sampling_type = X0_PassThrough
class ModelSamplingAdvanced(sampling_base, sampling_type):
pass
model_sampling = ModelSamplingAdvanced(model.model.model_config)
#load lora
model_clone = model.clone()
lora_path = folder_paths.get_full_path("intrinsic_loras", lora_name)
lora = load_torch_file(lora_path, safe_load=True)
self.loaded_lora = (lora_path, lora)
model_clone_with_lora = comfy.sd.load_lora_for_models(model_clone, None, lora, 1.0, 0)[0]
model_clone_with_lora.add_object_patch("model_sampling", model_sampling)
samples = {"samples": comfy.sample.sample(model_clone_with_lora, noise, 1, 1.0, "euler", "simple", positive, negative, sample,
denoise=1.0, disable_noise=True, start_step=0, last_step=1,
force_full_denoise=True, noise_mask=None, callback=None, disable_pbar=True, seed=None)}
pbar.update(1)
decoded = []
for start_idx in range(0, samples["samples"].shape[0], per_batch):
decoded.append(vae.decode(samples["samples"][start_idx:start_idx+per_batch]))
image_out = torch.cat(decoded, dim=0)
pbar.update(1)
if task == 'depth map':
imax = image_out.max()
imin = image_out.min()
image_out = (image_out-imin)/(imax-imin)
image_out = torch.max(image_out, dim=3, keepdim=True)[0].repeat(1, 1, 1, 3)
elif task == 'surface normals':
image_out = F.normalize(image_out * 2 - 1, dim=3) / 2 + 0.5
image_out = 1.0 - image_out
else:
image_out = image_out.clamp(-1.,1.)
return (image_out, samples,)
+137 -293
View File
@@ -6,7 +6,6 @@ import os
import logging
from tqdm import tqdm
import numpy as np
from comfy_api.latest import io, ui
device = comfy.model_management.get_torch_device()
@@ -49,27 +48,7 @@ def extract_lora(diff, key, rank, algorithm, lora_type, lowrank_iters=7, adaptiv
s_cum = torch.cumsum(S, dim=0)
min_cum_sum = adaptive_param * torch.sum(S)
lora_rank = torch.sum(s_cum < min_cum_sum).item()
elif lora_type == "adaptive_fro":
S_squared = S.pow(2)
S_fro_sq = float(torch.sum(S_squared))
sum_S_squared = torch.cumsum(S_squared, dim=0) / S_fro_sq
lora_rank = int(torch.searchsorted(sum_S_squared, adaptive_param**2)) + 1
lora_rank = max(1, min(lora_rank, len(S)))
else:
pass # Will print after capping
# Cap adaptive rank by the specified max rank
lora_rank = min(lora_rank, rank)
# Calculate and print actual fro percentage retained after capping
if lora_type == "adaptive_fro":
S_squared = S.pow(2)
s_fro = torch.sqrt(torch.sum(S_squared))
s_red_fro = torch.sqrt(torch.sum(S_squared[:lora_rank]))
fro_percent = float(s_red_fro / s_fro)
logging.info(f"{key} Extracted LoRA rank: {lora_rank}, Frobenius retained: {fro_percent:.1%}")
else:
logging.info(f"{key} Extracted LoRA rank: {lora_rank}")
print(f"{key} Extracted LoRA rank: {lora_rank}")
else:
lora_rank = rank
@@ -83,9 +62,9 @@ def extract_lora(diff, key, rank, algorithm, lora_type, lowrank_iters=7, adaptiv
if clamp_quantile:
dist = torch.cat([U.flatten(), Vh.flatten()])
if dist.numel() > 200_000:
# Sample 200,000 elements for quantile estimation
idx = torch.randperm(dist.numel(), device=dist.device)[:200_000]
if dist.numel() > 100_000:
# Sample 100,000 elements for quantile estimation
idx = torch.randperm(dist.numel(), device=dist.device)[:100_000]
dist_sample = dist[idx]
hi_val = torch.quantile(dist_sample, CLAMP_QUANTILE)
else:
@@ -101,31 +80,27 @@ def extract_lora(diff, key, rank, algorithm, lora_type, lowrank_iters=7, adaptiv
def calc_lora_model(model_diff, rank, prefix_model, prefix_lora, output_sd, lora_type, algorithm, lowrank_iters, out_dtype, bias_diff=False, adaptive_param=1.0, clamp_quantile=True):
# Get key names from module structure without materializing weights
sd_keys = []
for name, _ in model_diff.model.named_parameters():
if prefix_model is None or name.startswith(prefix_model):
sd_keys.append(name)
for name, _ in model_diff.model.named_buffers():
if prefix_model is None or name.startswith(prefix_model):
sd_keys.append(name)
comfy.model_management.load_models_gpu([model_diff], force_patch_weights=True)
model_diff.model.diffusion_model.cpu()
sd = model_diff.model_state_dict(filter_prefix=prefix_model)
del model_diff
comfy.model_management.soft_empty_cache()
for k, v in sd.items():
if isinstance(v, torch.Tensor):
sd[k] = v.cpu()
total_keys = len([k for k in sd_keys if k.endswith(".weight") or (bias_diff and k.endswith(".bias"))])
# Get total number of keys to process for progress bar
total_keys = len([k for k in sd if k.endswith(".weight") or (bias_diff and k.endswith(".bias"))])
# Create progress bar
progress_bar = tqdm(total=total_keys, desc=f"Extracting LoRA ({prefix_lora.strip('.')})")
comfy_pbar = comfy.utils.ProgressBar(total_keys)
# Process one weight at a time to minimize memory usage
for k in sd_keys:
for k in sd:
if k.endswith(".weight"):
# Patch and retrieve single weight
weight_diff = model_diff.patch_weight_to_device(k, return_weight=True)
if weight_diff is None:
progress_bar.update(1)
comfy_pbar.update(1)
continue
weight_diff = sd[k]
if weight_diff.ndim == 5:
logging.info(f"Skipping 5D tensor for key {k}")
del weight_diff
logging.info(f"Skipping 5D tensor for key {k}") #skip patch embed
progress_bar.update(1)
comfy_pbar.update(1)
continue
@@ -133,7 +108,6 @@ def calc_lora_model(model_diff, rank, prefix_model, prefix_lora, output_sd, lora
if weight_diff.ndim < 2:
if bias_diff:
output_sd["{}{}.diff".format(prefix_lora, k[len(prefix_model):-7])] = weight_diff.contiguous().to(out_dtype).cpu()
del weight_diff
progress_bar.update(1)
comfy_pbar.update(1)
continue
@@ -145,73 +119,61 @@ def calc_lora_model(model_diff, rank, prefix_model, prefix_lora, output_sd, lora
logging.warning(f"Could not generate lora weights for key {k}, error {e}")
else:
output_sd["{}{}.diff".format(prefix_lora, k[len(prefix_model):-7])] = weight_diff.contiguous().to(out_dtype).cpu()
del weight_diff
progress_bar.update(1)
comfy_pbar.update(1)
elif bias_diff and k.endswith(".bias"):
weight = model_diff.patch_weight_to_device(k, return_weight=True)
if weight is not None:
output_sd["{}{}.diff_b".format(prefix_lora, k[len(prefix_model):-5])] = weight.contiguous().to(out_dtype).cpu()
del weight
output_sd["{}{}.diff_b".format(prefix_lora, k[len(prefix_model):-5])] = sd[k].contiguous().to(out_dtype).cpu()
progress_bar.update(1)
comfy_pbar.update(1)
progress_bar.close()
del model_diff
comfy.model_management.soft_empty_cache()
return output_sd
class LoraExtractKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoraExtractKJ",
category="KJNodes/lora",
is_output_node=True,
inputs=[
io.MultiType.Input("finetuned", [io.Model, io.Clip], tooltip="The finetuned model or clip to extract LoRA from."),
io.MultiType.Input("original", [io.Model, io.Clip], tooltip="The original base model or clip to diff against."),
io.String.Input("filename_prefix", default="loras/ComfyUI_extracted_lora"),
io.Int.Input("rank", default=64, min=1, max=4096, step=1, tooltip="The rank to use for standard LoRA, or maximum rank limit for adaptive methods."),
io.Combo.Input("lora_type", options=["standard", "full", "adaptive_ratio", "adaptive_quantile", "adaptive_energy", "adaptive_fro"]),
io.Combo.Input("algorithm", options=["svd_linalg", "svd_lowrank"], default="svd_lowrank", tooltip="SVD algorithm to use, svd_lowrank is faster but less accurate."),
io.Int.Input("lowrank_iters", default=7, min=1, max=100, step=1, tooltip="The number of subspace iterations for lowrank SVD algorithm."),
io.Combo.Input("output_dtype", options=["fp16", "bf16", "fp32"], default="fp16"),
io.Boolean.Input("bias_diff", default=True),
io.Float.Input("adaptive_param", default=0.15, min=0.0, max=1.0, step=0.01, tooltip="For ratio mode, this is the ratio of the maximum singular value. For quantile mode, this is the quantile of the singular values. For fro mode, this is the Frobenius norm retention ratio."),
io.Boolean.Input("clamp_quantile", default=False),
],
)
class LoraExtractKJ:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
@classmethod
def execute(cls, finetuned, original, filename_prefix, rank, lora_type, algorithm, lowrank_iters, output_dtype, bias_diff, adaptive_param, clamp_quantile) -> io.NodeOutput:
def INPUT_TYPES(s):
return {"required":
{
"finetuned_model": ("MODEL",),
"original_model": ("MODEL",),
"filename_prefix": ("STRING", {"default": "loras/ComfyUI_extracted_lora"}),
"rank": ("INT", {"default": 8, "min": 1, "max": 4096, "step": 1}),
"lora_type": (["standard", "full", "adaptive_ratio", "adaptive_quantile", "adaptive_energy"],),
"algorithm": (["svd_linalg", "svd_lowrank"], {"default": "svd_linalg", "tooltip": "SVD algorithm to use, svd_lowrank is faster but less accurate."}),
"lowrank_iters": ("INT", {"default": 7, "min": 1, "max": 100, "step": 1, "tooltip": "The number of subspace iterations for lowrank SVD algorithm."}),
"output_dtype": (["fp16", "bf16", "fp32"], {"default": "fp16"}),
"bias_diff": ("BOOLEAN", {"default": True}),
"adaptive_param": ("FLOAT", {"default": 0.15, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "For ratio mode, this is the ratio of the maximum singular value. For quantile mode, this is the quantile of the singular values."}),
"clamp_quantile": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ()
FUNCTION = "save"
OUTPUT_NODE = True
CATEGORY = "KJNodes/lora"
def save(self, finetuned_model, original_model, filename_prefix, rank, lora_type, algorithm, lowrank_iters, output_dtype, bias_diff, adaptive_param, clamp_quantile):
if algorithm == "svd_lowrank" and lora_type != "standard":
raise ValueError("svd_lowrank algorithm is only supported for standard LoRA extraction.")
dtype = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}[output_dtype]
dtype = {"fp8_e4m3fn": torch.float8_e4m3fn, "bf16": torch.bfloat16, "fp16": torch.float16, "fp16_fast": torch.float16, "fp32": torch.float32}[output_dtype]
m = finetuned_model.clone()
kp = original_model.get_key_patches("diffusion_model.")
for k in kp:
m.add_patches({k: kp[k]}, - 1.0, 1.0)
model_diff = m
output_dir = folder_paths.get_output_directory()
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, output_dir)
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir)
output_sd = {}
is_clip = hasattr(finetuned, "patcher")
if is_clip:
clip_diff = finetuned.clone()
kp = original.get_key_patches()
kp = {k: v for k, v in kp.items() if not k.endswith(".position_ids") and not k.endswith(".logit_scale")}
clip_diff.add_patches(kp, -1.0, 1.0)
output_sd = calc_lora_model(clip_diff.patcher, rank, "", "text_encoders.", output_sd, lora_type, algorithm, lowrank_iters, dtype, bias_diff=bias_diff, adaptive_param=adaptive_param, clamp_quantile=clamp_quantile)
else:
m = finetuned.clone()
kp = original.get_key_patches("diffusion_model.")
m.add_patches(kp, -1.0, 1.0)
output_sd = calc_lora_model(m, rank, "diffusion_model.", "diffusion_model.", output_sd, lora_type, algorithm, lowrank_iters, dtype, bias_diff=bias_diff, adaptive_param=adaptive_param, clamp_quantile=clamp_quantile)
if model_diff is not None:
output_sd = calc_lora_model(model_diff, rank, "diffusion_model.", "diffusion_model.", output_sd, lora_type, algorithm, lowrank_iters, dtype, bias_diff=bias_diff, adaptive_param=adaptive_param, clamp_quantile=clamp_quantile)
if "adaptive" in lora_type:
rank_str = f"{lora_type}_{adaptive_param:.2f}"
else:
@@ -220,30 +182,43 @@ class LoraExtractKJ(io.ComfyNode):
output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
comfy.utils.save_torch_file(output_sd, output_checkpoint, metadata=None)
return io.NodeOutput(ui={"files": [ui.SavedResult(os.path.basename(output_checkpoint), subfolder, io.FolderType.output)]})
return {}
class LoraReduceRank(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="LoraReduceRankKJ",
display_name="LoraReduceRank",
category="KJNodes/lora",
description="Resize a LoRA model by reducing its rank. Based on kohya's sd-scripts: https://github.com/kohya-ss/sd-scripts/blob/main/networks/resize_lora.py",
is_output_node=True,
is_experimental=True,
inputs=[
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras"), tooltip="The name of the LoRA."),
io.Int.Input("new_rank", default=8, min=1, max=4096, step=1, tooltip="The new rank to resize the LoRA. Acts as max rank when using dynamic_method."),
io.Combo.Input("dynamic_method", options=["disabled", "sv_ratio", "sv_cumulative", "sv_fro", "sv_knee"], default="disabled", tooltip="Method to use for dynamically determining new alphas and dims. sv_knee finds the elbow point in the singular value curve."),
io.Float.Input("dynamic_param", default=0.2, min=0.0, max=2.0, step=0.01, tooltip="Parameter for dynamic methods. For sv_knee: sensitivity (1.0=standard knee, <1.0=more aggressive/lower rank, >1.0=more conservative)."),
io.Combo.Input("output_dtype", options=["match_original", "fp16", "bf16", "fp32"], default="match_original", tooltip="Data type to save the LoRA as."),
io.Boolean.Input("verbose", default=True),
],
)
NODE_CLASS_MAPPINGS = {
"LoraExtractKJ": LoraExtractKJ
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LoraExtractKJ": "LoraExtractKJ"
}
class LoraReduceRank:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
@classmethod
def execute(cls, lora_name, new_rank, dynamic_method, dynamic_param, output_dtype, verbose) -> io.NodeOutput:
def INPUT_TYPES(s):
return {"required":
{
"lora_name": (folder_paths.get_filename_list("loras"), {"tooltip": "The name of the LoRA."}),
"new_rank": ("INT", {"default": 8, "min": 1, "max": 4096, "step": 1, "tooltip": "The new rank to resize the LoRA. Acts as max rank when using dynamic_method."}),
"dynamic_method": (["disabled", "sv_ratio", "sv_cumulative", "sv_fro"], {"default": "disabled", "tooltip": "Method to use for dynamically determining new alphas and dims"}),
"dynamic_param": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Method to use for dynamically determining new alphas and dims"}),
"output_dtype": (["match_original", "fp16", "bf16", "fp32"], {"default": "match_original", "tooltip": "Data type to save the LoRA as."}),
"verbose": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ()
FUNCTION = "save"
OUTPUT_NODE = True
EXPERIMENTAL = True
DESCRIPTION = "Resize a LoRA model by reducing it's rank. Based on kohya's sd-scripts: https://github.com/kohya-ss/sd-scripts/blob/main/networks/resize_lora.py"
CATEGORY = "KJNodes/lora"
def save(self, lora_name, new_rank, output_dtype, dynamic_method, dynamic_param, verbose):
lora_path = folder_paths.get_full_path("loras", lora_name)
lora_sd, metadata = comfy.utils.load_torch_file(lora_path, return_metadata=True)
@@ -261,9 +236,10 @@ class LoraReduceRank(io.ComfyNode):
for k, v in lora_sd.items():
new_lora_sd[k.replace(".default", "")] = v
del lora_sd
logging.info("Resizing Lora...")
print("Resizing Lora...")
output_sd, old_dim, new_alpha, rank_list = resize_lora_model(new_lora_sd, new_rank, save_dtype, device, dynamic_method, dynamic_param, verbose)
# update metadata
if metadata is None:
metadata = {}
@@ -278,24 +254,32 @@ class LoraReduceRank(io.ComfyNode):
metadata["ss_network_dim"] = "Dynamic"
metadata["ss_network_alpha"] = "Dynamic"
# cast to save_dtype before calculating hashes
for key in list(output_sd.keys()):
value = output_sd[key]
if type(value) == torch.Tensor and value.dtype.is_floating_point and value.dtype != save_dtype:
output_sd[key] = value.to(save_dtype)
output_dir = folder_paths.get_output_directory()
output_filename_prefix = "loras/" + lora_name
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(output_filename_prefix, output_dir)
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(output_filename_prefix, self.output_dir)
output_dtype_str = f"_{output_dtype}" if output_dtype != "match_original" else ""
average_rank = str(int(np.mean(rank_list)))
rank_str = new_rank if dynamic_method == "disabled" else f"dynamic_{average_rank}"
output_checkpoint = f"{filename.replace('.safetensors', '')}_resized_from_{old_dim}_to_{rank_str}{output_dtype_str}_{counter:05}_.safetensors"
output_checkpoint = os.path.join(full_output_folder, output_checkpoint)
logging.info(f"Saving resized LoRA to {output_checkpoint}")
print(f"Saving resized LoRA to {output_checkpoint}")
comfy.utils.save_torch_file(output_sd, output_checkpoint, metadata=metadata)
return io.NodeOutput(ui={"files": [ui.SavedResult(os.path.basename(output_checkpoint), subfolder, io.FolderType.output)]})
return {}
NODE_CLASS_MAPPINGS = {
"LoraExtractKJ": LoraExtractKJ
}
NODE_DISPLAY_NAME_MAPPINGS = {
"LoraExtractKJ": "LoraExtractKJ"
}
# Convert LoRA to different rank approximation (should only be used to go to lower rank)
# This code is based off the extract_lora_from_models.py file which is based on https://github.com/cloneofsimo/lora/blob/develop/lora_diffusion/cli_svd.py
@@ -341,130 +325,45 @@ def index_sv_ratio(S, target):
return index
def index_sv_knee(S, sensitivity=1.0):
"""Find the knee/elbow point in the singular value curve.
Uses the Kneedle method: normalizes the curve to [0,1] on both axes,
then finds the point with maximum distance from a reference line.
sensitivity controls the aggressiveness:
1.0 = standard knee point
< 1.0 = more aggressive (lower rank), e.g. 0.5 roughly halves the knee rank
> 1.0 = more conservative (higher rank)
The reference line tilts based on sensitivity: at lower values,
the line favors keeping fewer singular values."""
n = len(S)
if n <= 2:
return 1
S_np = S.cpu().float().numpy()
# Normalize x and y to [0, 1]
x = np.linspace(0, 1, n)
y = (S_np - S_np[-1]) / (S_np[0] - S_np[-1] + 1e-10)
# Reference line from (0, 1) to (1, 1-sensitivity)
# At sensitivity=1.0: line goes (0,1)->(1,0), standard kneedle
# At sensitivity=0.5: line goes (0,1)->(1,0.5), steeper = more aggressive
y_line = 1.0 - sensitivity * x
# Signed distance: positive means curve is above the line (keep)
distances = y - y_line
# Find the point of maximum positive distance
index = int(np.argmax(distances))
index = max(1, min(index, n - 1))
return index
# Modified from Kohaku-blueleaf's extract/merge functions
def _svd_extract(weight_2d, lora_rank, dynamic_method, dynamic_param, device, scale=1):
"""Shared SVD extraction for both linear and conv weights.
Dynamic mode: single full SVD (need all singular values for rank selection).
Disabled mode: svd_lowrank only (rank is known, much faster for large matrices)."""
weight_2d = weight_2d.to(device)
if weight_2d.dtype != torch.float32:
weight_2d = weight_2d.float()
if dynamic_method and dynamic_method != "disabled":
# Full SVD: we need all singular values for dynamic rank selection,
# and we reuse U/Vh directly — one SVD, no wasted work
U, S, Vh = torch.linalg.svd(weight_2d, full_matrices=False)
param_dict = rank_resize(S, lora_rank, dynamic_method, dynamic_param, scale)
lora_rank = param_dict["new_rank"]
U = U[:, :lora_rank]
S = S[:lora_rank]
Vh = Vh[:lora_rank, :]
else:
# Randomized lowrank SVD: only compute top-k, much faster when rank << min(m,n)
U, S, V = torch.svd_lowrank(weight_2d, q=lora_rank, niter=7)
Vh = V.t()
param_dict = {"new_rank": lora_rank, "new_alpha": float(scale * lora_rank)}
del V
sqrt_S = torch.diag(torch.sqrt(S))
U = U @ sqrt_S
Vh = sqrt_S @ Vh
return U, Vh, param_dict
def extract_conv(weight, lora_rank, dynamic_method, dynamic_param, device, scale=1):
out_size, in_size, kernel_size, _ = weight.size()
if weight.dtype != torch.float32:
weight = weight.to(torch.float32)
U, S, Vh = torch.linalg.svd(weight.reshape(out_size, -1).to(device))
U, Vh, param_dict = _svd_extract(
weight.reshape(out_size, -1), lora_rank, dynamic_method, dynamic_param, device, scale
)
param_dict = rank_resize(S, lora_rank, dynamic_method, dynamic_param, scale)
lora_rank = param_dict["new_rank"]
U = U[:, :lora_rank]
S = S[:lora_rank]
U = U @ torch.diag(S)
Vh = Vh[:lora_rank, :]
param_dict["lora_down"] = Vh.reshape(lora_rank, in_size, kernel_size, kernel_size).cpu()
param_dict["lora_up"] = U.reshape(out_size, lora_rank, 1, 1).cpu()
del U, Vh, weight
del U, S, Vh, weight
return param_dict
def extract_linear(weight, lora_rank, dynamic_method, dynamic_param, device, scale=1):
out_size, in_size = weight.size()
U, Vh, param_dict = _svd_extract(
weight, lora_rank, dynamic_method, dynamic_param, device, scale
)
if weight.dtype != torch.float32:
weight = weight.to(torch.float32)
U, S, Vh = torch.linalg.svd(weight.to(device))
param_dict = rank_resize(S, lora_rank, dynamic_method, dynamic_param, scale)
lora_rank = param_dict["new_rank"]
param_dict["lora_down"] = Vh.reshape(lora_rank, in_size).cpu()
param_dict["lora_up"] = U.reshape(out_size, lora_rank).cpu()
del U, Vh, weight
return param_dict
def extract_linear_factored(lora_down, lora_up, lora_rank, dynamic_method, dynamic_param, device, scale=1):
"""SVD of lora_up @ lora_down without materializing the product.
up @ down == Qu @ (Ru @ Rd.T) @ Qd.T, so the singular values of the merged
weight are those of the small rank x rank core. Exact, and peak memory is the
two thin QRs instead of out_size x in_size.
"""
down = lora_down.to(device).float()
up = lora_up.to(device).float()
out_size, in_size = up.shape[0], down.shape[1]
Qu, Ru = torch.linalg.qr(up)
Qd, Rd = torch.linalg.qr(down.transpose(0, 1))
U, S, Vh = torch.linalg.svd(Ru @ Rd.transpose(0, 1), full_matrices=False)
if dynamic_method and dynamic_method != "disabled":
param_dict = rank_resize(S, lora_rank, dynamic_method, dynamic_param, scale)
lora_rank = param_dict["new_rank"]
else:
lora_rank = min(lora_rank, S.numel())
param_dict = {"new_rank": lora_rank, "new_alpha": float(scale * lora_rank)}
sqrt_S = torch.sqrt(S[:lora_rank])
U = (Qu @ U[:, :lora_rank]) * sqrt_S
Vh = sqrt_S.unsqueeze(1) * (Vh[:lora_rank] @ Qd.transpose(0, 1))
U = U[:, :lora_rank]
S = S[:lora_rank]
U = U @ torch.diag(S)
Vh = Vh[:lora_rank, :]
param_dict["lora_down"] = Vh.reshape(lora_rank, in_size).cpu()
param_dict["lora_up"] = U.reshape(out_size, lora_rank).cpu()
del U, S, Vh, Qu, Ru, Qd, Rd, up, down
del U, S, Vh, weight
return param_dict
@@ -495,34 +394,6 @@ def merge_linear(lora_down, lora_up, device):
return weight
def merge_conv3d(lora_down, lora_up, device):
in_rank, in_size, kD, kH, kW = lora_down.shape
out_size, out_rank, _, _, _ = lora_up.shape
assert in_rank == out_rank, f"rank {in_rank} {out_rank} mismatch"
lora_down = lora_down.to(device)
lora_up = lora_up.to(device)
merged = lora_up.reshape(out_size, -1) @ lora_down.reshape(in_rank, -1)
weight = merged.reshape(out_size, in_size, kD, kH, kW)
del lora_up, lora_down
return weight
def extract_conv3d(weight, lora_rank, dynamic_method, dynamic_param, device, scale=1):
out_size, in_size, kD, kH, kW = weight.size()
U, Vh, param_dict = _svd_extract(
weight.reshape(out_size, -1), lora_rank, dynamic_method, dynamic_param, device, scale
)
lora_rank = param_dict["new_rank"]
param_dict["lora_down"] = Vh.reshape(lora_rank, in_size, kD, kH, kW).cpu()
param_dict["lora_up"] = U.reshape(out_size, lora_rank, 1, 1, 1).cpu()
del U, Vh, weight
return param_dict
# Calculate new rank
@@ -543,11 +414,6 @@ def rank_resize(S, rank, dynamic_method, dynamic_param, scale=1):
# Calculate new dim and alpha based off sqrt sum of squares
new_rank = index_sv_fro(S, dynamic_param) + 1
new_alpha = float(scale * new_rank)
elif dynamic_method == "sv_knee":
# Knee/elbow detection in singular value curve
new_rank = index_sv_knee(S, dynamic_param) + 1
new_alpha = float(scale * new_rank)
else:
new_rank = rank
new_alpha = float(scale * new_rank)
@@ -585,7 +451,7 @@ def resize_lora_model(lora_sd, new_rank, save_dtype, device, dynamic_method, dyn
rank_list = []
if dynamic_method:
logging.info(f"Dynamically determining new alphas and dims based off {dynamic_method}: {dynamic_param}, max rank is {new_rank}")
print(f"Dynamically determining new alphas and dims based off {dynamic_method}: {dynamic_param}, max rank is {new_rank}")
lora_down_weight = None
lora_up_weight = None
@@ -597,7 +463,7 @@ def resize_lora_model(lora_sd, new_rank, save_dtype, device, dynamic_method, dyn
total_keys = len([k for k in lora_sd if k.endswith(".weight")])
pbar = comfy.utils.ProgressBar(total_keys)
for key, value in tqdm(lora_sd.items(), leave=True, desc="Resizing LoRA weights"):
for key, value in tqdm(lora_sd.items()):
key_parts = key.split(".")
block_down_name = None
for _format in LORA_DOWN_UP_FORMATS:
@@ -632,30 +498,9 @@ def resize_lora_model(lora_sd, new_rank, save_dtype, device, dynamic_method, dyn
if weights_loaded:
conv2d = len(lora_down_weight.size()) == 4
conv3d = len(lora_down_weight.size()) == 5
old_rank = lora_down_weight.size()[0]
max_old_rank = max(max_old_rank or 0, old_rank)
# Skip if merged weight would be too large (>100k elements in any dimension)
if conv2d:
in_rank, in_size, kernel_size, _ = lora_down_weight.shape
out_size, out_rank, _, _ = lora_up_weight.shape
merged_size = out_size * in_size * kernel_size * kernel_size
elif conv3d:
in_rank, in_size, kD, kH, kW = lora_down_weight.shape
out_size, out_rank, _, _, _ = lora_up_weight.shape
merged_size = out_size * in_size * kD * kH * kW
else:
in_rank, in_size = lora_down_weight.shape
out_size, out_rank = lora_up_weight.shape
merged_size = out_size * in_size
# Linear layers of any size go through the factored path; conv still needs the merge
if merged_size > 100_000_000 and (conv2d or conv3d):
logging.warning(f"Skipping {block_down_name}: merged weight too large ({merged_size:,} elements)")
tqdm.write(f"SKIPPED: {block_down_name} - too large ({merged_size:,} elements)")
pbar.update(1)
continue
if lora_alpha is None:
scale = 1.0
@@ -665,24 +510,22 @@ def resize_lora_model(lora_sd, new_rank, save_dtype, device, dynamic_method, dyn
if conv2d:
full_weight_matrix = merge_conv(lora_down_weight, lora_up_weight, device)
param_dict = extract_conv(full_weight_matrix, new_rank, dynamic_method, dynamic_param, device, scale)
elif conv3d:
full_weight_matrix = merge_conv3d(lora_down_weight, lora_up_weight, device)
param_dict = extract_conv3d(full_weight_matrix, new_rank, dynamic_method, dynamic_param, device, scale)
elif merged_size > 100_000_000:
param_dict = extract_linear_factored(lora_down_weight, lora_up_weight, new_rank, dynamic_method, dynamic_param, device, scale)
else:
full_weight_matrix = merge_linear(lora_down_weight, lora_up_weight, device)
param_dict = extract_linear(full_weight_matrix, new_rank, dynamic_method, dynamic_param, device, scale)
if verbose and "fro_retained" in param_dict:
if verbose:
max_ratio = param_dict["max_ratio"]
sum_retained = param_dict["sum_retained"]
fro_retained = param_dict["fro_retained"]
if not np.isnan(fro_retained):
fro_list.append(float(fro_retained))
log_str = f"{block_down_name:75} | sum(S) retained: {sum_retained:.1%}, fro retained: {fro_retained:.1%}, max(S) ratio: {max_ratio:0.1f}, new dim: {param_dict['new_rank']}"
tqdm.write(log_str)
verbose_str += log_str
verbose_str += f"{block_down_name:75} | "
verbose_str += f"sum(S) retained: {sum_retained:.1%}, fro retained: {fro_retained:.1%}, max(S) ratio: {max_ratio:0.1f}"
print(verbose_str)
if verbose and dynamic_method:
verbose_str += f", dynamic | dim: {param_dict['new_rank']}, alpha: {param_dict['new_alpha']}\n"
@@ -704,5 +547,6 @@ def resize_lora_model(lora_sd, new_rank, save_dtype, device, dynamic_method, dyn
pbar.update(1)
if verbose:
logging.info(f"Average Frobenius norm retention: {np.mean(fro_list):.2%} | std: {np.std(fro_list):0.3f}")
print(verbose_str)
print(f"Average Frobenius norm retention: {np.mean(fro_list):.2%} | std: {np.std(fro_list):0.3f}")
return o_lora_sd, max_old_rank, new_alpha, rank_list
-2358
View File
File diff suppressed because one or more lines are too long
+64 -326
View File
@@ -6,8 +6,6 @@ import scipy.ndimage
import numpy as np
from contextlib import nullcontext
import os
from tqdm import tqdm
import logging
from comfy import model_management
from comfy.utils import ProgressBar
@@ -19,8 +17,6 @@ import folder_paths
from ..utility.utility import tensor2pil, pil2tensor
script_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
main_device = model_management.get_torch_device()
offload_device = model_management.unet_offload_device()
class BatchCLIPSeg:
@@ -74,7 +70,7 @@ Segments an image or batch of images using CLIPSeg.
from huggingface_hub import snapshot_download
snapshot_download(repo_id="Kijai/clipseg-rd64-refined-fp16", local_dir=checkpoint_path, local_dir_use_symlinks=False)
self.model = CLIPSegForImageSegmentation.from_pretrained(checkpoint_path)
except Exception:
except:
checkpoint_path = "CIDAS/clipseg-rd64-refined"
self.model = CLIPSegForImageSegmentation.from_pretrained(checkpoint_path)
processor = CLIPSegProcessor.from_pretrained(checkpoint_path)
@@ -102,7 +98,7 @@ Segments an image or batch of images using CLIPSeg.
mask_tensor = torch.sigmoid(outputs.logits)
mask_tensor = (mask_tensor - mask_tensor.min()) / (mask_tensor.max() - mask_tensor.min())
mask_tensor = torch.where(mask_tensor > (threshold), mask_tensor, torch.tensor(0, dtype=torch.float))
print(mask_tensor.shape)
if len(mask_tensor.shape) == 2:
mask_tensor = mask_tensor.unsqueeze(0)
mask_tensor = F.interpolate(mask_tensor.unsqueeze(1), size=(H, W), mode='nearest')
@@ -232,34 +228,40 @@ creates animation between them.
image = Image.new("RGB", (width, height), "black")
draw = ImageDraw.Draw(image)
font = ImageFont.truetype(font_path, font_size)
# Split the text into lines and wrap words to fit width
text_lines = text.split('\n')
# Split the text into words
words = text.split()
# Initialize variables for line creation
lines = []
for text_line in text_lines:
if text_line.strip() == "":
# Preserve empty lines for multiple newlines
lines.append("")
continue
words = text_line.split()
current_line = []
current_line = []
current_line_width = 0
try: #new pillow
# Iterate through words to create lines
for word in words:
if current_line:
test_line = " ".join(current_line + [word])
else:
test_line = word
try:
test_line_width = font.getbbox(test_line)[2]
except Exception:
test_line_width = font.getsize(test_line)[0]
if test_line_width <= width - 2 * text_x:
word_width = font.getbbox(word)[2]
if current_line_width + word_width <= width - 2 * text_x:
current_line.append(word)
current_line_width += word_width + font.getbbox(" ")[2] # Add space width
else:
lines.append(" ".join(current_line))
current_line = [word]
if current_line:
lines.append(" ".join(current_line))
current_line_width = word_width
except: #old pillow
for word in words:
word_width = font.getsize(word)[0]
if current_line_width + word_width <= width - 2 * text_x:
current_line.append(word)
current_line_width += word_width + font.getsize(" ")[0] # Add space width
else:
lines.append(" ".join(current_line))
current_line = [word]
current_line_width = word_width
# Add the last line if it's not empty
if current_line:
lines.append(" ".join(current_line))
# Draw each line of text separately
y_offset = text_y
for line in lines:
@@ -269,20 +271,20 @@ creates animation between them.
text_center_y = y_offset + text_height / 2
try:
draw.text((text_x, y_offset), line, font=font, fill=font_color, features=['-liga'])
except Exception:
except:
draw.text((text_x, y_offset), line, font=font, fill=font_color)
y_offset += text_height # Move to the next line
if start_rotation != end_rotation:
image = image.rotate(rotation, center=(text_center_x, text_center_y))
rotation += rotation_increment
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
mask = image[:, :, :, 0]
masks.append(mask)
out.append(image)
if invert:
return (1.0 - torch.cat(out, dim=0), 1.0 - torch.cat(masks, dim=0),)
return (torch.cat(out, dim=0),torch.cat(masks, dim=0),)
@@ -371,7 +373,7 @@ class CreateFluidMask:
from ..utility.fluid import Fluid
try:
from scipy.special import erf
except ImportError:
except:
from scipy.spatial import erf
out = []
masks = []
@@ -384,7 +386,7 @@ class CreateFluidMask:
INFLOW_VELOCITY = inflow_velocity
INFLOW_COUNT = inflow_count
logging.info('Generating fluid solver, this may take some time.')
print('Generating fluid solver, this may take some time.')
fluid = Fluid(RESOLUTION, 'dye')
center = np.floor_divide(RESOLUTION, 2)
@@ -404,7 +406,7 @@ class CreateFluidMask:
for f in range(DURATION):
logging.info(f'Computing frame {f + 1} of {DURATION}.')
print(f'Computing frame {f + 1} of {DURATION}.')
if f <= INFLOW_DURATION:
fluid.velocity += inflow_velocity
fluid.dye += inflow_dye
@@ -448,8 +450,8 @@ class CreateAudioMask:
def createaudiomask(self, frames, width, height, invert, audio_path, scale):
try:
import librosa
except ImportError as e:
raise ImportError("Can not import librosa. Install it with 'pip install librosa'") from e
except ImportError:
raise Exception("Can not import librosa. Install it with 'pip install librosa'")
batch_size = frames
out = []
masks = []
@@ -876,7 +878,6 @@ class CreateVoronoiMask:
def createvoronoi(self, frames, num_points, line_width, speed, frame_width, frame_height):
from scipy.spatial import Voronoi
from matplotlib import pyplot as plt
# Define the number of images in the batch
batch_size = frames
out = []
@@ -996,55 +997,43 @@ class GrowMaskWithBlur:
- fill_holes: fill holes in the mask (slow)"""
def expand_mask(self, mask, expand, tapered_corners, flip_input, blur_radius, incremental_expandrate, lerp_alpha, decay_factor, fill_holes=False):
import kornia.morphology as morph
alpha = lerp_alpha
decay = decay_factor
if flip_input:
mask = 1.0 - mask
growmask = mask.reshape((-1, mask.shape[-2], mask.shape[-1]))
c = 0 if tapered_corners else 1
kernel = np.array([[c, 1, c],
[1, 1, 1],
[c, 1, c]])
growmask = mask.reshape((-1, mask.shape[-2], mask.shape[-1])).cpu()
out = []
previous_output = None
current_expand = expand
for m in tqdm(growmask, desc="Expanding/Contracting Mask"):
output = m.unsqueeze(0).unsqueeze(0).to(main_device) # Add batch and channel dims for kornia
if abs(round(current_expand)) > 0 and output.max() > 0:
# Create kernel - kornia expects kernel on same device as input
if tapered_corners:
kernel = torch.tensor([[0, 1, 0],
[1, 1, 1],
[0, 1, 0]], dtype=torch.float32, device=output.device)
for m in growmask:
output = m.numpy().astype(np.float32)
for _ in range(abs(round(current_expand))):
if current_expand < 0:
output = scipy.ndimage.grey_erosion(output, footprint=kernel)
else:
kernel = torch.tensor([[1, 1, 1],
[1, 1, 1],
[1, 1, 1]], dtype=torch.float32, device=output.device)
for _ in range(abs(round(current_expand))):
if current_expand < 0:
output = morph.erosion(output, kernel)
else:
output = morph.dilation(output, kernel)
output = output.squeeze(0).squeeze(0) # Remove batch and channel dims
output = scipy.ndimage.grey_dilation(output, footprint=kernel)
if current_expand < 0:
current_expand -= abs(incremental_expandrate)
else:
current_expand += abs(incremental_expandrate)
if fill_holes:
binary_mask = output > 0
output_np = binary_mask.cpu().numpy()
filled = scipy.ndimage.binary_fill_holes(output_np)
output = torch.from_numpy(filled.astype(np.float32)).to(output.device)
output = scipy.ndimage.binary_fill_holes(binary_mask)
output = output.astype(np.float32) * 255
output = torch.from_numpy(output)
if alpha < 1.0 and previous_output is not None:
# Interpolate between the previous and current frame
output = alpha * output + (1 - alpha) * previous_output
if decay < 1.0 and previous_output is not None:
# Add the decayed previous output to the current frame
output += decay * previous_output
output = output / output.max()
previous_output = output
out.append(output.cpu())
out.append(output)
if blur_radius != 0:
# Convert the tensor list to PIL images, apply blur, and convert back
@@ -1306,6 +1295,7 @@ def get_mask_polygon(self, mask_np):
return polygon.squeeze()
import cv2
class SeparateMasks:
@classmethod
def INPUT_TYPES(cls):
@@ -1328,7 +1318,6 @@ class SeparateMasks:
DESCRIPTION = "Separates a mask into multiple masks based on the size of the connected components."
def polygon_to_mask(self, polygon, shape):
import cv2
mask = np.zeros((shape[0], shape[1]), dtype=np.uint8) # Fixed shape handling
if len(polygon.shape) == 2: # Check if polygon points are valid
@@ -1337,7 +1326,6 @@ class SeparateMasks:
return mask
def get_mask_polygon(self, mask_np, max_points):
import cv2
contours, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
@@ -1347,6 +1335,7 @@ class SeparateMasks:
# Initialize with smaller epsilon for more points
perimeter = cv2.arcLength(hull, True)
epsilon = perimeter * 0.01 # Start smaller
min_eps = perimeter * 0.001 # Much smaller minimum
max_eps = perimeter * 0.2 # Smaller maximum
@@ -1382,6 +1371,9 @@ class SeparateMasks:
return best_approx.squeeze() if best_approx is not None else hull.squeeze()
def separate(self, mask: torch.Tensor, size_threshold_width: int, size_threshold_height: int, max_poly_points: int, mode: str):
from scipy.ndimage import label, center_of_mass
import numpy as np
B, H, W = mask.shape
separated = []
@@ -1390,7 +1382,7 @@ class SeparateMasks:
for b in range(B):
mask_np = mask[b].cpu().numpy().astype(np.uint8)
structure = np.ones((3, 3), dtype=np.int8)
labeled, ncomponents = scipy.ndimage.label(mask_np, structure=structure)
labeled, ncomponents = label(mask_np, structure=structure)
pbar = ProgressBar(ncomponents)
for component in range(1, ncomponents + 1):
@@ -1404,7 +1396,7 @@ class SeparateMasks:
width = x_max - x_min + 1
height = y_max - y_min + 1
centroid_x = (x_min + x_max) / 2 # Calculate x centroid
logging.info(f"Component {component}: width={width}, height={height}, x_pos={centroid_x}")
print(f"Component {component}: width={width}, height={height}, x_pos={centroid_x}")
if width >= size_threshold_width and height >= size_threshold_height:
if mode == "convex_polygons":
@@ -1432,258 +1424,4 @@ class SeparateMasks:
return out_masks,
else:
return torch.empty((1, 64, 64), device=mask.device),
class ConsolidateMasksKJ:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"masks": ("MASK",),
"width": ("INT", {"default": 512, "min": 0, "max": 4096, "step": 64}),
"height": ("INT", {"default": 512, "min": 0, "max": 4096, "step": 64}),
"padding": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
},
}
RETURN_TYPES = ("MASK",)
FUNCTION = "consolidate"
CATEGORY = "KJNodes/masking"
DESCRIPTION = "Consolidates a batch of separate masks by finding the largest group of masks that fit inside a tile of the given width and height (including the padding), and repeating until no more masks can be combined."
def consolidate(self, masks, width=512, height=512, padding=0):
B, H, W = masks.shape
def mask_fits(coords, candidate_coords):
x_min, y_min, x_max, y_max = coords
cx_min, cy_min, cx_max, cy_max = candidate_coords
nx_min, ny_min = min(x_min, cx_min), min(y_min, cy_min)
nx_max, ny_max = max(x_max, cx_max), max(y_max, cy_max)
if nx_min + width < nx_max + padding or ny_min + height < ny_max + padding:
return False, coords
return True, (nx_min, ny_min, nx_max, ny_max)
separated = []
final_masks = []
for b in range(B):
m = masks[b]
rows, cols = m.any(dim=1), m.any(dim=0)
y_min, y_max = torch.where(rows)[0][[0, -1]]
x_min, x_max = torch.where(cols)[0][[0, -1]]
w = x_max - x_min + 1
h = y_max - y_min + 1
separated.append(((x_min.item(), y_min.item(), x_max.item(), y_max.item()), m))
separated.sort(key=lambda x: x[0])
fits = []
for i, masks in enumerate(separated):
coord = masks[0]
fits_in_box = []
for j, cand_mask in enumerate(separated):
if i == j:
continue
r, coord = mask_fits(coord, cand_mask[0])
if r:
fits_in_box.append(j)
fits.append((i, fits_in_box))
fits.sort(key=lambda x: -len(x[1]))
seen = []
unique_fits = []
for idx, fs in fits:
uniq = [i for i in fs if i not in seen]
unique_fits.append((idx, fs, uniq))
seen.extend(uniq)
unique_fits.sort(key=lambda x: (-len(x[1]), -len(x[2])))
merged = []
for mask_idx, fitting_masks, _ in unique_fits:
if mask_idx in merged:
continue
fitting_masks = [i for i in fitting_masks if i not in merged]
combined_mask = separated[mask_idx][1].clone()
for i in fitting_masks:
combined_mask += separated[i][1]
merged.append(i)
merged.append(mask_idx)
final_masks.append(combined_mask)
logging.info(f"Consolidated {B} masks into {len(final_masks)}")
return (torch.stack(final_masks, dim=0),)
class DrawMaskOnImage:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"image": ("IMAGE", ),
"mask": ("MASK", ),
"color": ("STRING", {"default": "0, 0, 0", "tooltip": "Color as RGB/RGBA values in range 0-255 or 0.0-1.0, separated by commas. Ex: 255, 0, 0, 128"}),
},
"optional": {
"device": (["cpu", "gpu"], {"default": "cpu", "tooltip": "Device to use for processing"}),
}
}
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("images",)
FUNCTION = "apply"
CATEGORY = "KJNodes/masking"
DESCRIPTION = "Applies the provided masks to the input images with Alpha Blending support."
def apply(self, image, mask, color, device="cpu"):
B, H, W, C = image.shape
BM, HM, WM = mask.shape
processing_device = main_device if device == "gpu" else torch.device("cpu")
in_masks = mask.clone().to(processing_device)
in_images = image.clone().to(processing_device)
# Resize mask if dimensions don't match
if HM != H or WM != W:
in_masks = F.interpolate(mask.unsqueeze(1), size=(H, W), mode='nearest-exact').squeeze(1)
# Handle batch size mismatch
if B > BM:
in_masks = in_masks.repeat((B + BM - 1) // BM, 1, 1)[:B]
elif BM > B:
in_masks = in_masks[:B]
output_images = []
# Parse Color String (Handle RGB, RGBA, and Hex formats)
color = color.strip()
color_values = []
if color.startswith('#'):
# Handle hex format (#RGB, #RGBA, #RRGGBB, #RRGGBBAA)
hex_color = color.lstrip('#')
if len(hex_color) == 3: # #RGB
color_values = [int(c*2, 16) / 255.0 for c in hex_color]
elif len(hex_color) == 4: # #RGBA
color_values = [int(c*2, 16) / 255.0 for c in hex_color]
elif len(hex_color) == 6: # #RRGGBB
color_values = [int(hex_color[i:i+2], 16) / 255.0 for i in (0, 2, 4)]
elif len(hex_color) == 8: # #RRGGBBAA
color_values = [int(hex_color[i:i+2], 16) / 255.0 for i in (0, 2, 4, 6)]
else:
raise ValueError(f"Invalid hex color format: {color}")
else:
# Handle comma-separated RGB/RGBA format
for x in color.split(","):
val = float(x.strip())
color_values.append(val / 255.0 if val > 1.0 else val)
rgb = color_values[:3]
alpha_val = color_values[3] if len(color_values) == 4 else 1.0
fill_color = torch.tensor(rgb, dtype=torch.float32, device=processing_device)
for i in tqdm(range(B), desc="DrawMaskOnImage batch"):
curr_mask = in_masks[i] # [H, W]
img_idx = min(i, B - 1)
curr_image = in_images[img_idx] # [H, W, C]
blend_factor = curr_mask.unsqueeze(-1) * alpha_val
img_channels = curr_image.shape[-1]
if img_channels == 4:
img_rgb = curr_image[..., :3]
img_a = curr_image[..., 3:]
out_rgb = img_rgb * (1 - blend_factor) + fill_color * blend_factor
out_a = torch.maximum(img_a, blend_factor)
masked_image = torch.cat((out_rgb, out_a), dim=-1)
else:
masked_image = curr_image * (1 - blend_factor) + fill_color * blend_factor
output_images.append(masked_image)
if not output_images:
return (torch.zeros((0, H, W, C), dtype=image.dtype),)
out_tensor = torch.stack(output_images, dim=0).cpu()
return (out_tensor, )
class BlockifyMask:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"masks": ("MASK",),
"block_size": ("INT", {"default": 32, "min": 8, "max": 512, "step": 1, "tooltip": "Size of blocks in pixels (smaller = smaller blocks)"}),
},
"optional": {
"device": (["cpu", "gpu"], {"default": "cpu", "tooltip": "Device to use for processing"}),
}
}
RETURN_TYPES = ("MASK", )
RETURN_NAMES = ("mask",)
FUNCTION = "process"
CATEGORY = "KJNodes/masking"
DESCRIPTION = "Creates a block mask by dividing the bounding box of each mask into blocks of the specified size and filling in blocks that contain any part of the original mask."
def process(self, masks, block_size, device="cpu"):
processing_device = main_device if device == "gpu" else torch.device("cpu")
masks = masks.to(processing_device)
batch_size, height, width = masks.shape
result_masks = torch.zeros_like(masks)
for i in tqdm(range(batch_size), desc="BlockifyMask batch"):
mask = masks[i]
# Find bounding box efficiently
mask_bool = mask > 0
if not mask_bool.any():
continue
y_indices = torch.nonzero(mask_bool.any(dim=1), as_tuple=True)[0]
x_indices = torch.nonzero(mask_bool.any(dim=0), as_tuple=True)[0]
if len(y_indices) == 0 or len(x_indices) == 0:
continue
y_min, y_max = y_indices[0], y_indices[-1]
x_min, x_max = x_indices[0], x_indices[-1]
bbox_width = x_max - x_min + 1
bbox_height = y_max - y_min + 1
# Calculate block grid
w_divisions = max(1, bbox_width // block_size)
h_divisions = max(1, bbox_height // block_size)
w_slice = bbox_width // w_divisions
h_slice = bbox_height // h_divisions
# Create coordinate grids only for bbox region
y_coords = torch.arange(y_min, y_max + 1, device=processing_device).view(-1, 1)
x_coords = torch.arange(x_min, x_max + 1, device=processing_device).view(1, -1)
# Calculate block indices for bbox region
w_block_indices = (x_coords - x_min) // w_slice
h_block_indices = (y_coords - y_min) // h_slice
# Clamp to valid range
w_block_indices = w_block_indices.clamp(0, w_divisions - 1)
h_block_indices = h_block_indices.clamp(0, h_divisions - 1)
# Create unique block IDs by combining h and w indices
block_ids = h_block_indices * w_divisions + w_block_indices
# Get mask region within bbox
mask_region = mask[y_min:y_max+1, x_min:x_max+1]
# Find which blocks have content using scatter_add
max_blocks = h_divisions * w_divisions
block_content = torch.zeros(max_blocks, device=processing_device)
block_content.scatter_add_(0, block_ids.flatten(), mask_region.flatten())
# Create result for blocks that have content
has_content = block_content > 0
block_mask = has_content[block_ids]
# Fill the result
result_masks[i, y_min:y_max+1, x_min:x_max+1] = block_mask.float()
return (result_masks.clamp(0, 1),)
-295
View File
@@ -1,295 +0,0 @@
import inspect
import logging
import types
import torch
import comfy.model_management as mm
import comfy.ops
import comfy.quant_ops
from comfy.ldm.modules.attention import optimized_attention
from comfy.ldm.minimax.model import _mod_scale_shift, _mod_gate, PackedLayout
from comfy_api.latest import io, ui
try:
from server import PromptServer
except Exception:
PromptServer = None
def minimax_mlp_chunked_forward(self, x):
# x is packed (S, hidden); rows are independent and int8 activation quant is per-row, so token chunking is exact
if x.shape[0] > self.kj_seq_threshold and self.kj_num_chunks > 1:
out = torch.empty_like(x)
offset = 0
for c in torch.chunk(x, self.kj_num_chunks, dim=0):
out[offset:offset + c.shape[0]] = comfy.ops.linear_input_act(self.fc2, self.fc1(c), "swiglu")
offset += c.shape[0]
return out
return comfy.ops.linear_input_act(self.fc2, self.fc1(x), "swiglu")
class MiniMaxFFNChunkPatch:
def __init__(self, num_chunks, seq_threshold):
self.num_chunks = num_chunks
self.seq_threshold = seq_threshold
def __get__(self, obj, objtype=None):
def wrapped_forward(self_module, *args, **kwargs):
self_module.kj_num_chunks = self.num_chunks
self_module.kj_seq_threshold = self.seq_threshold
return minimax_mlp_chunked_forward(self_module, *args, **kwargs)
return types.MethodType(wrapped_forward, obj)
class MiniMaxChunkFeedForward(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="MiniMaxChunkFeedForward",
display_name="MiniMax H3 Chunk FeedForward",
category="KJNodes/experimental",
description="Chunks the MiniMax H3 feedforward (SwiGLU) over the packed token dim to reduce peak VRAM usage. "
"Each chunk still goes through ComfyUI's fused INT8 kernel when the model is quantized, and since "
"activations are quantized per-token the output matches the unchunked model.",
is_experimental=True,
inputs=[
io.Model.Input("model"),
io.Int.Input("chunks", default=2, min=1, max=64, step=1, tooltip="Number of chunks to split the feedforward tokens into. More chunks = lower peak VRAM, slightly more overhead."),
io.Int.Input("seq_threshold", default=4096, min=256, max=262144, step=256, tooltip="Only chunk when the packed token count exceeds this (skips tiny sequences)."),
],
outputs=[
io.Model.Output(display_name="model"),
],
)
@classmethod
def execute(cls, model, chunks, seq_threshold) -> io.NodeOutput:
if chunks == 1:
return io.NodeOutput(model)
m = model.clone()
diffusion_model = m.get_model_object("diffusion_model")
blocks = getattr(diffusion_model, "blocks", None)
if not blocks or not hasattr(blocks[0], "mlp") or not hasattr(blocks[0].mlp, "fc1"):
logging.warning("MiniMaxChunkFeedForward: model does not look like MiniMax H3 "
"(expected diffusion_model.blocks[*].mlp.fc1/fc2); returning model unchanged.")
return io.NodeOutput(model)
for idx, block in enumerate(blocks):
patched = MiniMaxFFNChunkPatch(chunks, seq_threshold).__get__(block.mlp, block.mlp.__class__)
m.add_object_patch(f"diffusion_model.blocks.{idx}.mlp.forward", patched)
return io.NodeOutput(m)
def minimax_attn_lowmem_forward(self, x, rope_freqs=None, transformer_options={}):
# Attention.forward restructured to free the normed h right after the qkv GEMM
# and the fused (S, 3*inner) qkv buffer before out_proj allocates
if isinstance(x, list):
x = x.pop()
s = x.shape[0]
device = x.device
dtype = x.dtype
qkv = self.qkv_proj(x)
del x
q, k, v = qkv.split(self.heads * self.head_dim, dim=-1)
v = v.view(s, self.heads, self.head_dim)
if rope_freqs is not None:
q = q.view(1, s, self.heads, self.head_dim)
k = k.view(1, s, self.heads, self.head_dim)
qw = mm.cast_to(self.q_norm.weight, device=device)
kw = mm.cast_to(self.k_norm.weight, device=device)
rot = rope_freqs.shape[-3] * 2
if mm.in_training:
q, k = comfy.quant_ops.ck.rms_rope_split_half(
q, k, rope_freqs, qw, kw, epsilon=self.q_norm.eps, rot_dim=rot)
else:
comfy.quant_ops.ck.rms_rope_split_half_(
q, k, rope_freqs, qw, kw, epsilon=self.q_norm.eps, rot_dim=rot)
q = q[0]
k = k[0]
else:
q = self.q_norm(q.view(s, self.heads, self.head_dim))
k = self.k_norm(k.view(s, self.heads, self.head_dim))
q = q.transpose(0, 1).unsqueeze(0)
k = k.transpose(0, 1).unsqueeze(0)
v = v.transpose(0, 1).unsqueeze(0)
n = min(transformer_options.get("minimax_head_chunks", 1), self.heads) if isinstance(transformer_options, dict) else 1
if n <= 1:
out = optimized_attention(q, k, v, self.heads, mask=None, skip_reshape=True, transformer_options=transformer_options).squeeze(0)
else:
# heads are independent so per-group attention is exact; the kernel's internal temps scale with heads per call
out = torch.empty((s, self.heads * self.head_dim), dtype=dtype, device=device)
hs = 0
for size in ([self.heads // n + (1 if i < self.heads % n else 0) for i in range(n)]):
he = hs + size
o = optimized_attention(q[:, hs:he], k[:, hs:he], v[:, hs:he], size, mask=None, skip_reshape=True, transformer_options=transformer_options)
out[:, hs * self.head_dim:he * self.head_dim] = o.squeeze(0)
hs = he
del q, k, v, qkv
return self.out_proj(out)
# attention overrides (e.g. Sol-Attn) compose via optimized_attention instead of wrapping this forward
minimax_attn_lowmem_forward._uses_optimized_attention = True
def minimax_block_lowmem_forward(self, x, t_emb, mod_segments, rope_freqs, transformer_options={}, attention=None):
# DiTBlock.forward, but hands h to attn in a list so attn can free it after the qkv GEMM
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.adaln_proj(t_emb)
h = _mod_scale_shift(self.norm1(x), shift_msa, scale_msa, mod_segments)
if attention is None:
attention = self.attn
h = [h]
x = _mod_gate(x, gate_msa, attention(h, rope_freqs=rope_freqs, transformer_options=transformer_options), mod_segments)
h = _mod_scale_shift(self.norm2(x), shift_mlp, scale_mlp, mod_segments)
return _mod_gate(x, gate_mlp, self.mlp(h), mod_segments)
class MiniMaxLowVRAMAttention(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="MiniMaxLowVRAMAttention",
display_name="MiniMax H3 Low VRAM Attention",
category="KJNodes/experimental",
description="Reduces peak VRAM of the MiniMax H3 attention without changing the math. Splits the attention "
"call into head groups so the kernel's internal transients (int8 q/k copies, fp32 accumulator) "
"shrink by the chunk count while the fused qkv buffer is alive, and frees the qkv buffer / "
"normed block input as soon as they're consumed. Output is identical to the unpatched model.",
is_experimental=True,
inputs=[
io.Model.Input("model"),
io.Int.Input("head_chunks", default=4, min=1, max=56, step=1, tooltip="Number of head groups to split the attention call into. Kernel-internal transients scale down with this; heads are independent so the result is unchanged."),
],
outputs=[
io.Model.Output(display_name="model"),
],
)
@classmethod
def execute(cls, model, head_chunks) -> io.NodeOutput:
m = model.clone()
diffusion_model = m.get_model_object("diffusion_model")
blocks = getattr(diffusion_model, "blocks", None)
if not blocks or not hasattr(blocks[0], "attn") or not hasattr(blocks[0].attn, "qkv_proj"):
logging.warning("MiniMaxLowVRAMAttention: model does not look like MiniMax H3 "
"(expected diffusion_model.blocks[*].attn.qkv_proj); returning model unchanged.")
return io.NodeOutput(model)
# the attn forwards read this per-branch, so head slicing follows the node in either chain order
if head_chunks > 1:
m.model_options["transformer_options"]["minimax_head_chunks"] = head_chunks
# Sol-Attn's compose gate uses this instead of the stock forward, keeping the low-VRAM path on sparse calls
m.model_options["transformer_options"]["sol_take_forward"] = minimax_attn_lowmem_forward
composed = False
for idx, block in enumerate(blocks):
m.add_object_patch(f"diffusion_model.blocks.{idx}.forward", types.MethodType(minimax_block_lowmem_forward, block))
attn_key = f"diffusion_model.blocks.{idx}.attn.forward"
if attn_key in m.object_patches:
# another attention patch (e.g. mem eff sage) owns this key; keep it, it picks up head_chunks from transformer_options
composed = True
continue
m.add_object_patch(attn_key, types.MethodType(minimax_attn_lowmem_forward, block.attn))
if composed:
logging.info("MiniMaxLowVRAMAttention: composing with an existing attention patch; "
"keeping its forward, adding the block-level h release and passing head_chunks through.")
return io.NodeOutput(m)
class MiniMaxH3TokenCounter(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="MiniMaxH3TokenCounter",
display_name="MiniMax H3 Token Counter",
category="KJNodes/misc",
description="Counts the packed sequence length a MiniMax H3 generation will run at, without sampling. "
"Builds the same [text | keyframes/references | audio | video] layout the model uses, from the "
"AV latent and the conditioning (text length, keyframe and reference blocks included).",
inputs=[
io.Latent.Input("samples", tooltip="The AV latent that would be sampled (video+audio pair from the H3 latent nodes)."),
io.Conditioning.Input("conditioning", tooltip="Positive conditioning; text length and any keyframe/reference blocks are read from it."),
],
outputs=[
io.Latent.Output(display_name="samples", tooltip="The input latent, unchanged."),
io.Conditioning.Output(display_name="conditioning", tooltip="The input conditioning, unchanged."),
io.Int.Output(display_name="tokens"),
io.String.Output(display_name="breakdown"),
],
hidden=[io.Hidden.unique_id],
)
@classmethod
def execute(cls, samples, conditioning) -> io.NodeOutput:
latent = samples["samples"]
if getattr(latent, "is_nested", False):
video, audio = latent.unbind()[:2]
audio_t = audio.shape[-1]
else:
video, audio_t = latent, 0
if video.ndim != 5:
raise ValueError(f"MiniMaxH3TokenCounter: expected a video latent of shape [B, C, T, H, W], got {tuple(video.shape)}")
# mirror model_base.MiniMaxH3.extra_conds: h/w rounded up to the DiT's 2x2 patch
latent_t = video.shape[2]
lat_h = (video.shape[3] + 1) // 2 * 2
lat_w = (video.shape[4] + 1) // 2 * 2
# older comfy versions have a PackedLayout without some kwargs; only fail if the conditioning actually uses one
supported = set(inspect.signature(PackedLayout.__init__).parameters)
def build_layout(cond, cond_dict):
kwargs = {"keyframes": cond_dict.get("minimax_keyframes"),
"refs": cond_dict.get("minimax_refs"),
"frame_count": cond_dict.get("minimax_frame_count")}
missing = [k for k, v in kwargs.items() if v is not None and k not in supported]
if missing:
raise RuntimeError(f"MiniMaxH3TokenCounter: this ComfyUI version's PackedLayout does not support {missing}, "
"update ComfyUI to count this conditioning correctly.")
return PackedLayout(cond.shape[1], latent_t, lat_h, lat_w, audio_t,
**{k: v for k, v in kwargs.items() if v is not None and k in supported})
# scheduled conds can differ in text length; report the largest sequence
layout = max((build_layout(cond, cond_dict) for cond, cond_dict in conditioning),
key=lambda l: l.seq_len)
rows = {}
seg_count = {}
for a, b, kind in layout.segments:
rows[kind] = rows.get(kind, 0) + (b - a)
seg_count[kind] = seg_count.get(kind, 0) + 1
parts = [("total", str(layout.seq_len))]
# int32 offset limit of the sageattention quant/attention kernels at H3's 56x128 heads
if layout.seq_len * 7168 >= 2**31:
parts.append(("WARNING", f"over the int32-safe attention range ({2**31 // 7168} tokens), sageattention kernels may overflow"))
parts.append(("text", str(rows.get("text", 0))))
if "cond" in rows:
parts.append(("keyframes", f"{rows['cond']} ({seg_count['cond']} frame{'s' if seg_count['cond'] > 1 else ''})"))
if "ref_img" in rows:
parts.append(("image/video refs", f"{rows['ref_img']} ({seg_count['ref_img']} block{'s' if seg_count['ref_img'] > 1 else ''})"))
if "ref_audio" in rows:
parts.append(("audio refs", f"{rows['ref_audio']} ({seg_count['ref_audio']} block{'s' if seg_count['ref_audio'] > 1 else ''})"))
parts.append(("audio", str(rows.get("audio", 0))))
parts.append(("video", f"{rows.get('video', 0)} ({latent_t}x{lat_h // 2}x{lat_w // 2} patches)"))
breakdown = "\n".join(f"{k}: {v}" for k, v in parts)
# inline display on the node; the frontend text preview strips all tags except <br>/<a>, so plain text only
unique_id = getattr(cls.hidden, "unique_id", None)
if PromptServer is not None and unique_id:
try:
PromptServer.instance.send_progress_text(breakdown.replace("WARNING:", "⚠"), unique_id)
except Exception:
pass
return io.NodeOutput(samples, conditioning, layout.seq_len, breakdown, ui=ui.PreviewText(breakdown))
File diff suppressed because it is too large Load Diff
+117 -897
View File
File diff suppressed because it is too large Load Diff
-773
View File
@@ -1,773 +0,0 @@
import torch
from comfy_execution.graph_utils import GraphBuilder, is_link
from comfy_api.latest import io
from comfy.nested_tensor import NestedTensor
import comfy.utils
NUM_FLOW_SOCKETS = 5
def _is_nested(x):
return isinstance(x, NestedTensor)
def _temporal_frame_count(samples):
"""Count temporal frames from a samples tensor or NestedTensor."""
if _is_nested(samples):
return samples.tensors[0].shape[2] # count from first sub-tensor (video)
if samples.ndim == 5:
return samples.shape[2] # (B,C,T,H,W)
return samples.shape[0] # (B,C,H,W) — batch count
def _accum_count(accum, blend_overlap=0):
"""Count items in an accumulation, handling tensors (Image/Mask) and dicts (Latent/Video Latent).
blend_overlap: frames consumed per seam by post-loop crossfade blending (fade modes)."""
if not isinstance(accum, dict) or "accum" not in accum:
return 0
total = 0
for item in accum["accum"]:
if isinstance(item, dict):
total += _temporal_frame_count(item["samples"])
else:
total += item.shape[0] # IMAGE/MASK: count batch items
if blend_overlap > 0 and len(accum["accum"]) > 1:
total -= (len(accum["accum"]) - 1) * blend_overlap
return total
class TensorLoopOpen(io.ComfyNode):
"""
Opens a loop that collects outputs. Supports two modes:
- iterations: runs a fixed number of iterations
- total_frames: runs until the target number of frames is accumulated
Wire flow_control → TensorLoopClose, use `previous_value` as input to your
generation, and connect the generated output → TensorLoopClose.processed.
Supports IMAGE, MASK, and LATENT types.
"""
MATCHTYPE = io.MatchType.Template("data", allowed_types=[io.Image, io.Mask, io.Latent])
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="TensorLoopOpen",
display_name="Open Tensor Loop",
category="advanced/looping",
inputs=[
io.DynamicCombo.Input("mode", tooltip="Loop termination mode.", options=[
io.DynamicCombo.Option("iterations", [
io.Int.Input("iterations", default=4, min=0,
tooltip="Number of loop iterations. Set to 0 to skip looping and pass through initial_value."),
]),
io.DynamicCombo.Option("total_frames", [
io.Int.Input("total_frames", default=100, min=1,
tooltip="Target number of output frames. The loop continues until this many frames are accumulated."),
]),
]),
io.MatchType.Input("initial_value", template=cls.MATCHTYPE, optional=True,
tooltip="Optional value to use as `previous_value` on the first iteration."),
],
outputs=[
io.FlowControl.Output("flow_control"),
io.MatchType.Output(cls.MATCHTYPE, id="previous_value",
tooltip="The value from the previous iteration (or initial_value on first pass)."),
io.Int.Output("accumulated_count", tooltip="Number of items collected so far (0 on first iteration)."),
io.Int.Output("current_iteration", tooltip="Current iteration index (1-based)."),
],
hidden=[io.Hidden.unique_id],
accept_all_inputs=True,
)
@classmethod
def execute(cls, mode: dict, initial_value=None, **kwargs) -> io.NodeOutput:
unique_id = cls.hidden.unique_id
state = kwargs.get("initial_value0") # packed state dict or None on first pass
if state is not None:
count = state["count"]
total_frames_val = state["total_frames"]
remaining = state["remaining"]
accum = state["accum"]
previous_value = state["previous_value"]
open_node_id = state["open_node_id"]
blend_overlap = state.get("blend_overlap", 0)
else:
selected_mode = mode.get("mode", "iterations")
count = mode.get("iterations", 4) if selected_mode == "iterations" else 0
total_frames_val = mode.get("total_frames", 100) if selected_mode == "total_frames" else 0
remaining = count
accum = None
previous_value = initial_value
open_node_id = unique_id
blend_overlap = 0
accumulated_count = _accum_count(accum, blend_overlap)
# In total_frames mode, count=0 and remaining goes negative each iteration.
# The math still produces correct 1-based iteration: 0-0+1=1, 0-(-1)+1=2, etc.
current_iteration = count - remaining + 1
if total_frames_val > 0:
comfy.utils.ProgressBar(total_frames_val, node_id=open_node_id).update_absolute(accumulated_count)
elif count > 0:
comfy.utils.ProgressBar(count, node_id=open_node_id).update_absolute(count - remaining)
loop_state = {"remaining": remaining, "accum": accum, "previous_value": previous_value, "count": count, "open_node_id": open_node_id, "total_frames": total_frames_val, "blend_overlap": blend_overlap}
return io.NodeOutput(loop_state, previous_value, accumulated_count, current_iteration)
def _overlap_option(name, tooltip):
"""DynamicCombo option with an overlap_frames count input."""
return io.DynamicCombo.Option(name, [
io.Int.Input("overlap_frames", default=8, min=1, tooltip=tooltip),
])
class TensorLoopClose(io.ComfyNode):
"""
Closes the loop started by TensorLoopOpen.
Connect:
- flow_control from TensorLoopOpen
- processed: the output generated this iteration
Supports IMAGE, MASK, and LATENT types.
"""
MATCHTYPE = io.MatchType.Template("data", allowed_types=[io.Image, io.Mask, io.Latent])
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="TensorLoopClose",
display_name="Close Tensor Loop",
category="advanced/looping",
inputs=[
io.FlowControl.Input("flow_control", raw_link=True),
io.MatchType.Input("processed", template=cls.MATCHTYPE, raw_link=True, tooltip="Output generated this iteration."),
io.Boolean.Input("accumulate", default=True,
tooltip="When enabled, collects all iterations into a batch. When disabled, only outputs the final iteration's result."),
io.DynamicCombo.Input("overlap", tooltip="How to handle duplicate frames where consecutive iterations overlap (e.g. context frames a video model re-generates).\n"
"- disabled: keep every frame as-is\n"
"- start/end: cut the duplicates from the start or end of each iteration's output\n"
"- fade_linear/fade_smooth: crossfade the end of each iteration into the start of the next", options=[
io.DynamicCombo.Option("disabled", []),
_overlap_option("start", "Number of frames to cut from the START of each iteration's output. Use when the model re-generates its context frames at the start of its output (most common for video continuation). The first generation is always kept whole; only iterations 2+ are trimmed."),
_overlap_option("end", "Number of frames to cut from the END of each iteration's output. Use when the model generates look-ahead frames at the end of its output that the next iteration re-generates. The trimmed tail of the final iteration is re-appended."),
_overlap_option("fade_linear", "Number of overlapping frames to crossfade: the end of each iteration is blended into the start of the next with a linear ramp. The first generation is kept whole."),
_overlap_option("fade_smooth", "Number of overlapping frames to crossfade: the end of each iteration is blended into the start of the next with a smoothstep (ease in/out) ramp. The first generation is kept whole."),
]),
io.Boolean.Input("stop", optional=True, default=False, raw_link=True, force_input=True,
tooltip="Optional early stop signal from inside the loop body. When True, the loop stops after the current iteration regardless of remaining iterations or total_frames target."),
],
outputs=[
io.MatchType.Output(cls.MATCHTYPE, id="output", tooltip="Accumulated batch or final iteration result, depending on 'accumulate' setting."),
],
enable_expand=True,
)
@classmethod
def execute(cls, flow_control, processed, accumulate=True, overlap=None, stop=False) -> io.NodeOutput:
graph = GraphBuilder()
open_id = flow_control[0]
unpack = graph.node("_ImageAccumStateUnpack", loop_state=[open_id, 0])
# unpack: 0=remaining, 1=accum, 2=previous_value, 3=accumulated_count, 4=count, 5=open_node_id, 6=total_frames
sub = graph.node("_IntOperations", operation="subtract", a=unpack.out(0), b=1)
overlap_mode = "disabled"
overlap_frames = 0
if isinstance(overlap, dict):
overlap_mode = overlap.get("overlap", "disabled")
overlap_frames = overlap.get("overlap_frames", 0)
accum_out = unpack.out(1)
if accumulate:
to_accum = processed
if overlap_frames > 0 and overlap_mode != "disabled":
# The first generation has no preceding chunk, so it is kept whole; only the seams
# of iterations 2+ are trimmed. start_trim is 0 on iteration 1 (a _BatchOps no-op).
if overlap_mode == "start":
iter_index = graph.node("_IntOperations", a=unpack.out(4), b=unpack.out(0), operation="subtract")
not_first = graph.node("_IntOperations", a=iter_index.out(0), b=0, operation=">")
start_trim = graph.node("_IntOperations", a=not_first.out(0), b=overlap_frames, operation="multiply")
to_accum = graph.node("_BatchOps", batch=processed, operation="trim_start", amount=start_trim.out(0)).out(0)
elif overlap_mode == "end":
to_accum = graph.node("_BatchOps", batch=processed, operation="trim_end", amount=overlap_frames).out(0)
# fade modes blend the seams post-loop
accum_out = graph.node("_AccumulateNode", to_add=to_accum, accumulation=accum_out).out(0)
# Disable total_frames when not accumulating to avoid infinite loops
blend_overlap = overlap_frames if overlap_mode in ("fade_linear", "fade_smooth") else 0
pack = graph.node("_ImageAccumStatePack",
remaining=sub.out(0),
accum=accum_out,
previous_value=processed,
count=unpack.out(4),
open_node_id=unpack.out(5),
total_frames=unpack.out(6) if accumulate else 0,
prev_accumulated_count=unpack.out(3),
blend_overlap=blend_overlap if accumulate else 0,
)
# Optional early stop from loop body
if is_link(stop):
condition = graph.node("_ConditionalSelect",
condition=stop,
value_if_true=False,
value_if_false=pack.out(1),
).out(0)
else:
condition = pack.out(1)
while_close = graph.node(
"_WhileLoopClose",
flow_control=flow_control,
condition=condition,
initial_value0=pack.out(0),
)
final_unpack = graph.node("_ImageAccumStateUnpack", loop_state=while_close.out(0))
if accumulate:
if overlap_mode in ("fade_linear", "fade_smooth") and overlap_frames > 0:
result = graph.node("_AccumulationToImageBatch",
accumulation=final_unpack.out(1),
overlap_frames=overlap_frames,
overlap_mode=overlap_mode,
).out(0)
else:
result = graph.node("_AccumulationToImageBatch", accumulation=final_unpack.out(1)).out(0)
# End mode: re-append the tail that was trimmed from the last iteration
if overlap_mode == "end" and overlap_frames > 0:
tail = graph.node("_BatchOps", batch=final_unpack.out(2), operation="keep_end", amount=overlap_frames).out(0)
result = graph.node("_BatchOps", batch=result, operation="concat", batch_b=tail).out(0)
result = graph.node("_BatchOps", batch=result, operation="max_count", amount=final_unpack.out(6)).out(0)
else:
result = final_unpack.out(2)
# Bypass: when iterations==0 AND total_frames==0, return initial_value directly
count_is_zero = graph.node("_IntOperations", a=unpack.out(4), b=0, operation="==")
tf_is_zero = graph.node("_IntOperations", a=unpack.out(6), b=0, operation="==")
both_zero = graph.node("_IntOperations", a=count_is_zero.out(0), b=tf_is_zero.out(0), operation="multiply")
result = graph.node("_ConditionalSelect",
condition=both_zero.out(1),
value_if_true=unpack.out(2),
value_if_false=result,
).out(0)
return io.NodeOutput(result, expand=graph.finalize())
# Internal helper nodes — dev only, hidden from the node menu.
class _AccumulateNode(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="_AccumulateNode",
display_name="Accumulate",
category="advanced/looping",
is_dev_only=True,
inputs=[
io.AnyType.Input("to_add"),
io.Accumulation.Input("accumulation", optional=True),
],
outputs=[
io.Accumulation.Output(),
],
)
@classmethod
def execute(cls, to_add, accumulation=None) -> io.NodeOutput:
if accumulation is None:
value = [to_add]
else:
value = accumulation["accum"] + [to_add]
return io.NodeOutput({"accum": value})
class _WhileLoopOpen(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="_WhileLoopOpen",
display_name="While Loop Open",
category="looping",
is_dev_only=True,
inputs=[
io.Boolean.Input("condition", default=True),
*[io.AnyType.Input(f"initial_value{i}", optional=True) for i in range(NUM_FLOW_SOCKETS)],
],
outputs=[
io.FlowControl.Output("flow_control", display_name="FLOW_CONTROL"),
*[io.AnyType.Output(f"value{i}") for i in range(NUM_FLOW_SOCKETS)],
],
accept_all_inputs=True,
)
@classmethod
def execute(cls, condition: bool, **kwargs) -> io.NodeOutput:
values = [kwargs.get(f"initial_value{i}", None) for i in range(NUM_FLOW_SOCKETS)]
return io.NodeOutput("stub", *values)
class _WhileLoopClose(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="_WhileLoopClose",
display_name="While Loop Close",
category="looping",
is_dev_only=True,
inputs=[
io.FlowControl.Input("flow_control", raw_link=True),
io.Boolean.Input("condition", force_input=True),
*[io.AnyType.Input(f"initial_value{i}", optional=True) for i in range(NUM_FLOW_SOCKETS)],
],
outputs=[
*[io.AnyType.Output(f"value{i}") for i in range(NUM_FLOW_SOCKETS)],
],
hidden=[io.Hidden.dynprompt, io.Hidden.unique_id],
enable_expand=True,
accept_all_inputs=True,
)
@staticmethod
def _explore_dependencies(node_id, dynprompt, upstream):
node_info = dynprompt.get_node(node_id)
if "inputs" not in node_info:
return
for k, v in node_info["inputs"].items():
if is_link(v):
parent_id = v[0]
if parent_id not in upstream:
upstream[parent_id] = []
_WhileLoopClose._explore_dependencies(parent_id, dynprompt, upstream)
upstream[parent_id].append(node_id)
@staticmethod
def _collect_contained(node_id, upstream, contained):
if node_id not in upstream:
return
for child_id in upstream[node_id]:
if child_id not in contained:
contained[child_id] = True
_WhileLoopClose._collect_contained(child_id, upstream, contained)
@classmethod
def execute(cls, flow_control, condition: bool, **kwargs) -> io.NodeOutput:
dynprompt = cls.hidden.dynprompt
unique_id = cls.hidden.unique_id
values = [kwargs.get(f"initial_value{i}", None) for i in range(NUM_FLOW_SOCKETS)]
if not condition: # Done with the loop — return current values
return io.NodeOutput(*values)
# Build the graph expansion for the next loop iteration
upstream = {}
cls._explore_dependencies(unique_id, dynprompt, upstream)
contained = {}
open_node = flow_control[0]
cls._collect_contained(open_node, upstream, contained)
contained[unique_id] = True
contained[open_node] = True
# Use "Recurse" for this node's clone to avoid exponential name growth
graph = GraphBuilder()
for node_id in contained:
original_node = dynprompt.get_node(node_id)
node = graph.node(original_node["class_type"], "Recurse" if node_id == unique_id else node_id)
node.set_override_display_id(node_id)
for node_id in contained:
original_node = dynprompt.get_node(node_id)
node = graph.lookup_node("Recurse" if node_id == unique_id else node_id)
for k, v in original_node["inputs"].items():
if is_link(v) and v[0] in contained:
parent = graph.lookup_node(v[0])
node.set_input(k, parent.out(v[1]))
else:
node.set_input(k, v)
new_open = graph.lookup_node(open_node)
for i in range(NUM_FLOW_SOCKETS):
new_open.set_input(f"initial_value{i}", values[i])
my_clone = graph.lookup_node("Recurse")
result = tuple(my_clone.out(x) for x in range(NUM_FLOW_SOCKETS))
return io.NodeOutput(*result, expand=graph.finalize())
class _IntOperations(io.ComfyNode):
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="_IntOperations",
display_name="Int Operations",
category="advanced/looping",
is_dev_only=True,
inputs=[
io.Int.Input("a", default=0, min=-0xffffffffffffffff, max=0xffffffffffffffff, step=1),
io.Int.Input("b", default=0, min=-0xffffffffffffffff, max=0xffffffffffffffff, step=1),
io.Combo.Input("operation", options=[
"add", "subtract", "multiply", "divide", "modulo", "power",
"==", "!=", "<", ">", "<=", ">=",
]),
],
outputs=[
io.Int.Output(),
io.Boolean.Output(),
],
)
OPS = {
"add": lambda a, b: a + b, "subtract": lambda a, b: a - b,
"multiply": lambda a, b: a * b, "divide": lambda a, b: a // b if b else 0,
"modulo": lambda a, b: a % b if b else 0, "power": lambda a, b: a ** b,
"==": lambda a, b: a == b, "!=": lambda a, b: a != b,
"<": lambda a, b: a < b, ">": lambda a, b: a > b,
"<=": lambda a, b: a <= b, ">=": lambda a, b: a >= b,
}
@classmethod
def execute(cls, a: int, b: int, operation: str) -> io.NodeOutput:
result = cls.OPS[operation](a, b)
return io.NodeOutput(int(result), bool(result))
def _get_frame_count(x, dim):
"""Return the size along the given dimension."""
if isinstance(x, dict) and "samples" in x:
s = x["samples"]
return s.tensors[0].shape[dim] if _is_nested(s) else s.shape[dim]
return x.shape[dim]
def _get_cat_dim(x):
"""Return the concatenation dimension for a tensor: dim 2 for 5D video, dim 0 for everything else."""
if isinstance(x, dict):
s = x["samples"]
if _is_nested(s):
return 2 # NestedTensor sub-tensors use temporal dim
return 2 if s.ndim == 5 else 0
return 0 # IMAGE/MASK: batch dim
def _slice_tensor(x, dim, start=None, end=None):
"""Slice a tensor, latent dict, or NestedTensor along the given dim."""
if isinstance(x, dict) and "samples" in x:
result = x.copy()
samples = x["samples"]
if _is_nested(samples):
sliced = []
for t in samples.tensors:
sliced.append(t[(slice(None),) * dim + (slice(start, end),)])
result["samples"] = NestedTensor(sliced)
mask = x.get("noise_mask")
if mask is not None and _is_nested(mask):
mask_sliced = []
for t in mask.tensors:
mask_sliced.append(t[(slice(None),) * dim + (slice(start, end),)])
result["noise_mask"] = NestedTensor(mask_sliced)
else:
result["samples"] = samples[(slice(None),) * dim + (slice(start, end),)]
mask = x.get("noise_mask")
if mask is not None and mask.ndim == samples.ndim:
result["noise_mask"] = mask[(slice(None),) * dim + (slice(start, end),)]
return result
else:
return x[(slice(None),) * dim + (slice(start, end),)]
def _concat_tensor(a, b, dim):
"""Concatenate two tensors, latent dicts, or NestedTensors along the given dim."""
if isinstance(a, dict) and "samples" in a:
result = a.copy()
sa, sb = a["samples"], b["samples"]
if _is_nested(sa):
result["samples"] = NestedTensor([torch.cat([ta, tb], dim=dim) for ta, tb in zip(sa.tensors, sb.tensors)])
ma, mb = a.get("noise_mask"), b.get("noise_mask")
if ma is not None and mb is not None and _is_nested(ma) and _is_nested(mb):
result["noise_mask"] = NestedTensor([torch.cat([ta, tb], dim=dim) for ta, tb in zip(ma.tensors, mb.tensors)])
elif ma is not None and _is_nested(ma):
# Per-frame mask can't cover the concatenated frames — drop it
result.pop("noise_mask", None)
else:
result["samples"] = torch.cat([sa, sb], dim=dim)
ma, mb = a.get("noise_mask"), b.get("noise_mask")
if ma is not None and mb is not None and ma.ndim == sa.ndim and mb.ndim == sb.ndim:
result["noise_mask"] = torch.cat([ma, mb], dim=dim)
elif ma is not None and ma.ndim == sa.ndim:
# Per-frame mask can't cover the concatenated frames — drop it
result.pop("noise_mask", None)
return result
else:
return torch.cat([a, b], dim=dim)
def _blend_overlap(items, overlap_frames, mode):
"""Concatenate items with crossfade blending in the overlap regions."""
if not items:
return None
dim = _get_cat_dim(items[0])
# Clamp overlap to the smallest item size to avoid slicing beyond bounds
min_frames = min(_get_frame_count(item, dim) for item in items)
overlap_frames = min(overlap_frames, min_frames - 1) if min_frames > 1 else 0
if overlap_frames <= 0:
# Nothing to blend — fall through to simple concat
result = items[0]
for item in items[1:]:
result = _concat_tensor(result, item, dim)
return result
t = torch.linspace(0, 1, overlap_frames)
if mode == "fade_smooth":
t = t * t * (3 - 2 * t)
result = items[0]
for i in range(1, len(items)):
prev_tail = _slice_tensor(result, dim, start=-overlap_frames)
curr_head = _slice_tensor(items[i], dim, end=overlap_frames)
curr_rest = _slice_tensor(items[i], dim, start=overlap_frames)
result_base = _slice_tensor(result, dim, end=-overlap_frames)
# Lerp: blended = prev_tail * (1-w) + curr_head * w
if isinstance(prev_tail, dict):
blended = prev_tail.copy()
ps, cs = prev_tail["samples"], curr_head["samples"]
if _is_nested(ps):
blended_tensors = []
for pt, ch in zip(ps.tensors, cs.tensors):
w = t.to(pt).reshape([1] * dim + [overlap_frames] + [1] * (pt.ndim - dim - 1))
blended_tensors.append(pt * (1 - w) + ch * w)
blended["samples"] = NestedTensor(blended_tensors)
else:
w = t.to(ps).reshape([1] * dim + [overlap_frames] + [1] * (ps.ndim - dim - 1))
blended["samples"] = ps * (1 - w) + cs * w
else:
w = t.to(prev_tail).reshape([1] * dim + [overlap_frames] + [1] * (prev_tail.ndim - dim - 1))
blended = prev_tail * (1 - w) + curr_head * w
result = _concat_tensor(_concat_tensor(result_base, blended, dim), curr_rest, dim)
return result
class _AccumulationToImageBatch(io.ComfyNode):
"""Concatenates an ACCUMULATION of IMAGE/MASK tensors or LATENT dicts into a single batch."""
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="_AccumulationToImageBatch",
display_name="Accumulation to Batch",
category="advanced/looping",
is_dev_only=True,
inputs=[
io.Accumulation.Input("accumulation"),
io.Int.Input("overlap_frames", default=0, min=0),
io.Combo.Input("overlap_mode", options=["disabled", "fade_linear", "fade_smooth"], default="disabled"),
],
outputs=[io.AnyType.Output("result")],
)
@classmethod
def execute(cls, accumulation, overlap_frames=0, overlap_mode="disabled") -> io.NodeOutput:
items = accumulation["accum"]
if not items:
return io.NodeOutput(None)
# Fade modes: blend overlap regions between consecutive items
if overlap_mode in ("fade_linear", "fade_smooth") and overlap_frames > 0 and len(items) > 1:
return io.NodeOutput(_blend_overlap(items, overlap_frames, overlap_mode))
# Standard concatenation (no overlap or start/end trim was handled per-iteration)
if isinstance(items[0], dict):
samples = items[0]["samples"]
if _is_nested(samples):
num_sub = len(samples.tensors)
catted = []
for i in range(num_sub):
catted.append(torch.cat([item["samples"].tensors[i] for item in items], dim=2))
result = items[0].copy()
result["samples"] = NestedTensor(catted)
result.pop("noise_mask", None)
# Only keep masks when every item has one, otherwise the mask wouldn't cover all frames
masks = [item.get("noise_mask") for item in items]
if all(m is not None and _is_nested(m) for m in masks):
mask_catted = []
for i in range(len(masks[0].tensors)):
mask_catted.append(torch.cat([m.tensors[i] for m in masks], dim=2))
result["noise_mask"] = NestedTensor(mask_catted)
return io.NodeOutput(result)
elif samples.ndim == 5:
result = items[0].copy()
result["samples"] = torch.cat([item["samples"] for item in items], dim=2)
result.pop("noise_mask", None)
masks = [item.get("noise_mask") for item in items]
if all(m is not None and m.ndim == 5 for m in masks):
result["noise_mask"] = torch.cat(masks, dim=2)
return io.NodeOutput(result)
else:
# Image latent — batch along dim 0
result = items[0].copy()
result["samples"] = torch.cat([item["samples"] for item in items], dim=0)
result.pop("noise_mask", None)
masks = [item.get("noise_mask") for item in items]
if all(m is not None and m.ndim == samples.ndim for m in masks):
result["noise_mask"] = torch.cat(masks, dim=0)
return io.NodeOutput(result)
else:
return io.NodeOutput(torch.cat(items, dim=0))
class _ConditionalSelect(io.ComfyNode):
"""Returns value_if_true when condition is True, value_if_false otherwise.
Uses lazy evaluation so only the selected branch is resolved."""
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="_ConditionalSelect",
display_name="Conditional Select",
category="advanced/looping",
is_dev_only=True,
inputs=[
io.Boolean.Input("condition"),
io.AnyType.Input("value_if_true", lazy=True),
io.AnyType.Input("value_if_false", lazy=True),
],
outputs=[io.AnyType.Output("result")],
)
@classmethod
def check_lazy_status(cls, condition, value_if_true=None, value_if_false=None) -> list[str]:
# Unevaluated lazy inputs arrive as None
if condition and value_if_true is None:
return ["value_if_true"]
if not condition and value_if_false is None:
return ["value_if_false"]
return []
@classmethod
def execute(cls, condition, **kwargs) -> io.NodeOutput:
selected = kwargs.get("value_if_true") if condition else kwargs.get("value_if_false")
return io.NodeOutput(selected)
class _ImageAccumStatePack(io.ComfyNode):
"""Packs loop state into a single dict for TensorLoopOpen's initial_value0."""
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="_ImageAccumStatePack",
display_name="Image Accum State Pack",
category="advanced/looping",
is_dev_only=True,
inputs=[
io.AnyType.Input("remaining"),
io.Accumulation.Input("accum", optional=True),
io.AnyType.Input("previous_value"),
io.AnyType.Input("count"),
io.AnyType.Input("open_node_id"),
io.AnyType.Input("total_frames"),
io.Int.Input("prev_accumulated_count", default=0),
io.Int.Input("blend_overlap", default=0),
],
outputs=[
io.AnyType.Output("loop_state"),
io.Boolean.Output("should_continue"),
],
)
@classmethod
def execute(cls, remaining, accum, previous_value, count, open_node_id, total_frames, prev_accumulated_count=0, blend_overlap=0) -> io.NodeOutput:
# Effective count: fade modes consume blend_overlap frames per seam in the post-loop blend
accumulated_count = _accum_count(accum, blend_overlap)
if total_frames > 0:
should_continue = accumulated_count < total_frames
# Bail if the last iteration made no progress — the target is unreachable
if accumulated_count <= prev_accumulated_count:
should_continue = False
comfy.utils.ProgressBar(total_frames, node_id=open_node_id).update_absolute(accumulated_count)
else:
should_continue = remaining > 0
current_iteration = count - remaining
comfy.utils.ProgressBar(count, node_id=open_node_id).update_absolute(current_iteration)
return io.NodeOutput(
{"remaining": remaining, "accum": accum, "previous_value": previous_value, "count": count, "open_node_id": open_node_id, "total_frames": total_frames, "blend_overlap": blend_overlap},
should_continue,
)
class _ImageAccumStateUnpack(io.ComfyNode):
"""Unpacks loop_state from TensorLoopOpen."""
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="_ImageAccumStateUnpack",
display_name="Image Accum State Unpack",
category="advanced/looping",
is_dev_only=True,
inputs=[io.AnyType.Input("loop_state")],
outputs=[
io.Int.Output("remaining"),
io.Accumulation.Output("accumulation"),
io.AnyType.Output("previous_value"),
io.Int.Output("accumulated_count"),
io.Int.Output("count"),
io.AnyType.Output("open_node_id"),
io.Int.Output("total_frames"),
],
)
@classmethod
def execute(cls, loop_state) -> io.NodeOutput:
remaining = loop_state["remaining"]
accum = loop_state["accum"]
previous_value = loop_state["previous_value"]
count = loop_state.get("count", 0)
open_node_id = loop_state.get("open_node_id")
total_frames = loop_state.get("total_frames", 0)
accumulated_count = _accum_count(accum, loop_state.get("blend_overlap", 0))
return io.NodeOutput(remaining, accum, previous_value, accumulated_count, count, open_node_id, total_frames)
class _BatchOps(io.ComfyNode):
"""Batch slicing, trimming, and concatenation operations."""
@classmethod
def define_schema(cls) -> io.Schema:
return io.Schema(
node_id="_BatchOps",
display_name="Batch Ops",
category="advanced/looping",
is_dev_only=True,
inputs=[
io.AnyType.Input("batch"),
io.Combo.Input("operation", options=["max_count", "trim_start", "trim_end", "keep_end", "concat"]),
io.Int.Input("amount", default=0, min=0),
io.AnyType.Input("batch_b", optional=True),
],
outputs=[io.AnyType.Output("output")],
)
@classmethod
def execute(cls, batch, operation, amount=0, batch_b=None) -> io.NodeOutput:
dim = _get_cat_dim(batch)
if operation == "concat" and batch_b is not None:
return io.NodeOutput(_concat_tensor(batch, batch_b, dim))
if amount <= 0:
return io.NodeOutput(batch)
# Clamp to avoid slicing beyond the tensor size
total = _get_frame_count(batch, dim)
amount = min(amount, total)
if operation == "max_count":
return io.NodeOutput(_slice_tensor(batch, dim, end=amount))
elif operation == "trim_start":
return io.NodeOutput(_slice_tensor(batch, dim, start=amount))
elif operation == "trim_end":
return io.NodeOutput(_slice_tensor(batch, dim, end=-amount) if amount < total else batch)
elif operation == "keep_end":
return io.NodeOutput(_slice_tensor(batch, dim, start=-amount))
return io.NodeOutput(batch)
LOOP_NODES = [
TensorLoopOpen,
TensorLoopClose,
_WhileLoopOpen,
_WhileLoopClose,
_AccumulateNode,
_IntOperations,
_AccumulationToImageBatch,
_ConditionalSelect,
_ImageAccumStateUnpack,
_ImageAccumStatePack,
_BatchOps,
]
File diff suppressed because it is too large Load Diff
-259
View File
@@ -1,259 +0,0 @@
import torch
import torch.nn.functional as F
from comfy import model_management
from comfy_api.latest import io
class ImageSharpenKJ(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ImageSharpenKJ",
category="KJNodes/image",
display_name="Image Sharpen KJ",
search_aliases=["sharpen", "unsharp mask", "deconvolution", "cas", "rcas", "high-pass", "postprocessing"],
description="""GPU-accelerated image sharpening with multiple methods.
**RCAS** — AMD's Robust Contrast-Adaptive Sharpening (from FSR).
Single 5-tap cross filter that adapts to local contrast.
Minimal artifacts, good for general use with little tuning.
**Adaptive USM** — Unsharp mask with local variance modulation.
Sharpens detail-rich areas more, flat/noisy areas less.
More controllable than RCAS via radius and threshold parameters.
**High-Pass** — Extracts high-frequency detail and blends it back.
Gives a "clarity" enhancement feel. Uses radius to control detail scale.
**Deconvolution** — Richardson-Lucy iterative deconvolution.
Can recover actual lost detail from blur, not just enhance edges.
Uses radius as the estimated blur kernel and iterations to control convergence.""",
inputs=[
io.MatchType.Input("image", io.MatchType.Template("img_or_mask", [io.Image, io.Mask])),
io.DynamicCombo.Input("method", options=[
io.DynamicCombo.Option(key="rcas", inputs=[
io.Float.Input("strength", default=0.8, min=0.0, max=1.0, step=0.01,
tooltip="0 = no sharpening, 1 = full RCAS sharpening."),
]),
io.DynamicCombo.Option(key="adaptive_usm", inputs=[
io.Float.Input("strength", default=0.5, min=0.0, max=3.0, step=0.01,
tooltip="Sharpening multiplier. Values above 1.0 give aggressive sharpening."),
io.Float.Input("radius", default=1.0, min=0.5, max=5.0, step=0.1,
tooltip="Gaussian blur sigma for the unsharp mask. Larger = enhances coarser detail."),
io.Float.Input("threshold", default=0.05, min=0.0, max=1.0, step=0.01,
tooltip="Noise gate. Higher = only sharpen areas with more texture/detail. 0 = sharpen everything."),
]),
io.DynamicCombo.Option(key="high_pass", inputs=[
io.Float.Input("strength", default=0.5, min=0.0, max=3.0, step=0.01,
tooltip="Blend factor for high-frequency detail. Values above 1.0 give a punchier effect."),
io.Float.Input("radius", default=1.0, min=0.5, max=5.0, step=0.1,
tooltip="Gaussian blur sigma defining the frequency cutoff. Larger = enhances coarser detail."),
]),
io.DynamicCombo.Option(key="deconvolution", inputs=[
io.Float.Input("strength", default=0.5, min=0.0, max=1.0, step=0.01,
tooltip="Blend between original (0) and fully deconvolved (1)."),
io.Float.Input("radius", default=1.0, min=0.5, max=5.0, step=0.1,
tooltip="Sigma of the assumed Gaussian blur to reverse."),
io.Int.Input("iterations", default=10, min=1, max=100, step=1,
tooltip="Richardson-Lucy iterations. More = sharper but slower, diminishing returns past ~20."),
]),
]),
],
outputs=[
io.MatchType.Output(io.MatchType.Template("img_or_mask", [io.Image, io.Mask]), display_name="output"),
],
)
@classmethod
def execute(cls, image, method) -> io.NodeOutput:
selected = method["method"]
strength = method.get("strength", 0.5)
if strength == 0:
return io.NodeOutput(image)
is_mask = image.ndim == 3 # BHW
if is_mask:
image = image.unsqueeze(-1) # BHW -> BHWC with C=1
radius = method.get("radius", 1.0)
threshold = method.get("threshold", 0.05)
iterations = method.get("iterations", 10)
if selected == "rcas":
result = _rcas(image, strength)
elif selected == "adaptive_usm":
result = _adaptive_usm(image, strength, radius, threshold)
elif selected == "high_pass":
result = _high_pass(image, strength, radius)
else:
result = _deconvolution(image, strength, radius, iterations)
if is_mask:
result = result.squeeze(-1) # BHWC -> BHW
return io.NodeOutput(result)
def _rcas(image: torch.Tensor, strength: float) -> torch.Tensor:
"""AMD FidelityFX RCAS — 5-tap cross filter with contrast-adaptive lobe."""
device = model_management.get_torch_device()
intermediate_device = model_management.intermediate_device()
dtype = model_management.intermediate_dtype()
B, H, W, C = image.shape
out = torch.empty(B, H, W, C, device=intermediate_device, dtype=dtype)
attenuation = strength
for i in range(B):
img = image[i:i+1].to(device=device, dtype=dtype).permute(0, 3, 1, 2)
padded = F.pad(img, (1, 1, 1, 1), mode='reflect')
n = padded[:, :, 0:H, 1:W+1]
s = padded[:, :, 2:H+2, 1:W+1]
w = padded[:, :, 1:H+1, 0:W]
e = padded[:, :, 1:H+1, 2:W+2]
center = img
mn = torch.min(torch.min(torch.min(torch.min(n, s), w), e), center)
mx = torch.max(torch.max(torch.max(torch.max(n, s), w), e), center)
# hitMin = -mn / (4*mx), hitMax = -(1-mx) / (4*(1-mn))
hit_min = mn.div(mx * 4.0 + 1e-6).neg_()
hit_max = mx.neg().add_(1.0).div_(mn.neg().add_(1.0).mul_(4.0).add_(1e-6)).neg_()
torch.max(hit_min, hit_max, out=hit_min)
lobe = hit_min.min(dim=1, keepdim=True).values
lobe.mul_(attenuation)
lobe.clamp_(-0.1875, 0.0)
# (center + lobe*(n+s+e+w)) / (1 + 4*lobe)
norm = lobe.mul(4.0).add_(1.0).reciprocal_()
neighbors = n + s
neighbors.add_(e).add_(w)
neighbors.mul_(lobe).add_(center).mul_(norm)
neighbors.clamp_(0.0, 1.0)
out[i:i+1] = neighbors.permute(0, 2, 3, 1).to(device=intermediate_device)
return out
def _gaussian_kernel_1d(sigma: float, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
radius = max(int(3.0 * sigma + 0.5), 1)
x = torch.arange(-radius, radius + 1, device=device, dtype=dtype)
kernel = torch.exp(-0.5 * (x / sigma) ** 2)
kernel.div_(kernel.sum())
return kernel
def _gaussian_blur(img: torch.Tensor, sigma: float) -> torch.Tensor:
"""Separable Gaussian blur on BCHW tensor."""
kernel_1d = _gaussian_kernel_1d(sigma, img.device, img.dtype)
k = kernel_1d.shape[0]
C = img.shape[1]
pad = k // 2
kh = kernel_1d.view(1, 1, 1, k).expand(C, -1, -1, -1)
blurred = F.conv2d(F.pad(img, (pad, pad, 0, 0), mode='reflect'), kh, groups=C)
kv = kernel_1d.view(1, 1, k, 1).expand(C, -1, -1, -1)
blurred = F.conv2d(F.pad(blurred, (0, 0, pad, pad), mode='reflect'), kv, groups=C)
return blurred
def _adaptive_usm(image: torch.Tensor, strength: float, radius: float, threshold: float) -> torch.Tensor:
"""Unsharp mask modulated by local variance — sharpens texture, skips flat areas."""
device = model_management.get_torch_device()
intermediate_device = model_management.intermediate_device()
dtype = model_management.intermediate_dtype()
B, H, W, C = image.shape
out = torch.empty(B, H, W, C, device=intermediate_device, dtype=dtype)
var_sigma = radius * 1.5
for i in range(B):
img = image[i:i+1].to(device=device, dtype=dtype, copy=True).permute(0, 3, 1, 2)
blurred = _gaussian_blur(img, radius)
detail = img - blurred
# Local variance: E[x^2] - E[x]^2
local_mean = _gaussian_blur(img, var_sigma)
local_mean_sq = _gaussian_blur(img.square(), var_sigma)
local_mean_sq.sub_(local_mean * local_mean).clamp_(min=0.0)
# Convert to std deviation so threshold is in pixel-intensity scale
modulation = local_mean_sq.mean(dim=1, keepdim=True).sqrt_()
if threshold > 0:
modulation.div_(threshold).clamp_(0.0, 1.0)
else:
modulation.fill_(1.0)
img.addcmul_(detail, modulation, value=strength).clamp_(0.0, 1.0)
out[i:i+1] = img.permute(0, 2, 3, 1).to(device=intermediate_device)
return out
def _high_pass(image: torch.Tensor, strength: float, radius: float) -> torch.Tensor:
"""High-pass sharpening: original + strength * (original - blur)."""
device = model_management.get_torch_device()
intermediate_device = model_management.intermediate_device()
dtype = model_management.intermediate_dtype()
B, H, W, C = image.shape
out = torch.empty(B, H, W, C, device=intermediate_device, dtype=dtype)
for i in range(B):
img = image[i:i+1].to(device=device, dtype=dtype).permute(0, 3, 1, 2)
low_pass = _gaussian_blur(img, radius)
low_pass.neg_().add_(img).mul_(strength).add_(img).clamp_(0.0, 1.0)
out[i:i+1] = low_pass.permute(0, 2, 3, 1).to(device=intermediate_device)
return out
def _deconvolution(image: torch.Tensor, strength: float, radius: float, iterations: int) -> torch.Tensor:
"""Richardson-Lucy deconvolution with Gaussian PSF."""
device = model_management.get_torch_device()
intermediate_device = model_management.intermediate_device()
dtype = model_management.intermediate_dtype()
B, H, W, C = image.shape
out = torch.empty(B, H, W, C, device=intermediate_device, dtype=dtype)
# Gaussian PSF kernel (symmetric, so PSF^T = PSF)
kernel_1d = _gaussian_kernel_1d(radius, device, dtype)
k = kernel_1d.shape[0]
pad = k // 2
kh = kernel_1d.view(1, 1, 1, k).expand(C, -1, -1, -1)
kv = kernel_1d.view(1, 1, k, 1).expand(C, -1, -1, -1)
for i in range(B):
img = image[i:i+1].to(device=device, dtype=dtype).permute(0, 3, 1, 2)
estimate = img.clone()
for _ in range(iterations):
# estimate *= (observed / (estimate * PSF)) * PSF^T
blurred = F.conv2d(F.pad(estimate, (pad, pad, 0, 0), mode='reflect'), kh, groups=C)
blurred = F.conv2d(F.pad(blurred, (0, 0, pad, pad), mode='reflect'), kv, groups=C)
blurred.add_(1e-6)
torch.div(img, blurred, out=blurred)
correction = F.conv2d(F.pad(blurred, (pad, pad, 0, 0), mode='reflect'), kh, groups=C)
correction = F.conv2d(F.pad(correction, (0, 0, pad, pad), mode='reflect'), kv, groups=C)
estimate.mul_(correction)
torch.lerp(img, estimate, strength, out=estimate)
estimate.clamp_(0.0, 1.0)
out[i:i+1] = estimate.permute(0, 2, 3, 1).to(device=intermediate_device)
return out
-193
View File
@@ -1,193 +0,0 @@
"""Loaders for tiny VAE decoders that ComfyUI core can't build yet.
TinyVAEDecoder handles flat TAESD-style decoders. Core's Decoder hardcodes a 64-wide stack
with 3 upsamples, so anything else fails to load — the 2D taeh3 is 96 wide with 4. The
architecture is recoverable from the checkpoint: keys are positional module indices, so
`N.conv.0.weight` is a Block, `N.weight` a conv, and the gaps are parameterless modules.
TAEHVDecoder handles the temporal taehv format, which core builds but can't size for H3.
"""
import logging
import torch
import torch.nn as nn
import comfy.model_management
import comfy.utils
from comfy.taesd.taesd import Block, Clamp, conv
def build_tae_decoder(sd):
by_index = {}
for k, v in sd.items():
head, _, rest = k.partition(".")
if not head.isdigit():
raise ValueError(f"not a flat TAE decoder state dict (unexpected key '{k}')")
by_index.setdefault(int(head), {})[rest] = v
modules = []
for i in range(max(by_index) + 1):
entry = by_index.get(i)
if entry is None:
# index 0 is the input Clamp, 2 the ReLU after the input conv, the rest are upsamples
modules.append(Clamp() if i == 0 else nn.ReLU() if i == 2 else nn.Upsample(scale_factor=2))
elif "conv.0.weight" in entry:
w = entry["conv.0.weight"]
# only pass the kwarg when it's needed — older ComfyUI has no midblock-GN variant
if "pool.0.weight" in entry:
modules.append(Block(w.shape[1], w.shape[0], use_midblock_gn=True))
else:
modules.append(Block(w.shape[1], w.shape[0]))
elif "weight" in entry:
w = entry["weight"]
modules.append(conv(w.shape[1], w.shape[0], bias="bias" in entry))
else:
raise ValueError(f"unrecognized TAE decoder module at index {i}: {sorted(entry)}")
return nn.Sequential(*modules)
class TinyVAEDecoder:
"""Decode-only tiny VAE. decode() is float32 in [0, 1] like the TAE family; decode_video() is uint8."""
decodes_prefix = False # honours frame_indices exactly
def __init__(self, sd, device=None, dtype=None):
# keys may carry a "taesd_decoder."/"decoder." prefix; strip whatever is common
prefix = ""
first = next(iter(sd))
if not first.split(".")[0].isdigit():
prefix = first.split(".")[0] + "."
sd = {k[len(prefix):]: v for k, v in sd.items() if k.startswith(prefix)}
self.device = device if device is not None else comfy.model_management.vae_device()
self.dtype = dtype if dtype is not None else comfy.model_management.vae_dtype(
self.device, [torch.float16, torch.bfloat16])
self.model = build_tae_decoder(sd)
self.model.load_state_dict(sd)
self.model = _place(self.model, self.device, self.dtype)
self.latent_channels = self.model[1].weight.shape[1]
self.upscale_ratio = 2 ** sum(isinstance(m, nn.Upsample) for m in self.model)
def decode(self, latent):
"""[B, C, H, W] -> [B, 3, H*ratio, W*ratio], float32 on the input device."""
out = self.model(latent.to(device=self.device, dtype=self.dtype))
return out.to(device=latent.device, dtype=torch.float32)
def decode_video(self, latent, frame_indices=None):
"""[B, C, T, H, W] -> [T, 3, H*ratio, W*ratio] uint8 on the CPU. Decodes one frame at a time
in the model dtype and casts before the host copy, so the GPU never holds more than one frame;
frames land directly in the preallocated output instead of a second stack copy."""
x = latent[0]
indices = list(range(x.shape[1])) if frame_indices is None else list(frame_indices)
out = None
for i, t in enumerate(indices):
f = self.model(x[:, t].unsqueeze(0).to(device=self.device, dtype=self.dtype))[0]
f = f.clamp_(0, 1).mul_(255).to(torch.uint8)
if out is None:
out = torch.empty((len(indices),) + tuple(f.shape), dtype=torch.uint8)
out[i].copy_(f)
return out
def _place(model, device, dtype):
model = model.eval().to(device=device, dtype=dtype)
if torch.device(device).type == "cuda":
model.to(memory_format=torch.channels_last)
return model
def is_taehv_state_dict(sd):
return "decoder.1.weight" in sd and "decoder.22.bias" in sd
class TAEHVDecoder:
"""Temporal tiny VAE (madebyollin/taehv), decode only."""
decodes_prefix = True # memblock state chains forward, so partial requests decode the prefix
def __init__(self, sd, device=None, dtype=None):
from comfy.taesd.taehv import TAEHV, conv
latent_channels = sd["decoder.1.weight"].shape[1]
# final conv emits image_channels * patch_size**2
patch_size = max(1, int(round((sd["decoder.22.bias"].shape[0] / 3) ** 0.5)))
model = TAEHV(latent_channels=latent_channels)
# core derives patch_size from the channel count and has no entry for H3's 24;
# those two convs are the only parts of the model that depend on it
if model.patch_size != patch_size:
model.patch_size = patch_size
model.encoder[0] = conv(3 * patch_size ** 2, model.encoder[0].out_channels)
model.decoder[-1] = conv(model.decoder[-1].in_channels, 3 * patch_size ** 2)
model.load_state_dict(sd)
del model.encoder # decode only, so keep it off the device entirely
self.device = device if device is not None else comfy.model_management.vae_device()
self.dtype = dtype if dtype is not None else comfy.model_management.vae_dtype(
self.device, [torch.float16, torch.bfloat16])
self.model = _place(model, self.device, self.dtype)
self.latent_channels = latent_channels
self.upscale_ratio = patch_size * 2 ** sum(isinstance(m, nn.Upsample) for m in model.decoder)
self.is_h3 = latent_channels == 24 and patch_size == 2
def _decode(self, latent):
# [B, C, T, H, W] -> [B, 3, T*t_upscale - trim, H*ratio, W*ratio] on the intermediate device
# (CPU unless --gpu-only); frames stream off the GPU one at a time inside the memblock loop
return self.model.decode(latent.to(device=self.device, dtype=self.dtype))
def decode(self, latent):
"""[B, C, H, W] -> [B, 3, H*ratio, W*ratio], decoded as a single frame."""
return self._decode(latent.unsqueeze(2))[:, :, 0]
def _decode_h3_full(self, latent):
"""Whole-clip decode in H3's own chunking, so the frame count comes out exact."""
# H3's VAE codes 17 pixel frames per 5 latent tokens: trim each chunk's prefix
# rather than once globally, then drop the encoder's 3-token tail pad
from comfy.taesd.taehv import apply_model_with_memblocks
m = self.model
x = m.process_in(latent.to(device=self.device, dtype=self.dtype)).movedim(2, 1)
x = apply_model_with_memblocks(m.decoder, x, m.parallel, False,
output_device=comfy.model_management.intermediate_device(),
patch_size=m.patch_size, decode=True)
chunk = 5 * m.t_upscale
x = torch.nn.functional.pad(x, (0, 0, 0, 0, 0, 0, 0, -x.shape[1] % chunk))
x = x.unflatten(1, (-1, chunk))[:, :, m.frames_to_trim:].flatten(1, 2)
x = x[:, :-3 * m.t_upscale]
return x.movedim(2, 1)
def decode_video(self, latent, frame_indices=None):
"""[B, C, T, H, W] -> [n, 3, H*ratio, W*ratio] on the intermediate device, no repack."""
t_total = latent.shape[2]
n = t_total if frame_indices is None else max(1, min(len(frame_indices), t_total))
if n == t_total:
# whole clip asked for, so decode it properly — this returns every pixel frame,
# which is more than the latent frame count (17 per 5 tokens for H3)
if self.is_h3:
out = self._decode_h3_full(latent[:1])
if out.shape[2] > 0:
return out[0].movedim(0, 1)
return self._decode(latent[:1])[0].movedim(0, 1)
# MemBlock state chains forward, so frames can't be sampled across the clip without
# decoding everything before them — take a prefix to keep the per-step cost bounded
out = self._decode(latent[:1, :, :n])[0].movedim(0, 1)
if out.shape[0] > n:
out = out[torch.linspace(0, out.shape[0] - 1, n).round().long()]
return out
def load_tiny_vae_decoder(name, device=None, dtype=None):
"""Load by vae_approx filename. Returns None (and logs) if it can't be used."""
import folder_paths
path = folder_paths.get_full_path("vae_approx", name)
if path is None:
logging.warning(f"[KJ TinyVAE] '{name}' not found in models/vae_approx.")
return None
try:
sd = comfy.utils.load_torch_file(path, safe_load=True)
if is_taehv_state_dict(sd):
return TAEHVDecoder(sd, device=device, dtype=dtype)
return TinyVAEDecoder(sd, device=device, dtype=dtype)
except Exception as e:
logging.warning(f"[KJ TinyVAE] Could not load '{name}': {e}")
return None
-794
View File
@@ -1,794 +0,0 @@
"""Triton VAE decode/encode kernels: fused norm+SiLU and optional int8 convs.
Supported (auto-detected): Wan (RMSNorm), KL/Flux2/SDXL/SD1.5 (GroupNorm)
and LTXV/LTX2 (PixelNorm). int8 covers Wan, flux-family KL and LTX decoders.
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
try:
import triton
import triton.language as tl
except ImportError as e:
raise ImportError("PatchTritonVAE requires triton (pip install triton, or triton-windows)") from e
from comfy.ldm.wan.vae import RMS_norm, CausalConv3d
from comfy.ldm.modules.diffusionmodules.model import ResnetBlock
from comfy.ldm.lightricks.vae.pixel_norm import PixelNorm
from comfy.ldm.lightricks.vae.causal_conv3d import CausalConv3d as LTXCausalConv3d
from comfy.ldm.lightricks.vae.causal_video_autoencoder import ResnetBlock3D as LTXResnetBlock3D, Encoder as LTXEncoder, Decoder as LTXDecoder
import os
import copy
import logging
import comfy.ops
PROFILE = os.environ.get("KJNODES_TRITON_PROFILE", "") == "1"
CL3D = torch.channels_last_3d
import comfy.model_patcher
from comfy.patcher_extension import CallbacksMP
from comfy_api.latest import io
@triton.jit
def _rms_silu_kernel(x_ptr, g_ptr, out_ptr, C, S, stride_b, eps, scale, rcount, BLOCK_S: tl.constexpr, BLOCK_C: tl.constexpr,
HAS_GAMMA: tl.constexpr = True, SILU: tl.constexpr = True, EPS_INSIDE: tl.constexpr = False):
pid_s = tl.program_id(0)
pid_b = tl.program_id(1)
offs_s = pid_s * BLOCK_S + tl.arange(0, BLOCK_S)
mask_s = offs_s < S
# int64 offsets: C*S per batch can exceed 2^31 at high resolutions
base = x_ptr + pid_b.to(tl.int64) * stride_b
acc = tl.zeros([BLOCK_S], dtype=tl.float32)
for c0 in range(0, C, BLOCK_C):
offs_c = c0 + tl.arange(0, BLOCK_C)
mask_c = offs_c < C
ptrs = base + offs_c[:, None].to(tl.int64) * S + offs_s[None, :]
v = tl.load(ptrs, mask=mask_c[:, None] & mask_s[None, :], other=0.0).to(tl.float32)
acc += tl.sum(v * v, axis=0)
if EPS_INSIDE: # pixel_norm: 1/sqrt(mean(x^2) + eps)
inv = scale / tl.sqrt(acc * rcount + eps)
else:
inv = scale / tl.maximum(tl.sqrt(acc), eps)
out_base = out_ptr + pid_b.to(tl.int64) * stride_b
for c0 in range(0, C, BLOCK_C):
offs_c = c0 + tl.arange(0, BLOCK_C)
mask_c = offs_c < C
m = mask_c[:, None] & mask_s[None, :]
ptrs = base + offs_c[:, None].to(tl.int64) * S + offs_s[None, :]
v = tl.load(ptrs, mask=m, other=0.0).to(tl.float32)
y = v * inv[None, :]
if HAS_GAMMA:
g = tl.load(g_ptr + offs_c, mask=mask_c, other=0.0).to(tl.float32)
y = y * g[:, None]
if SILU:
y = y * tl.sigmoid(y)
tl.store(out_base + offs_c[:, None].to(tl.int64) * S + offs_s[None, :], y.to(out_ptr.dtype.element_ty), mask=m)
@triton.jit
def _rms_silu_cl_kernel(x_ptr, g_ptr, out_ptr, amax_ptr, C, ROWS, eps, scale, rcount, BLOCK_S: tl.constexpr, BLOCK_C: tl.constexpr,
HAS_GAMMA: tl.constexpr = True, SILU: tl.constexpr = True, EPS_INSIDE: tl.constexpr = False,
AMAX: tl.constexpr = False):
pid = tl.program_id(0)
offs_s = pid * BLOCK_S + tl.arange(0, BLOCK_S)
offs_c = tl.arange(0, BLOCK_C)
mask_s = offs_s < ROWS
mask_c = offs_c < C
m = mask_s[:, None] & mask_c[None, :]
# int64 offsets: ROWS*C (= numel) can exceed 2^31 at high resolutions
ptrs = x_ptr + offs_s[:, None].to(tl.int64) * C + offs_c[None, :]
v = tl.load(ptrs, mask=m, other=0.0).to(tl.float32)
acc = tl.sum(v * v, axis=1)
if EPS_INSIDE:
inv = scale / tl.sqrt(acc * rcount + eps)
else:
inv = scale / tl.maximum(tl.sqrt(acc), eps)
y = v * inv[:, None]
if HAS_GAMMA:
g = tl.load(g_ptr + offs_c, mask=mask_c, other=0.0).to(tl.float32)
y = y * g[None, :]
if SILU:
y = y * tl.sigmoid(y)
if AMAX:
# free global amax for downstream int8 quantization
tl.atomic_max(amax_ptr, tl.max(tl.max(tl.abs(y), axis=1), axis=0))
tl.store(out_ptr + offs_s[:, None].to(tl.int64) * C + offs_c[None, :], y.to(out_ptr.dtype.element_ty), mask=m)
@triton.jit
def _gn_stats_cl(x_ptr, sum_ptr, sumsq_ptr, S,
G: tl.constexpr, BLOCK_S: tl.constexpr, BLOCK_C: tl.constexpr):
pid = tl.program_id(0)
b = tl.program_id(1)
offs_c = tl.arange(0, BLOCK_C)
acc1 = tl.zeros([G], dtype=tl.float32)
acc2 = tl.zeros([G], dtype=tl.float32)
s0 = pid * BLOCK_S
step = tl.num_programs(0) * BLOCK_S
while s0 < S:
offs_s = s0 + tl.arange(0, BLOCK_S)
m = (offs_s < S)[:, None]
# int64 offsets: (B*S)*C can exceed 2^31 at high resolutions
ptr = x_ptr + (b.to(tl.int64) * S + offs_s[:, None]) * BLOCK_C + offs_c[None, :]
v = tl.load(ptr, mask=m, other=0.0).to(tl.float32)
v3 = tl.reshape(v, (BLOCK_S, G, BLOCK_C // G))
acc1 += tl.sum(tl.sum(v3, axis=2), axis=0)
acc2 += tl.sum(tl.sum(v3 * v3, axis=2), axis=0)
s0 += step
tl.atomic_add(sum_ptr + b * G + tl.arange(0, G), acc1)
tl.atomic_add(sumsq_ptr + b * G + tl.arange(0, G), acc2)
@triton.jit
def _gn_silu_apply_cl(x_ptr, sum_ptr, sumsq_ptr, w_ptr, bias_ptr, out_ptr, amax_ptr, S, count, eps,
G: tl.constexpr, BLOCK_S: tl.constexpr, BLOCK_C: tl.constexpr, SILU: tl.constexpr,
AMAX: tl.constexpr = False):
pid = tl.program_id(0)
b = tl.program_id(1)
CS: tl.constexpr = BLOCK_C // G
offs_s = pid * BLOCK_S + tl.arange(0, BLOCK_S)
offs_c = tl.arange(0, BLOCK_C)
m = (offs_s < S)[:, None]
ptr = x_ptr + (b.to(tl.int64) * S + offs_s[:, None]) * BLOCK_C + offs_c[None, :]
v = tl.load(ptr, mask=m, other=0.0).to(tl.float32)
mean_g = tl.load(sum_ptr + b * G + tl.arange(0, G)) / count
var_g = tl.load(sumsq_ptr + b * G + tl.arange(0, G)) / count - mean_g * mean_g
var_g = tl.maximum(var_g, 0.0) # E[x²]-mean² can go slightly negative from fp32 cancellation
rstd_g = 1.0 / tl.sqrt(var_g + eps)
mean_c = tl.reshape(tl.broadcast_to(mean_g[:, None], (G, CS)), (BLOCK_C,))
rstd_c = tl.reshape(tl.broadcast_to(rstd_g[:, None], (G, CS)), (BLOCK_C,))
w = tl.load(w_ptr + offs_c).to(tl.float32)
bias = tl.load(bias_ptr + offs_c).to(tl.float32)
y = (v - mean_c[None, :]) * rstd_c[None, :] * w[None, :] + bias[None, :]
if SILU:
y = y * tl.sigmoid(y)
if AMAX:
# free global amax for downstream int8 quantization
masked = tl.where(m, tl.abs(y), 0.0)
tl.atomic_max(amax_ptr, tl.max(tl.max(masked, axis=1), axis=0))
tl.store(out_ptr + (b.to(tl.int64) * S + offs_s[:, None]) * BLOCK_C + offs_c[None, :],
y.to(out_ptr.dtype.element_ty), mask=m)
# autotuned variants: benchmark configs once per shape key, cache the fastest
_rms_silu_kernel_tuned = triton.autotune(
configs=[triton.Config({"BLOCK_S": bs, "BLOCK_C": bc}, num_warps=w)
for bs in (128, 256, 512) for bc in (32, 64) for w in (4, 8)],
key=["C", "S"])(_rms_silu_kernel)
_rms_silu_cl_kernel_tuned = triton.autotune(
configs=[triton.Config({"BLOCK_S": bs}, num_warps=w) for bs in (4, 8, 16, 32, 64) for w in (4, 8)],
key=["C", "ROWS"])(_rms_silu_cl_kernel)
_gn_stats_cl_tuned = triton.autotune(
configs=[triton.Config({"BLOCK_S": bs}, num_warps=w) for bs in (8, 16, 32, 64) for w in (4, 8)],
key=["S"], reset_to_zero=["sum_ptr", "sumsq_ptr"])(_gn_stats_cl)
_gn_silu_apply_cl_tuned = triton.autotune(
configs=[triton.Config({"BLOCK_S": bs}, num_warps=w) for bs in (8, 16, 32, 64) for w in (4, 8)],
key=["S", "count"])(_gn_silu_apply_cl)
def fused_rms_silu(x, gamma, scale, eps=1e-12, autotune=False, silu=True, eps_inside=False):
B, C = x.shape[0], x.shape[1]
S = x.numel() // (B * C)
x = x.contiguous()
out = torch.empty_like(x)
flags = {"HAS_GAMMA": gamma is not None, "SILU": silu, "EPS_INSIDE": eps_inside}
g = gamma if gamma is not None else x
if autotune:
grid = lambda meta: (triton.cdiv(S, meta["BLOCK_S"]), B)
_rms_silu_kernel_tuned[grid](x, g, out, C, S, C * S, eps, scale, 1.0 / C, **flags)
else:
_rms_silu_kernel[(triton.cdiv(S, 256), B)](x, g, out, C, S, C * S, eps, scale, 1.0 / C,
BLOCK_S=256, BLOCK_C=32, num_warps=8, **flags)
return out
def fused_rms_silu_cl(x, gamma, scale, eps=1e-12, autotune=False, silu=True, eps_inside=False, return_amax=False):
C = x.shape[1]
rows = x.numel() // C
out = torch.empty_like(x)
BLOCK_C = triton.next_power_of_2(C)
flags = {"HAS_GAMMA": gamma is not None, "SILU": silu, "EPS_INSIDE": eps_inside, "AMAX": return_amax}
g = gamma if gamma is not None else x
amax = torch.zeros(1, device=x.device, dtype=torch.float32) if return_amax else out
if autotune:
grid = lambda meta: (triton.cdiv(rows, meta["BLOCK_S"]),)
_rms_silu_cl_kernel_tuned[grid](x, g, out, amax, C, rows, eps, scale, 1.0 / C, BLOCK_C=BLOCK_C, **flags)
else:
BLOCK_S = max(1, 4096 // BLOCK_C)
_rms_silu_cl_kernel[(triton.cdiv(rows, BLOCK_S),)](x, g, out, amax, C, rows, eps, scale, 1.0 / C,
BLOCK_S=BLOCK_S, BLOCK_C=BLOCK_C, num_warps=4, **flags)
if return_amax:
return out, amax[0]
return out
def fused_gn_silu_cl(x, weight, bias, groups, eps, silu=True, autotune=False, return_amax=False):
B, C, H, W = x.shape
S = H * W
sums = torch.zeros(2, B * groups, device=x.device, dtype=torch.float32)
out = torch.empty_like(x)
count = S * (C // groups)
amax = torch.zeros(1, device=x.device, dtype=torch.float32) if return_amax else out
if autotune:
# program count beyond ~1024 only adds same-address atomic contention
grid_stats = lambda meta: (min(1024, triton.cdiv(S, meta["BLOCK_S"])), B)
_gn_stats_cl_tuned[grid_stats](x, sums[0], sums[1], S, G=groups, BLOCK_C=C)
grid_apply = lambda meta: (triton.cdiv(S, meta["BLOCK_S"]), B)
_gn_silu_apply_cl_tuned[grid_apply](x, sums[0], sums[1], weight, bias, out, amax, S, count, eps,
G=groups, BLOCK_C=C, SILU=silu, AMAX=return_amax)
else:
BLOCK_S = max(1, 8192 // C)
nprog = min(1024, triton.cdiv(S, BLOCK_S))
_gn_stats_cl[(nprog, B)](x, sums[0], sums[1], S, G=groups, BLOCK_S=BLOCK_S, BLOCK_C=C, num_warps=8)
_gn_silu_apply_cl[(triton.cdiv(S, BLOCK_S), B)](x, sums[0], sums[1], weight, bias, out, amax, S, count, eps,
G=groups, BLOCK_S=BLOCK_S, BLOCK_C=C, SILU=silu, AMAX=return_amax, num_warps=8)
if return_amax:
return out, amax[0]
return out
class FusedRMSSiLU(nn.Module):
emit_amax = False # lets the downstream int8 conv skip its amax pass
def __init__(self, rms, autotune=False):
super().__init__()
self.gamma = rms.gamma
self.scale = rms.scale
self.autotune = autotune
def forward(self, x):
if not x.is_cuda:
return F.silu(F.normalize(x, dim=1) * self.scale * self.gamma.to(x))
gamma = self.gamma
if gamma.device != x.device: # lowvram partial load keeps params offloaded on cpu
gamma = gamma.to(x.device)
gamma = gamma.reshape(-1)
if not gamma.is_contiguous():
gamma = gamma.contiguous()
if x.ndim == 5 and x.is_contiguous(memory_format=torch.channels_last_3d) and not x.is_contiguous():
if self.emit_amax:
out, amax = fused_rms_silu_cl(x, gamma, self.scale, autotune=self.autotune, return_amax=True)
out._kj_amax = amax
return out
return fused_rms_silu_cl(x, gamma, self.scale, autotune=self.autotune)
return fused_rms_silu(x, gamma, self.scale, autotune=self.autotune)
class FusedPixelNorm(nn.Module):
def __init__(self, pn, silu=True, autotune=False):
super().__init__()
self.eps = pn.eps
self.silu = silu
self.autotune = autotune
def forward(self, x):
if x.is_cuda:
if x.ndim == 5 and x.is_contiguous(memory_format=torch.channels_last_3d) and not x.is_contiguous():
return fused_rms_silu_cl(x, None, 1.0, self.eps, autotune=self.autotune, silu=self.silu, eps_inside=True)
return fused_rms_silu(x, None, 1.0, self.eps, autotune=self.autotune, silu=self.silu, eps_inside=True)
out = x / torch.sqrt(torch.mean(x ** 2, dim=1, keepdim=True) + self.eps)
if self.silu:
out = F.silu(out)
return out
class FusedGNSiLU(nn.Module):
emit_amax = False # lets the downstream int8 conv skip its amax pass
def __init__(self, gn, silu=True, autotune=False):
super().__init__()
self.weight = gn.weight
self.bias = gn.bias
self.num_groups = gn.num_groups
self.eps = gn.eps
self.silu = silu
self.autotune = autotune
def forward(self, x):
C = x.shape[1]
G = self.num_groups
weight, bias = self.weight, self.bias
if weight.device != x.device: # lowvram partial load keeps params offloaded on cpu
weight = weight.to(x.device)
bias = bias.to(x.device)
if x.is_cuda and x.ndim == 4 and (C & (C - 1)) == 0 and (G & (G - 1)) == 0 and C % G == 0 \
and x.is_contiguous(memory_format=torch.channels_last) and not x.is_contiguous():
if self.emit_amax and self.silu:
out, amax = fused_gn_silu_cl(x, weight, bias, self.num_groups, self.eps, self.silu,
autotune=self.autotune, return_amax=True)
out._kj_amax = amax
return out
return fused_gn_silu_cl(x, weight, bias, self.num_groups, self.eps, self.silu,
autotune=self.autotune)
out = F.group_norm(x, self.num_groups, weight, bias, self.eps)
if self.silu:
out = F.silu(out)
return out
def convert_conv_layout(model, channels_last=True):
for mod in model.modules():
if isinstance(mod, nn.Conv3d):
mod.to(memory_format=torch.channels_last_3d if channels_last else torch.contiguous_format)
elif isinstance(mod, nn.Conv2d):
mod.to(memory_format=torch.channels_last if channels_last else torch.contiguous_format)
def build_object_patches(model, autotune=False):
"""Norm fusion object patches; matches already-fused modules for rebuilds."""
patches = {}
for name, mod in model.named_modules():
if isinstance(mod, nn.Sequential): # Wan-style RMS_norm+SiLU pairs
for i in range(len(mod) - 1):
if isinstance(mod[i], RMS_norm) and isinstance(mod[i + 1], nn.SiLU) \
and mod[i].gamma.ndim == 4 and mod[i].bias is None:
patches[f"{name}.{i}"] = FusedRMSSiLU(mod[i], autotune=autotune)
patches[f"{name}.{i + 1}"] = nn.Identity()
elif isinstance(mod[i], FusedRMSSiLU) and isinstance(mod[i + 1], nn.Identity):
mod[i].autotune = autotune
patches[f"{name}.{i}"] = mod[i]
patches[f"{name}.{i + 1}"] = mod[i + 1]
elif isinstance(mod, ResnetBlock) and not hasattr(mod, "temb_proj"): # KL-style
for norm_name in ("norm1", "norm2"):
norm = getattr(mod, norm_name)
if isinstance(norm, nn.GroupNorm):
patches[f"{name}.{norm_name}"] = FusedGNSiLU(norm, autotune=autotune)
elif isinstance(norm, FusedGNSiLU):
norm.autotune = autotune
patches[f"{name}.{norm_name}"] = norm
patches[f"{name}.swish"] = nn.Identity()
elif name.endswith("norm_out"): # KL encoder/decoder head, SiLU applied separately
if isinstance(mod, nn.GroupNorm):
patches[name] = FusedGNSiLU(mod, silu=False, autotune=autotune)
elif isinstance(mod, FusedGNSiLU):
mod.autotune = autotune
patches[name] = mod
elif isinstance(mod, LTXResnetBlock3D):
# conditioned decoder blocks apply a scale-shift between norm and SiLU
fuse_silu = not mod.timestep_conditioning
for norm_name in ("norm1", "norm2"):
norm = getattr(mod, norm_name)
if isinstance(norm, PixelNorm) and norm.dim == 1:
patches[f"{name}.{norm_name}"] = FusedPixelNorm(norm, silu=fuse_silu, autotune=autotune)
elif isinstance(norm, FusedPixelNorm):
norm.autotune = autotune
patches[f"{name}.{norm_name}"] = norm
if fuse_silu and f"{name}.norm1" in patches:
patches[f"{name}.non_linearity"] = nn.Identity()
elif isinstance(mod, (LTXEncoder, LTXDecoder)): # LTX head norm + conv_act
fuse_silu = isinstance(mod, LTXEncoder) or not mod.timestep_conditioning
if isinstance(mod.conv_norm_out, PixelNorm) and mod.conv_norm_out.dim == 1:
patches[f"{name}.conv_norm_out"] = FusedPixelNorm(mod.conv_norm_out, silu=fuse_silu, autotune=autotune)
elif isinstance(mod.conv_norm_out, FusedPixelNorm):
mod.conv_norm_out.autotune = autotune
patches[f"{name}.conv_norm_out"] = mod.conv_norm_out
if fuse_silu and f"{name}.conv_norm_out" in patches:
patches[f"{name}.conv_act"] = nn.Identity()
return patches
# int8 (W8A8) convolutions: implicit GEMM on int8 tensor cores
@triton.jit
def _quant_pad_s8(x_ptr, c_ptr, out_ptr, amax_ptr, C, C_PAD, S_PAD, S_CACHE, NTOT, BLOCK: tl.constexpr):
# one-pass padded int8 buffer: [zero frames | cache | payload], channels zero-extended to C_PAD
pid = tl.program_id(0)
offs = pid * BLOCK + tl.arange(0, BLOCK)
m = offs < NTOT
inv = 127.0 / tl.maximum(tl.load(amax_ptr).to(tl.float32), 1e-8)
s_idx = offs // C_PAD
c = offs % C_PAD
valid = m & (c < C)
in_c = valid & (s_idx >= S_PAD) & (s_idx < S_PAD + S_CACHE)
in_x = valid & (s_idx >= S_PAD + S_CACHE)
v = tl.load(x_ptr + (s_idx - S_PAD - S_CACHE) * C + c, mask=in_x, other=0.0).to(tl.float32)
v += tl.load(c_ptr + (s_idx - S_PAD) * C + c, mask=in_c, other=0.0).to(tl.float32)
v = v * inv
v = tl.minimum(tl.maximum(v + tl.where(v >= 0, 0.5, -0.5), -127.0), 127.0)
tl.store(out_ptr + offs, v.to(tl.int8), mask=m)
@triton.jit
def _conv3d_s8(x_ptr, w_ptr, out_ptr, scale_ptr, bias_ptr,
H, W, C, C_out, C3, M,
BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr,
HAS_BIAS: tl.constexpr):
# the three dw taps of each (dt, dh) group are one contiguous 3C span in NDHWC
pid_m = tl.program_id(0)
pid_n = tl.program_id(1)
offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
mask_m = offs_m < M
mask_n = offs_n < C_out
w_o = offs_m % W
h_o = (offs_m // W) % H
t_o = offs_m // (W * H)
acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.int32)
for g in tl.static_range(9):
dt = g // 3
dh = g % 3
t_i = t_o + dt
h_i = h_o + dh - 1
h_ok = mask_m & (h_i >= 0) & (h_i < H)
base = ((t_i * H + h_i) * W + (w_o - 1)) * C
for k0 in range(0, C3, BLOCK_K):
dw = k0 // C # padded C is a multiple of BLOCK_K, one dw tap per chunk
m_ok = h_ok & (w_o + dw - 1 >= 0) & (w_o + dw - 1 < W)
offs_k = k0 + tl.arange(0, BLOCK_K)
a = tl.load(x_ptr + base[:, None] + offs_k[None, :], mask=m_ok[:, None], other=0)
b = tl.load(w_ptr + (g * C3 + offs_k)[:, None] * C_out + offs_n[None, :],
mask=mask_n[None, :], other=0)
acc = tl.dot(a, b, acc, out_dtype=tl.int32)
y = acc.to(tl.float32) * tl.load(scale_ptr + offs_n, mask=mask_n, other=0.0)
if HAS_BIAS:
y += tl.load(bias_ptr + offs_n, mask=mask_n, other=0.0).to(tl.float32)[None, :]
tl.store(out_ptr + offs_m[:, None] * C_out + offs_n[None, :],
y.to(out_ptr.dtype.element_ty), mask=mask_m[:, None] & mask_n[None, :])
@triton.jit
def _conv2d_s8(x_ptr, w_ptr, out_ptr, scale_ptr, bias_ptr,
H, W, C, C_out, C3, M,
BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr,
HAS_BIAS: tl.constexpr):
# 2D (3x3, pad 1) variant of _conv3d_s8: 3 dh groups, batch-aware rows
pid_m = tl.program_id(0)
pid_n = tl.program_id(1)
offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
mask_m = offs_m < M
mask_n = offs_n < C_out
w_o = offs_m % W
h_o = (offs_m // W) % H
b = offs_m // (W * H)
acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.int32)
for g in tl.static_range(3):
h_i = h_o + g - 1
h_ok = mask_m & (h_i >= 0) & (h_i < H)
base = ((b * H + h_i) * W + (w_o - 1)) * C
for k0 in range(0, C3, BLOCK_K):
dw = k0 // C
m_ok = h_ok & (w_o + dw - 1 >= 0) & (w_o + dw - 1 < W)
offs_k = k0 + tl.arange(0, BLOCK_K)
a = tl.load(x_ptr + base[:, None] + offs_k[None, :], mask=m_ok[:, None], other=0)
wb = tl.load(w_ptr + (g * C3 + offs_k)[:, None] * C_out + offs_n[None, :],
mask=mask_n[None, :], other=0)
acc = tl.dot(a, wb, acc, out_dtype=tl.int32)
y = acc.to(tl.float32) * tl.load(scale_ptr + offs_n, mask=mask_n, other=0.0)
if HAS_BIAS:
y += tl.load(bias_ptr + offs_n, mask=mask_n, other=0.0).to(tl.float32)[None, :]
tl.store(out_ptr + offs_m[:, None] * C_out + offs_n[None, :],
y.to(out_ptr.dtype.element_ty), mask=mask_m[:, None] & mask_n[None, :])
def _pack_int8_weight(weight, spatial_dims):
sw = (weight.detach().abs().amax(dim=tuple(range(1, weight.ndim)), keepdim=False).clamp(min=1e-8).float() / 127.0)
shape = [-1] + [1] * (weight.ndim - 1)
qw = (weight.detach().float() / sw.reshape(shape)).round_().clamp_(-127, 127).to(torch.int8)
c_in = weight.shape[1]
# zero-pad channels to keep every K chunk dw-aligned
c_pad = ((c_in + 63) // 64) * 64
if c_pad != c_in:
pad = [0, 0] * spatial_dims + [0, c_pad - c_in]
qw = torch.nn.functional.pad(qw, pad)
perm = tuple(range(2, weight.ndim)) + (1, 0)
return qw.permute(*perm).reshape(-1, weight.shape[0]).contiguous(), sw, c_pad
class Int8CausalConv3d(CausalConv3d):
"""int8 Wan CausalConv3d; subclass keeps isinstance cache routing and shares the weight Parameter."""
_logged = set()
_prof = []
def __init__(self, orig):
super().__init__(orig.in_channels, orig.out_channels, orig.kernel_size,
stride=orig.stride, padding=1)
self.weight = orig.weight
self.bias = orig.bias
qw, sw, self.int8_cpad = _pack_int8_weight(orig.weight, spatial_dims=3)
self.register_buffer("int8_qw", qw, persistent=False)
self.register_buffer("int8_sw", sw * (1.0 / 127.0), persistent=False)
self._last_amax = None
def forward(self, x, cache_x=None, cache_list=None, cache_idx=None):
if not x.is_cuda or x.shape[0] != 1:
reason = "non-cuda input" if not x.is_cuda else f"batch size {x.shape[0]}"
if reason not in Int8CausalConv3d._logged:
Int8CausalConv3d._logged.add(reason)
logging.info(f"PatchTritonVAE int8: falling back to bf16 conv ({reason})")
return super().forward(x, cache_x=cache_x, cache_list=cache_list, cache_idx=cache_idx)
if "active" not in Int8CausalConv3d._logged:
Int8CausalConv3d._logged.add("active")
logging.info("PatchTritonVAE int8: int8 conv kernel path active")
if PROFILE:
ev0 = torch.cuda.Event(enable_timing=True)
ev1 = torch.cuda.Event(enable_timing=True)
ev0.record()
out = self._int8_forward(x, cache_x, cache_list, cache_idx)
ev1.record()
Int8CausalConv3d._prof.append((ev0, ev1))
if len(Int8CausalConv3d._prof) >= 250:
torch.cuda.synchronize()
total = sum(a.elapsed_time(b) for a, b in Int8CausalConv3d._prof)
logging.info(f"PatchTritonVAE int8 profile: {len(Int8CausalConv3d._prof)} conv calls, {total:.0f} ms total")
Int8CausalConv3d._prof.clear()
return out
return self._int8_forward(x, cache_x, cache_list, cache_idx)
def _int8_forward(self, x, cache_x, cache_list, cache_idx):
if cache_list is not None:
cache_x = cache_list[cache_idx]
cache_list[cache_idx] = None
amax = getattr(x, "_kj_amax", None)
if not x.is_contiguous(memory_format=CL3D) or x.is_contiguous():
x = x.contiguous(memory_format=CL3D)
B, C, T, H, W = x.shape
ct = 0 if cache_x is None else cache_x.shape[2]
pad = max(0, 2 - ct)
T_in = T + ct + pad
if amax is None:
amax = x.abs().amax()
if ct and self._last_amax is not None:
# cache frames are bounded by the previous payload amax
amax = torch.maximum(amax, self._last_amax)
self._last_amax = amax.detach()
xr = x.permute(0, 2, 3, 4, 1).reshape(-1)
if ct:
if not cache_x.is_contiguous(memory_format=CL3D) or cache_x.is_contiguous():
cache_x = cache_x.contiguous(memory_format=CL3D)
cr = cache_x.permute(0, 2, 3, 4, 1).reshape(-1)
else:
cr = xr
C_pad = self.int8_cpad
hw = H * W
n_tot = T_in * hw * C_pad
qx = torch.empty(n_tot, device=x.device, dtype=torch.int8)
_quant_pad_s8[(triton.cdiv(n_tot, 4096),)](xr, cr, qx, amax, C, C_pad, pad * hw, ct * hw, n_tot,
BLOCK=4096, num_warps=8)
C_out = self.out_channels
scale_vec = self.int8_sw * amax.float()
bias = self.bias
T_out = T_in - 2
M = T_out * hw
out = torch.empty(M, C_out, device=x.device, dtype=x.dtype)
grid = (triton.cdiv(M, 128), triton.cdiv(C_out, 64))
_conv3d_s8[grid](qx, self.int8_qw, out, scale_vec,
bias.float() if bias is not None else scale_vec,
H, W, C_pad, C_out, 3 * C_pad, M,
BLOCK_M=128, BLOCK_N=64, BLOCK_K=64,
HAS_BIAS=bias is not None, num_warps=4, num_stages=4)
return out.view(1, T_out, H, W, C_out).permute(0, 4, 1, 2, 3)
class Int8Conv2d(comfy.ops.disable_weight_init.Conv2d):
"""int8 3x3 Conv2d for KL VAE ResnetBlocks; shares the weight Parameter."""
def __init__(self, orig):
super().__init__(orig.in_channels, orig.out_channels, orig.kernel_size,
stride=orig.stride, padding=orig.padding)
self.weight = orig.weight
self.bias = orig.bias
qw, sw, self.int8_cpad = _pack_int8_weight(orig.weight, spatial_dims=2)
self.register_buffer("int8_qw", qw, persistent=False)
self.register_buffer("int8_sw", sw * (1.0 / 127.0), persistent=False)
def forward(self, x):
if not x.is_cuda:
return super().forward(x)
amax = getattr(x, "_kj_amax", None)
if not x.is_contiguous(memory_format=torch.channels_last) or x.is_contiguous():
x = x.contiguous(memory_format=torch.channels_last)
B, C, H, W = x.shape
if amax is None:
amax = x.abs().amax()
xr = x.permute(0, 2, 3, 1).reshape(-1)
C_pad = self.int8_cpad
n_tot = B * H * W * C_pad
qx = torch.empty(n_tot, device=x.device, dtype=torch.int8)
_quant_pad_s8[(triton.cdiv(n_tot, 4096),)](xr, xr, qx, amax, C, C_pad, 0, 0, n_tot,
BLOCK=4096, num_warps=8)
C_out = self.out_channels
scale_vec = self.int8_sw * amax.float()
bias = self.bias
M = B * H * W
out = torch.empty(M, C_out, device=x.device, dtype=x.dtype)
grid = (triton.cdiv(M, 128), triton.cdiv(C_out, 64))
_conv2d_s8[grid](qx, self.int8_qw, out, scale_vec,
bias.float() if bias is not None else scale_vec,
H, W, C_pad, C_out, 3 * C_pad, M,
BLOCK_M=128, BLOCK_N=64, BLOCK_K=64,
HAS_BIAS=bias is not None, num_warps=4, num_stages=4)
return out.view(B, H, W, C_out).permute(0, 3, 1, 2)
class Int8InnerConv3d(comfy.ops.disable_weight_init.Conv3d):
"""int8 replacement for the plain Conv3d inside the LTX CausalConv3d wrapper."""
def __init__(self, orig):
super().__init__(orig.in_channels, orig.out_channels, orig.kernel_size,
stride=orig.stride, padding=orig.padding)
self.weight = orig.weight
self.bias = orig.bias
qw, sw, self.int8_cpad = _pack_int8_weight(orig.weight, spatial_dims=3)
self.register_buffer("int8_qw", qw, persistent=False)
self.register_buffer("int8_sw", sw * (1.0 / 127.0), persistent=False)
def forward(self, x):
if not x.is_cuda or x.shape[0] != 1 or x.shape[2] < 3:
return super().forward(x)
if not x.is_contiguous(memory_format=CL3D) or x.is_contiguous():
x = x.contiguous(memory_format=CL3D)
B, C, T_in, H, W = x.shape
amax = x.abs().amax()
xr = x.permute(0, 2, 3, 4, 1).reshape(-1)
C_pad = self.int8_cpad
n_tot = T_in * H * W * C_pad
qx = torch.empty(n_tot, device=x.device, dtype=torch.int8)
_quant_pad_s8[(triton.cdiv(n_tot, 4096),)](xr, xr, qx, amax, C, C_pad, 0, 0, n_tot,
BLOCK=4096, num_warps=8)
C_out = self.out_channels
scale_vec = self.int8_sw * amax.float()
bias = self.bias
T_out = T_in - 2
M = T_out * H * W
out = torch.empty(M, C_out, device=x.device, dtype=x.dtype)
grid = (triton.cdiv(M, 128), triton.cdiv(C_out, 64))
_conv3d_s8[grid](qx, self.int8_qw, out, scale_vec,
bias.float() if bias is not None else scale_vec,
H, W, C_pad, C_out, 3 * C_pad, M,
BLOCK_M=128, BLOCK_N=64, BLOCK_K=64,
HAS_BIAS=bias is not None, num_warps=4, num_stages=4)
return out.view(1, T_out, H, W, C_out).permute(0, 4, 1, 2, 3)
def _ltx_inner_eligible(conv):
return isinstance(conv, torch.nn.Conv3d) and tuple(conv.kernel_size) == (3, 3, 3) \
and tuple(conv.stride) == (1, 1, 1) and tuple(conv.dilation) == (1, 1, 1) \
and tuple(conv.padding) == (0, 1, 1) and conv.padding_mode == "zeros" \
and conv.in_channels >= 64 and conv.in_channels % 32 == 0 and conv.out_channels >= 64
def build_int8_patches(model):
# SD1.5/SDXL activation ranges break per-tensor int8; flux-family KL is fine
kl_ok = getattr(getattr(getattr(model, "decoder", None), "conv_in", None), "in_channels", 0) >= 16
kl_skipped = False
patches = {}
for name, mod in model.named_modules():
if not name.startswith("decoder."):
continue
if isinstance(mod, Int8CausalConv3d):
patches[name] = mod
elif isinstance(mod, CausalConv3d) and tuple(mod.kernel_size) == (3, 3, 3) \
and tuple(mod.stride) == (1, 1, 1) and mod.in_channels >= 64 \
and mod.in_channels % 32 == 0 and mod.out_channels >= 64:
patches[name] = Int8CausalConv3d(mod)
elif isinstance(mod, ResnetBlock): # KL image VAEs
if not kl_ok:
kl_skipped = True
continue
for conv_name in ("conv1", "conv2"):
conv = getattr(mod, conv_name)
if isinstance(conv, Int8Conv2d):
patches[f"{name}.{conv_name}"] = conv
elif isinstance(conv, torch.nn.Conv2d) and tuple(conv.kernel_size) == (3, 3) \
and tuple(conv.stride) == (1, 1) and conv.in_channels >= 64 \
and conv.out_channels >= 64:
patches[f"{name}.{conv_name}"] = Int8Conv2d(conv)
elif isinstance(mod, LTXResnetBlock3D): # LTXV/LTX2
for conv_name in ("conv1", "conv2"):
wrapper = getattr(mod, conv_name)
if not isinstance(wrapper, LTXCausalConv3d):
continue
inner = wrapper.conv
if isinstance(inner, Int8InnerConv3d):
patches[f"{name}.{conv_name}.conv"] = inner
elif _ltx_inner_eligible(inner):
patches[f"{name}.{conv_name}.conv"] = Int8InnerConv3d(inner)
if kl_skipped:
logging.info("int8_conv: skipping SD1.5/SDXL-class VAE (4ch latent), int8 quality is insufficient there")
return patches
class PatchTritonVAE(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="PatchTritonVAE",
display_name="Patch Triton VAE",
category="KJNodes/experimental",
is_experimental=True,
description="Speeds up VAE decode/encode with fused Triton norm+SiLU kernels and channels_last conv layout. "
"Supported VAEs (auto-detected): Wan 2.1/2.2 video VAEs incl. Qwen-Image (RMSNorm, ~1.4x/1.15x), KL image VAEs "
"such as Flux/Flux2, SDXL and SD1.5 (GroupNorm, ~1.6-1.8x at 2048px), and LTXV/LTX2 video VAEs (PixelNorm; "
"timestep-conditioned decoder blocks get norm-only fusion). Other architectures are not supported. "
"Applied as object patches on a cloned patcher, so it only exists while this VAE is loaded.",
inputs=[
io.Vae.Input("vae"),
io.Boolean.Input("fuse_norm_silu", default=True, tooltip="Replace norm+SiLU chains (RMSNorm for Wan, GroupNorm for KL VAEs) with fused Triton kernels (single pass, fp32 accumulation). Requires triton."),
io.Boolean.Input("channels_last", default=True, tooltip="Convert conv weights to channels_last memory format, removing cuDNN layout transposes around every conv. Required for the fused GroupNorm kernel to engage on KL VAEs."),
io.Boolean.Input("int8_conv", default=False, tooltip="EXPERIMENTAL: run the VAE decoder's 3x3 convolutions on int8 tensor cores (4x the bf16 rate on Ada+). Weights quantized per-out-channel, activations dynamically per-tensor; ~45-48 dB vs the bf16 decode, minor quality loss possible. Wan 2.1/2.2 and KL image VAEs (Flux2/SDXL/SD1.5), ignored for others."),
io.Boolean.Input("autotune", default=False, tooltip="Benchmark several kernel block-size configs on first use of each tensor shape and cache the fastest. Brief stutter per new resolution, usually a few percent faster after warmup."),
],
outputs=[
io.Vae.Output(display_name="vae"),
],
)
@classmethod
def execute(cls, vae, fuse_norm_silu=True, channels_last=True, int8_conv=False, autotune=False) -> io.NodeOutput:
vae = copy.copy(vae)
model = vae.first_stage_model
if vae.patcher.is_dynamic():
# dynamic-vram rematerializes weights per forward, discarding channels_last
model.to(vae.vae_dtype)
new_patcher = comfy.model_patcher.ModelPatcher(
model, load_device=vae.patcher.load_device, offload_device=vae.patcher.offload_device)
# parentless patchers get flagged is_dead on GC (leak warning + full gc)
new_patcher.parent = vae.patcher
vae.patcher = new_patcher
def clear_dynamic_cast_flags(patcher, device_to, lowvram_model_memory, force_patch_weights, full_load):
# dynamic loads of the shared model set these without ever resetting them
for mod in patcher.model.modules():
if hasattr(mod, "comfy_cast_weights") and not hasattr(mod, "prev_comfy_cast_weights"):
mod.comfy_cast_weights = False
mod._v_signature = None
vae.patcher.add_callback_with_key(CallbacksMP.ON_LOAD, "wan_vae_fused_clear_cast", clear_dynamic_cast_flags)
else:
vae.patcher = vae.patcher.clone()
if fuse_norm_silu:
patches = build_object_patches(model, autotune=autotune)
if not patches:
raise RuntimeError("No fusable norm layers found, this node supports Wan video VAEs, KL image VAEs (Flux2/SDXL/SD1.5) and LTXV/LTX2 video VAEs")
for name, obj in patches.items():
vae.patcher.add_object_patch(name, obj)
logging.info(f"PatchTritonVAE: registered {len(patches)} fused norm object patches")
int8_applied = False
if int8_conv:
int8_patches = build_int8_patches(model)
if int8_patches:
for name, obj in int8_patches.items():
vae.patcher.add_object_patch(name, obj)
int8_applied = True
logging.info(f"PatchTritonVAE: registered {len(int8_patches)} int8 conv object patches")
if fuse_norm_silu:
# fused norms hand their amax to the int8 convs
for obj in vae.patcher.object_patches.values():
if isinstance(obj, (FusedRMSSiLU, FusedGNSiLU)):
obj.emit_amax = True
else:
logging.warning("PatchTritonVAE: int8_conv enabled but no eligible conv layers found (Wan video VAEs and flux-family KL VAEs only), skipping")
if channels_last or int8_applied:
if int8_applied and not channels_last:
logging.info("PatchTritonVAE: channels_last enforced, the int8 conv path requires it")
vae.disable_offload = True
# reapply per load: weight staging restores the original contiguous layout
def reapply_channels_last(patcher, device_to, lowvram_model_memory, force_patch_weights, full_load):
convert_conv_layout(patcher.model, channels_last=True)
vae.patcher.add_callback_with_key(CallbacksMP.ON_LOAD, "wan_vae_fused_channels_last", reapply_channels_last)
else:
convert_conv_layout(model, channels_last=False)
return io.NodeOutput(vae)
+2 -2
View File
@@ -1,9 +1,9 @@
[project]
name = "comfyui-kjnodes"
description = "Various quality of life -nodes for ComfyUI, mostly just visual stuff to improve usability."
version = "1.5.2"
version = "1.1.4"
license = {file = "LICENSE"}
dependencies = ["pillow>=10.3.0", "color-matcher", "matplotlib"]
dependencies = ["librosa", "numpy", "pillow>=10.3.0", "scipy", "color-matcher", "matplotlib", "huggingface_hub"]
[project.urls]
Repository = "https://github.com/kijai/ComfyUI-KJNodes"
+3 -1
View File
@@ -1,5 +1,7 @@
pillow>=10.3.0
scipy
color-matcher
matplotlib
huggingface_hub
mss
opencv-python-headless
opencv-python
+3 -94
View File
@@ -1,8 +1,7 @@
import torch
import numpy as np
from PIL import Image, ImageColor
from PIL import Image
from typing import Union, List
import logging
# Utility functions from mtb nodes: https://github.com/melMass/comfy_mtb
def pil2tensor(image: Union[Image.Image, List[Image.Image]]) -> torch.Tensor:
@@ -12,48 +11,6 @@ def pil2tensor(image: Union[Image.Image, List[Image.Image]]) -> torch.Tensor:
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
BBOX_TYPES = "BBOX,BOUNDING_BOX"
def normalize_bboxes(bboxes, bbox_format="xywh") -> List[tuple]:
"""Accept KJNodes BBOX tuples and/or native BOUNDING_BOX dicts, single or nested,
and return a flat list of 4-tuples in bbox_format ("xywh" or "xyxy").
Tuples are assumed to already be in bbox_format, dicts are converted."""
if isinstance(bboxes, (torch.Tensor, np.ndarray)):
bboxes = bboxes.tolist()
if isinstance(bboxes, dict):
bboxes = [bboxes]
elif isinstance(bboxes, (list, tuple)):
if len(bboxes) == 4 and all(isinstance(v, (int, float)) for v in bboxes):
bboxes = [bboxes]
else:
raise ValueError(f"Unsupported bbox input: {type(bboxes).__name__}")
out = []
for bbox in bboxes:
if isinstance(bbox, (torch.Tensor, np.ndarray)):
bbox = bbox.tolist()
if isinstance(bbox, (list, tuple)) and bbox and not all(isinstance(v, (int, float)) for v in bbox):
out.extend(normalize_bboxes(bbox, bbox_format)) # nested list, e.g. per-frame boxes
continue
if isinstance(bbox, dict):
x, y = int(bbox["x"]), int(bbox["y"])
w, h = int(bbox["width"]), int(bbox["height"])
out.append((x, y, x + w, y + h) if bbox_format == "xyxy" else (x, y, w, h))
elif isinstance(bbox, (list, tuple)) and len(bbox) >= 4:
out.append(tuple(int(v) for v in bbox[:4]))
else:
raise ValueError(f"Invalid bbox: {bbox}")
return out
def bbox_to_bounding_box(bbox, bbox_format="xywh") -> dict:
"""Convert a single KJNodes BBOX to the native BOUNDING_BOX dict."""
a, b, c, d = normalize_bboxes(bbox, bbox_format)[0]
if bbox_format == "xyxy":
return {"x": min(a, c), "y": min(b, d), "width": abs(c - a), "height": abs(d - b)}
return {"x": a, "y": b, "width": c, "height": d}
def np2tensor(img_np: Union[np.ndarray, List[np.ndarray]]) -> torch.Tensor:
if isinstance(img_np, list):
return torch.cat([np2tensor(img) for img in img_np], dim=0)
@@ -66,7 +23,7 @@ def tensor2np(tensor: torch.Tensor):
return np.clip(255.0 * tensor.cpu().numpy(), 0, 255).astype(np.uint8)
else: # Batch of images
return [np.clip(255.0 * t.cpu().numpy(), 0, 255).astype(np.uint8) for t in tensor]
def tensor2pil(image: torch.Tensor) -> List[Image.Image]:
batch_count = image.size(0) if len(image.shape) > 3 else 1
if batch_count > 1:
@@ -79,52 +36,4 @@ def tensor2pil(image: torch.Tensor) -> List[Image.Image]:
Image.fromarray(
np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
)
]
def string_to_color(color_string: str) -> List[int]:
color_list = [0, 0, 0] # Default fallback (black)
if ',' in color_string:
# Handle CSV format (e.g., "255, 0, 0" or "255, 0, 0, 128" or "1.0, 0.5, 0.0")
try:
values = [float(channel.strip()) for channel in color_string.split(',')]
# Convert to 0-255 range if values are in 0-1 range
if all(0 <= v <= 1 for v in values):
color_list = [int(v * 255) for v in values]
else:
color_list = [int(v) for v in values]
except ValueError:
logging.warning(f"Invalid color format: {color_string}. Using default black.")
elif color_string.startswith('#') or (color_string.lstrip('#').isalnum() and not color_string.lstrip('#').replace('.', '', 1).isdigit()):
# Could be Hex format or color name
color_string_stripped = color_string.lstrip('#')
# Try hex first
if len(color_string_stripped) in [6, 8] and all(c in '0123456789ABCDEFabcdef' for c in color_string_stripped):
if len(color_string_stripped) == 6: # #RRGGBB
color_list = [int(color_string_stripped[i:i+2], 16) for i in (0, 2, 4)]
elif len(color_string_stripped) == 8: # #RRGGBBAA
color_list = [int(color_string_stripped[i:i+2], 16) for i in (0, 2, 4, 6)]
else:
# Try color name (e.g., "red", "blue", "cyan")
try:
rgb = ImageColor.getrgb(color_string)
color_list = list(rgb)
except ValueError:
logging.warning(f"Invalid color name or hex format: {color_string}. Using default black.")
else:
# Handle single value (grayscale) - can be int or float
try:
value = float(color_string.strip())
# Convert to 0-255 range if it's a float between 0-1
if 0 <= value <= 1:
value = int(value * 255)
else:
value = int(value)
color_list = [value, value, value]
except ValueError:
logging.warning(f"Invalid color format: {color_string}. Using default black.")
# Clip values to valid range
color_list = np.clip(color_list, 0, 255).tolist()
return color_list
]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
const { app } = window.comfyAPI.app;
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "KJNodes.appearance",
+55
View File
@@ -0,0 +1,55 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "KJNodes.browserstatus",
setup() {
if (!app.ui.settings.getSettingValue("KJNodes.browserStatus")) {
return;
}
api.addEventListener("status", ({ detail }) => {
let title = "ComfyUI";
let favicon = "green";
let queueRemaining = detail && detail.exec_info.queue_remaining;
if (queueRemaining) {
favicon = "red";
title = `00% - ${queueRemaining} | ${title}`;
}
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
link.href = new URL(`../${favicon}.png`, import.meta.url);
document.title = title;
});
//add progress to the title
api.addEventListener("progress", ({ detail }) => {
const { value, max } = detail;
const progress = Math.floor((value / max) * 100);
let title = document.title;
if (!isNaN(progress) && progress >= 0 && progress <= 100) {
const paddedProgress = String(progress).padStart(2, '0');
title = `${paddedProgress}% ${title.replace(/^\d+%\s/, '')}`;
}
document.title = title;
});
},
init() {
if (!app.ui.settings.getSettingValue("KJNodes.browserStatus")) {
return;
}
const pythongossFeed = app.extensions.find(
(e) => e.name === 'pysssss.FaviconStatus',
)
if (pythongossFeed) {
console.warn("KJNodes - Overriding pysssss.FaviconStatus")
pythongossFeed.setup = function() {
console.warn("Disabled by KJNodes")
};
}
},
});
-485
View File
@@ -1,485 +0,0 @@
const { app } = window.comfyAPI.app;
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Linearly mix two hex colors: 0 = pure a, 1 = pure b. */
function mixColors(a, b, t) {
const parse = (hex) => {
if (hex.length === 4) hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
};
const ca = parse(a), cb = parse(b);
const r = Math.round(ca[0] + (cb[0] - ca[0]) * t);
const g = Math.round(ca[1] + (cb[1] - ca[1]) * t);
const bl = Math.round(ca[2] + (cb[2] - ca[2]) * t);
return `#${((1 << 24) | (r << 16) | (g << 8) | bl).toString(16).slice(1)}`;
}
/** Ensure a color string has a leading '#' (PrimeVue ColorPicker omits it). */
function asHex(color, fallback = "#000000") {
if (!color) return fallback;
return color.startsWith("#") ? color : `#${color}`;
}
function getSetting(id, fallback) {
return app.ui.settings.getSettingValue(id) ?? fallback;
}
// ── Pattern tiles ─────────────────────────────────────────────────────────────
// Each pattern is described by:
// tile(size) → [tileW, tileH] the period of the pattern in graph units.
// draw(ctx, size, t, fg, bg) draws ONE seamless tile of the foreground
// motif into a context already scaled so that
// [0..tileW] × [0..tileH] graph units fill the
// whole tile bitmap. The solid background is
// painted separately, so tiles are transparent
// except where the motif is drawn.
//
// At render time we rasterise one tile into a small OffscreenCanvas sized to the
// pattern's *on-screen* pixel size for the current zoom, then fill the canvas
// with createPattern("repeat"). This is O(1) per frame (the GPU repeats the
// tile) instead of stroking the whole graph every frame, and because the tile
// is rendered at native device resolution and blitted ~1:1
const patternDefs = {
dots: {
tile: (size) => [size, size],
draw: (ctx, size, t, fg) => {
ctx.fillStyle = fg;
ctx.beginPath();
ctx.arc(size / 2, size / 2, t * 2, 0, Math.PI * 2);
ctx.fill();
},
},
grid: {
tile: (size) => [size, size],
draw: (ctx, size, t, fg) => {
ctx.fillStyle = fg;
ctx.fillRect(size - t, 0, t, size - t);
ctx.fillRect(0, size - t, size, t);
},
},
"cross dots": {
tile: (size) => [size, size],
draw: (ctx, size, t, fg) => {
const half = size / 2;
const arm = Math.max(t + 1, size * 0.08);
const ht = t / 2;
ctx.fillStyle = fg;
ctx.fillRect(half - arm, half - ht, arm * 2, t);
ctx.fillRect(half - ht, half - arm, t, arm * 2);
},
},
blueprint: {
tile: (size) => [size, size],
draw: (ctx, size, t, fg, bg) => {
const sub = size / 4;
ctx.fillStyle = mixColors(bg, fg, 0.3);
for (let i = sub; i < size; i += sub) {
ctx.fillRect(i, 0, t, size - t);
ctx.fillRect(0, i, size, t);
}
ctx.fillStyle = fg;
ctx.fillRect(size - t, 0, t, size - t);
ctx.fillRect(0, size - t, size, t);
},
},
isometric: {
tile: (size) => [size, Math.round(size / 2)],
draw: (ctx, size, t, fg) => {
const w = size;
const h = Math.round(size / 2);
ctx.strokeStyle = fg;
ctx.lineWidth = t;
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w / 2, 0);
ctx.lineTo(w, h / 2);
ctx.moveTo(0, h / 2);
ctx.lineTo(w / 2, h);
ctx.lineTo(w, h / 2);
ctx.stroke();
},
},
hexagons: {
tile: (size) => [(size / 2) * 3, Math.round((size / 2) * Math.sqrt(3))],
draw: (ctx, size, t, fg) => {
const r = size / 2;
const h = Math.round(r * Math.sqrt(3));
ctx.strokeStyle = fg;
ctx.lineWidth = t;
const hex = (cx, cy) => {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = Math.PI / 3 * i;
const px = cx + r * Math.cos(a);
const py = cy + r * Math.sin(a);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.stroke();
};
hex(r, h / 2);
hex(r * 2.5, 0);
hex(r * 2.5, h);
},
},
octagons: {
tile: (size) => [size, size],
draw: (ctx, size, t, fg) => {
const s = size / (1 + Math.SQRT2);
const d = (size - s) / 2;
ctx.strokeStyle = fg;
ctx.lineWidth = t;
ctx.beginPath();
ctx.moveTo(d, 0);
ctx.lineTo(size - d, 0);
ctx.lineTo(size, d);
ctx.lineTo(size, size - d);
ctx.lineTo(size - d, size);
ctx.lineTo(d, size);
ctx.lineTo(0, size - d);
ctx.lineTo(0, d);
ctx.closePath();
ctx.stroke();
},
},
waves: {
tile: (size) => [size, size],
draw: (ctx, size, t, fg, bg) => {
const lines = Math.max(2, Math.round(size / 20));
const spacing = size / lines;
ctx.lineWidth = t;
for (let i = 0; i < lines; i++) {
const blend = 0.1 + (i % 3) * 0.07;
const amp = spacing * (0.15 + (i % 4) * 0.05);
const baseY = spacing * i + spacing / 2;
ctx.strokeStyle = mixColors(bg, fg, blend);
ctx.beginPath();
for (let x = 0; x <= size; x++) {
const y = baseY + Math.sin((x / size) * Math.PI * 2 + i * 0.8) * amp;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
},
},
"carbon fiber": {
tile: (size) => {
const cell = Math.max(2, Math.round(size / 4));
return [cell * 4, cell * 4];
},
draw: (ctx, size, t, fg, bg) => {
const cell = Math.max(2, Math.round(size / 4));
const s = cell * 4;
// Dark cells == background, left transparent so the bg shows through.
ctx.fillStyle = mixColors(bg, fg, 0.2);
for (let row = 0; row < 4; row++) {
for (let col = 0; col < 4; col++) {
if ((col + row) % 4 < 2) ctx.fillRect(col * cell, row * cell, cell, cell);
}
}
ctx.fillStyle = mixColors(bg, fg, 0.07);
const gt = Math.max(1, Math.round(t));
for (let i = 1; i < 4; i++) {
ctx.fillRect(0, i * cell, s, gt);
ctx.fillRect(i * cell, 0, gt, s);
}
},
},
};
// Ordered list of choices: 'default' (LiteGraph's own grid) + 'none' + patterns.
const PATTERN_KEYS = ["default", "none", ...Object.keys(patternDefs)];
// Below this on-screen tile size (device px) the motif is an indiscernible
// smear, so we skip it and show the plain background.
const MIN_TILE_PX = 3;
// Cap the tile bitmap. This bounds the only per-zoom-frame work (re-rendering
// one tile to match the new on-screen size); beyond this the tile is gently
// upscaled by the pattern fill (few, large tiles when zoomed in → fine).
const MAX_TILE_PX = 256;
// ── State ───────────────────────────────────────────────────────────────────
let currentType = "default";
let currentFg = "#444444";
let currentBg = "#212121";
let currentSize = 32;
let currentThickness = 2;
let currentTileW = 32; // tile period (graph units) for the active pattern
let currentTileH = 32;
let dirty = true;
// Bumped whenever a setting changes; lets the per-frame cache check compare a
// number instead of building a key string each frame (avoids GC churn on drag).
let settingsVersion = 0;
// Cached tile bitmap + pattern — reused across frames; rebuilt only when the
// settings version or the on-screen tile size changes. Panning/dragging (scale
// unchanged) always hits the cache with zero allocation.
const patternCache = { ver: -1, iDw: -1, iDh: -1, pattern: null };
function invalidateCache() {
dirty = true;
settingsVersion++;
app.canvas?.setDirty(true, true);
}
function readSettings() {
currentType = getSetting("KJNodes.canvasBg.pattern", "default");
currentFg = asHex(getSetting("KJNodes.canvasBg.patternColor", "#444444"));
currentBg = asHex(getSetting("KJNodes.canvasBg.bgColor", "#212121"));
currentSize = getSetting("KJNodes.canvasBg.scale", 32);
currentThickness = getSetting("KJNodes.canvasBg.thickness", 2);
// Resolve the tile period once per change so the render loop never allocates.
const def = patternDefs[currentType];
if (def) {
const dims = def.tile(currentSize);
currentTileW = dims[0];
currentTileH = dims[1];
}
}
/** Build (or reuse) a repeat pattern of one tile rendered at `iDw × iDh` px. */
function getPattern(ctx, def, tileW, tileH, iDw, iDh) {
if (patternCache.ver === settingsVersion && patternCache.iDw === iDw && patternCache.iDh === iDh && patternCache.pattern) {
return patternCache.pattern;
}
const oc = new OffscreenCanvas(iDw, iDh);
const tctx = oc.getContext("2d");
tctx.scale(iDw / tileW, iDh / tileH);
// Bake the background into the tile so the whole canvas is painted with a
// single opaque pattern fill (no separate solid-fill pass per frame).
tctx.fillStyle = currentBg;
tctx.fillRect(0, 0, tileW, tileH);
def.draw(tctx, currentSize, currentThickness, currentFg, currentBg);
patternCache.pattern = ctx.createPattern(oc, "repeat");
patternCache.ver = settingsVersion;
patternCache.iDw = iDw;
patternCache.iDh = iDh;
return patternCache.pattern;
}
// ── Commands ────────────────────────────────────────────────────────────────
const patternCommands = PATTERN_KEYS.map((key) => ({
id: `KJNodes.canvasBg.setPattern.${key}`,
label: key.charAt(0).toUpperCase() + key.slice(1),
menubarLabel: key.charAt(0).toUpperCase() + key.slice(1),
function: () => {
app.ui.settings.setSettingValue("KJNodes.canvasBg.pattern", key);
},
active: () => getSetting("KJNodes.canvasBg.pattern", "default") === key,
}));
// Single source of truth for slider bounds — commands reference these same objects.
const SCALE_ATTRS = { min: 8, max: 512, step: 4 };
const THICKNESS_ATTRS = { min: 0.5, max: 20, step: 0.5 };
function stepSetting(id, fallback, attrs, direction) {
const cur = getSetting(id, fallback);
const next = cur + attrs.step * direction;
if (next >= attrs.min && next <= attrs.max) {
app.ui.settings.setSettingValue(id, next);
}
}
const scaleCommands = [
{
id: "KJNodes.canvasBg.increaseScale",
label: "Increase pattern scale",
menubarLabel: "Increase scale",
function: () => stepSetting("KJNodes.canvasBg.scale", 32, SCALE_ATTRS, 1),
active: () => false,
},
{
id: "KJNodes.canvasBg.decreaseScale",
label: "Decrease pattern scale",
menubarLabel: "Decrease scale",
function: () => stepSetting("KJNodes.canvasBg.scale", 32, SCALE_ATTRS, -1),
active: () => false,
},
];
const thicknessCommands = [
{
id: "KJNodes.canvasBg.increaseThickness",
label: "Increase pattern thickness",
menubarLabel: "Increase thickness",
function: () => stepSetting("KJNodes.canvasBg.thickness", 2, THICKNESS_ATTRS, 1),
active: () => false,
},
{
id: "KJNodes.canvasBg.decreaseThickness",
label: "Decrease pattern thickness",
menubarLabel: "Decrease thickness",
function: () => stepSetting("KJNodes.canvasBg.thickness", 2, THICKNESS_ATTRS, -1),
active: () => false,
},
];
// ── Extension ───────────────────────────────────────────────────────────────
app.registerExtension({
name: "KJNodes.CanvasBackground",
commands: [...patternCommands, ...scaleCommands, ...thicknessCommands],
menuCommands: [
{
path: ["KJNodes", "Canvas Background"],
commands: patternCommands.map((c) => c.id),
},
{
path: ["KJNodes", "Canvas Background"],
commands: scaleCommands.map((c) => c.id),
},
{
path: ["KJNodes", "Canvas Background"],
commands: thicknessCommands.map((c) => c.id),
},
],
settings: [
{
id: "KJNodes.canvasBg.pattern",
name: "Background pattern",
category: ["KJNodes", "Canvas Background", "Pattern"],
tooltip: "Choose a background pattern for the node graph canvas. 'default' uses LiteGraph's built-in dot grid. 'none' disables all background drawing.",
type: "combo",
defaultValue: "default",
options: PATTERN_KEYS,
onChange: invalidateCache,
},
{
id: "KJNodes.canvasBg.patternColor",
name: "Pattern color",
category: ["KJNodes", "Canvas Background", "Pattern color"],
tooltip: "Foreground color of the pattern",
type: "color",
defaultValue: "#444444",
onChange: invalidateCache,
},
{
id: "KJNodes.canvasBg.bgColor",
name: "Background color",
category: ["KJNodes", "Canvas Background", "Background color"],
tooltip: "Background fill color behind the pattern",
type: "color",
defaultValue: "#212121",
onChange: invalidateCache,
},
{
id: "KJNodes.canvasBg.scale",
name: "Pattern scale",
category: ["KJNodes", "Canvas Background", "Pattern scale"],
tooltip: "Size of one pattern tile in pixels",
type: "slider",
defaultValue: 32,
attrs: SCALE_ATTRS,
onChange: invalidateCache,
},
{
id: "KJNodes.canvasBg.thickness",
name: "Pattern thickness",
category: ["KJNodes", "Canvas Background", "Pattern thickness"],
tooltip: "Thickness of lines and size of dots in pixels",
type: "slider",
defaultValue: 2,
attrs: THICKNESS_ATTRS,
onChange: invalidateCache,
},
],
setup() {
const canvas = app.canvas;
if (!canvas) return;
// onRenderBackground runs before LiteGraph applies its own graph
// transform and before it paints the clear color / background pattern.
// Returning true tells LiteGraph we handled the background, so it skips
// both. We then paint the whole canvas with a single repeat-pattern fill
const origCallback = canvas.onRenderBackground;
canvas.onRenderBackground = function (cvs, ctx) {
if (origCallback) {
const result = origCallback.call(this, cvs, ctx);
if (result) return true;
}
if (dirty) {
readSettings();
dirty = false;
}
// If ComfyUI's own background image setting is active, don't interfere.
if (getSetting("Comfy.Canvas.BackgroundImage", "")) return false;
// 'default' → let LiteGraph draw its built-in dot grid as usual.
if (currentType === "default") return false;
const scale = this.ds.scale;
const offset = this.ds.offset;
const dpr = window.devicePixelRatio || 1;
ctx.save();
const def = patternDefs[currentType];
let painted = false;
if (def) {
const tileW = currentTileW;
const tileH = currentTileH;
const k = scale * dpr;
const onW = tileW * k; // on-screen tile size, device px
const onH = tileH * k;
if (onW >= MIN_TILE_PX && onH >= MIN_TILE_PX) {
const iDw = Math.min(Math.round(onW), MAX_TILE_PX);
const iDh = Math.min(Math.round(onH), MAX_TILE_PX);
const pattern = getPattern(ctx, def, tileW, tileH, iDw, iDh);
// Tile the (bg-baked, opaque) pattern over the whole canvas with a
// single nearest-neighbor fill. The tile is rendered at device resolution
const a = Math.round(onW) <= MAX_TILE_PX ? 1 : onW / iDw;
const d = Math.round(onH) <= MAX_TILE_PX ? 1 : onH / iDh;
const periodX = a * iDw;
const periodY = d * iDh;
let px = dpr * scale * offset[0];
let py = dpr * scale * offset[1];
px -= Math.floor(px / periodX) * periodX;
py -= Math.floor(py / periodY) * periodY;
ctx.imageSmoothingEnabled = false;
ctx.setTransform(a, 0, 0, d, px, py);
ctx.fillStyle = pattern;
ctx.fillRect(-px / a, -py / d, cvs.width / a, cvs.height / d);
painted = true;
}
}
if (!painted) {
// 'none', or tiles too small to discern → plain background only.
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = currentBg;
ctx.fillRect(0, 0, cvs.width, cvs.height);
}
ctx.restore();
return true;
};
invalidateCache();
},
});
-836
View File
@@ -1,836 +0,0 @@
import { chainCallback, addMiddleClickPan, addWheelPassthrough } from './utility.js';
const { app } = window.comfyAPI.app;
// --- begin context window math (port of comfy/context_windows.py) ---
const MAX_WINDOWS = 512;
const TRUNC_MSG = `too many windows, truncated at ${MAX_WINDOWS}`;
function capReached(windows, errors) {
if (windows.length < MAX_WINDOWS) return false;
errors.push(TRUNC_MSG);
return true;
}
function pythonRound(x) {
// python round() is half-even
const floor = Math.floor(x);
const diff = x - floor;
if (diff > 0.5) return floor + 1;
if (diff < 0.5) return floor;
return floor % 2 === 0 ? floor : floor + 1;
}
function orderedHalving(val) {
const bin = BigInt(val).toString(2).padStart(64, '0').split('').reverse().join('');
return Number(BigInt('0b' + bin)) / 18446744073709551616;
}
function range(start, stop) {
const out = [];
for (let i = start; i < stop; i++) out.push(i);
return out;
}
// loop min/max; avoids Math.min(...arr) spread-limit crash on long windows
function arrMin(a) {
let m = Infinity;
for (let i = 0; i < a.length; i++) if (a[i] < m) m = a[i];
return m;
}
function arrMax(a) {
let m = -Infinity;
for (let i = 0; i < a.length; i++) if (a[i] > m) m = a[i];
return m;
}
function arraysEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function doesWindowRollOver(window, numFrames) {
let prev = -1;
for (let i = 0; i < window.length; i++) {
const val = ((window[i] % numFrames) + numFrames) % numFrames;
if (val < prev) return [true, i];
prev = val;
}
return [false, -1];
}
function shiftWindowToStart(window, numFrames) {
const startVal = window[0];
for (let i = 0; i < window.length; i++) {
window[i] = ((window[i] - startVal) + numFrames) % numFrames;
}
}
function shiftWindowToEnd(window, numFrames) {
shiftWindowToStart(window, numFrames);
const endDelta = numFrames - window[window.length - 1] - 1;
for (let i = 0; i < window.length; i++) window[i] += endDelta;
}
// p: {length, overlap, stride, closedLoop, step}
function createWindows(numFrames, schedule, p, errors) {
const L = p.length;
if (schedule === 'standard_static') {
if (numFrames <= L) return [range(0, numFrames)];
const delta = L - p.overlap;
if (delta <= 0) {
errors.push(`context_overlap (${p.overlap}) must be smaller than context_length (${L}); the sampler would error`);
return [];
}
const windows = [];
for (let startIdx = 0; startIdx < numFrames; startIdx += delta) {
const ending = startIdx + L;
if (ending >= numFrames) {
const finalStart = startIdx - (ending - numFrames);
windows.push(range(finalStart, finalStart + L));
break;
}
windows.push(range(startIdx, startIdx + L));
if (capReached(windows, errors)) break;
}
return windows;
}
if (schedule === 'standard_static_balanced') {
// standard_static count, placed evenly so seams are uniform (±1 frame)
if (numFrames <= L) return [range(0, numFrames)];
if (L - p.overlap <= 0) errors.push(`context_overlap (${p.overlap}) >= context_length (${L}); windows collapse to stride 1`);
const strideTarget = Math.max(1, L - p.overlap);
let N = Math.ceil((numFrames - L) / strideTarget) + 1; // stride <= L => no gaps
if (N > MAX_WINDOWS) {
errors.push(TRUNC_MSG);
N = MAX_WINDOWS;
}
const windows = [];
for (let i = 0; i < N; i++) {
const start = Math.floor(i * (numFrames - L) / (N - 1) + 0.5);
windows.push(range(start, start + L));
}
return windows;
}
if (schedule === 'batched') {
if (numFrames <= L) return [range(0, numFrames)];
const windows = [];
for (let startIdx = 0; startIdx < numFrames; startIdx += L) {
windows.push(range(startIdx, Math.min(startIdx + L, numFrames)));
if (capReached(windows, errors)) break;
}
return windows;
}
if (schedule === 'batched_shifted') {
// zero-overlap windows whose seams roll each step (~1.0x compute)
if (numFrames <= L) return [range(0, numFrames)];
const offset = pythonRound(numFrames * orderedHalving(p.step)) % L; // 0 at step 0 (plain batched)
const windows = [];
let start = 0;
if (offset > 0) { windows.push(range(0, offset)); start = offset; }
while (start < numFrames) {
const end = Math.min(start + L, numFrames);
windows.push(range(start, end));
start = end;
if (capReached(windows, errors)) break;
}
return windows;
}
if (schedule === 'looped_uniform' || schedule === 'standard_uniform') {
const looped = schedule === 'looped_uniform';
if (looped ? numFrames < L : numFrames <= L) return [range(0, numFrames)];
const windows = [];
const strideCount = Math.min(p.stride, Math.ceil(Math.log2(numFrames / L)) + 1);
for (let sPow = 0; sPow < strideCount; sPow++) {
const contextStep = 1 << sPow;
const pad = pythonRound(numFrames * orderedHalving(p.step));
const start = Math.floor(orderedHalving(p.step) * contextStep) + pad;
const stop = numFrames + pad + (looped && p.closedLoop ? 0 : -p.overlap);
const stepSize = L * contextStep - p.overlap;
if (stepSize <= 0) {
errors.push(`window advance is ${stepSize} (≤ 0) at stride ${contextStep}; the sampler would hang/error`);
continue;
}
for (let j = start; j < stop; j += stepSize) {
const w = [];
for (let e = j; e < j + L * contextStep; e += contextStep) {
w.push(((e % numFrames) + numFrames) % numFrames);
}
windows.push(w);
if (capReached(windows, errors)) return windows;
}
}
if (!looped) {
// shift roll-over windows to the end, fill gaps, drop duplicates
const deleteIdxs = [];
let winI = 0;
while (winI < windows.length) {
const [isRoll, rollIdx] = doesWindowRollOver(windows[winI], numFrames);
if (isRoll) {
const rollVal = windows[winI][rollIdx];
shiftWindowToEnd(windows[winI], numFrames);
if (!windows[(winI + 1) % windows.length].includes(rollVal)) {
windows.splice(winI + 1, 0, range(rollVal, rollVal + L));
}
}
for (let preI = 0; preI < winI; preI++) {
if (arraysEqual(windows[winI], windows[preI])) {
deleteIdxs.push(winI);
break;
}
}
winI++;
}
deleteIdxs.reverse();
for (const i of deleteIdxs) windows.splice(i, 1);
}
return windows;
}
return [range(0, numFrames)];
}
// extra overlap on the last static window from its shove-back; 0 = even tiling
function staticFinalDelta(F, L, overlap) {
if (F <= L) return 0;
const d = L - overlap;
if (d <= 0) return 0; // invalid config, flagged elsewhere
const mod = (F - L) % d;
return mod === 0 ? 0 : d - mod;
}
function linspace(a, b, n) {
if (n === 1) return [a];
const out = [];
for (let i = 0; i < n; i++) out.push(a + (b - a) * i / (n - 1));
return out;
}
function weightsPyramid(length) {
const seq = [];
if (length % 2 === 0) {
const m = length / 2;
for (let i = 1; i <= m; i++) seq.push(i);
for (let i = m; i >= 1; i--) seq.push(i);
} else {
const m = (length + 1) / 2;
for (let i = 1; i < m; i++) seq.push(i);
seq.push(m);
for (let i = m - 1; i >= 1; i--) seq.push(i);
}
return seq;
}
function weightsOverlapLinear(length, fullLength, idxs, overlap, errors) {
const w = new Array(length).fill(1);
if (overlap > length && errors) {
errors.push(`overlap-linear with context_overlap > context_length would error in the sampler`);
}
const ov = Math.min(overlap, length);
if (ov > 0) {
if (arrMin(idxs) > 0) {
const ramp = linspace(1e-37, 1, ov);
for (let i = 0; i < ov; i++) w[i] = ramp[i];
}
if (arrMax(idxs) < fullLength - 1) {
const ramp = linspace(1, 1e-37, ov);
for (let i = 0; i < ov; i++) w[length - ov + i] = ramp[i];
}
}
return w;
}
function weightsRelative(idxList) {
// per-index triangular bias (sequential weighted avg == bias/Σbias share)
const first = idxList[0];
const last = idxList[idxList.length - 1];
return idxList.map((idx) => {
const bias = 1 - Math.abs(idx - (first + last) / 2) / ((last - first + 1e-2) / 2);
return Math.max(1e-2, bias);
});
}
// smooth overlap-linear: flat interior, raised-cosine ramps that sum to 1
function weightsHann(length, fullLength, idxs, overlap, errors) {
const w = new Array(length).fill(1);
if (overlap > length && errors) {
errors.push(`hann with context_overlap (${overlap}) > context_length (${length}); ramp clamped`);
}
const ov = Math.min(Math.max(overlap, 0), length);
if (ov >= 1) {
const denom = Math.max(ov - 1, 1);
if (arrMin(idxs) > 0) {
for (let j = 0; j < ov; j++) w[j] = Math.max(0.5 * (1 - Math.cos(Math.PI * j / denom)), 1e-37);
}
if (arrMax(idxs) < fullLength - 1) {
for (let j = 0; j < ov; j++) w[length - ov + j] = Math.max(0.5 * (1 - Math.cos(Math.PI * (denom - j) / denom)), 1e-37);
}
}
return w;
}
// smooth bell over the window (smooth twin of pyramid)
function weightsGaussian(length) {
if (length <= 1) return new Array(length).fill(1);
const c = (length - 1) / 2;
const std = Math.max(length / 4, 1e-6);
const out = [];
for (let i = 0; i < length; i++) out.push(Math.exp(-0.5 * ((i - c) / std) ** 2));
return out;
}
function computeViz(p) {
const errors = [];
const numFrames = Math.max(1, p.numFrames);
const rawWindows = createWindows(numFrames, p.schedule, {
length: Math.max(1, p.length),
overlap: Math.max(0, p.overlap),
stride: Math.max(1, p.stride),
closedLoop: p.closedLoop,
step: Math.max(0, p.step),
}, errors);
const windows = rawWindows.map((idxList) => {
let anchor = -1;
if (p.causalFix) {
const a = idxList[0] - 1;
if (a >= 0 && a < numFrames) anchor = a;
}
let weights;
if (p.fuse === 'relative') weights = weightsRelative(idxList);
else if (p.fuse === 'flat') weights = new Array(idxList.length).fill(1);
else if (p.fuse === 'overlap-linear') weights = weightsOverlapLinear(idxList.length, numFrames, idxList, Math.max(0, p.overlap), errors);
else if (p.fuse === 'hann') weights = weightsHann(idxList.length, numFrames, idxList, Math.max(0, p.overlap), errors);
else if (p.fuse === 'gaussian') weights = weightsGaussian(idxList.length);
else weights = weightsPyramid(idxList.length);
const [rolls] = doesWindowRollOver(idxList, numFrames);
return { idxList, anchor, weights, rolls };
});
// per-frame total weight (counts_final in the handler)
const totals = new Float64Array(numFrames);
for (const w of windows) {
for (let pos = 0; pos < w.idxList.length; pos++) totals[w.idxList[pos]] += w.weights[pos];
}
const missing = [];
for (let f = 0; f < numFrames; f++) if (totals[f] === 0) missing.push(f);
let evals = 0;
for (const w of windows) evals += w.idxList.length + (w.anchor >= 0 ? 1 : 0);
return { numFrames, windows, totals, missing, evals, errors: [...new Set(errors)] };
}
// --- end context window math ---
const STYLE_ID = 'kj-cwviz-style';
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return;
const s = document.createElement('style');
s.id = STYLE_ID;
s.textContent = `
.kj-cw-root { width:100%; height:100%; display:flex; flex-direction:column; background:#181818; overflow:hidden; user-select:none; border-radius:4px; }
.kj-cw-bar { display:flex; align-items:center; gap:6px; padding:4px 6px; background:#222; color:#ddd; font:12px sans-serif; flex:0 0 auto; }
.kj-cw-btn { background:#333; color:#ddd; border:1px solid #444; padding:1px 10px; cursor:pointer; font:12px sans-serif; border-radius:3px; }
.kj-cw-btn:hover { background:#444; }
.kj-cw-scrubber { position:relative; flex:1 1 auto; height:20px; background:#2a2a2a; border:1px solid #444; cursor:ew-resize; user-select:none; touch-action:none; border-radius:3px; overflow:hidden; }
.kj-cw-scrubber-fill { position:absolute; top:0; bottom:0; left:0; width:0; background:#3a5a7a; pointer-events:none; }
.kj-cw-scrubber-text { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:#ddd; font:11px sans-serif; pointer-events:none; text-shadow:0 1px 2px rgba(0,0,0,0.6); }
.kj-cw-steps { width:42px; background:#2a2a2a; color:#ddd; border:1px solid #444; font:11px sans-serif; padding:2px 4px; border-radius:3px; }
.kj-cw-info { padding:2px 8px; color:#aaa; font:11px sans-serif; flex:0 0 auto; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.kj-cw-info.error { color:#e06c5a; }
.kj-cw-canvas { flex:1 1 auto; min-height:0; width:100%; display:block; cursor:crosshair; }
`;
document.head.appendChild(s);
}
const STEP_DEPENDENT = ['standard_uniform', 'looped_uniform', 'batched_shifted']; // schedules whose windows change per sampling step
const WINDOW_HUE_STEP = 137.508; // golden angle, distinct adjacent colors
function windowColor(i, sat = 65, light = 60, alpha = 1) {
return `hsla(${(i * WINDOW_HUE_STEP) % 360}, ${sat}%, ${light}%, ${alpha})`;
}
function pickTickStep(numFrames, plotW) {
const steps = [1, 2, 5, 10, 20, 25, 50, 100, 200, 500, 1000];
for (const s of steps) {
if ((plotW / numFrames) * s >= 34) return s;
}
return steps[steps.length - 1];
}
app.registerExtension({
name: 'KJNodes.ContextWindowsVisualizer',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== 'ContextWindowsVisualizerKJ') return;
chainCallback(nodeType.prototype, 'onNodeCreated', function () {
ensureStyles();
const node = this;
if (node.size[0] < 560 || node.size[1] < 480) node.setSize([Math.max(node.size[0], 560), Math.max(node.size[1], 480)]);
const root = document.createElement('div');
root.className = 'kj-cw-root';
const bar = document.createElement('div');
bar.className = 'kj-cw-bar';
const playBtn = document.createElement('button');
playBtn.className = 'kj-cw-btn';
playBtn.textContent = '▶';
playBtn.title = 'Animate sampling steps (uniform schedules shift their windows each step)';
const scrubber = document.createElement('div');
scrubber.className = 'kj-cw-scrubber';
const scrubberFill = document.createElement('div');
scrubberFill.className = 'kj-cw-scrubber-fill';
const scrubberText = document.createElement('span');
scrubberText.className = 'kj-cw-scrubber-text';
scrubber.appendChild(scrubberFill);
scrubber.appendChild(scrubberText);
const stepsInput = document.createElement('input');
stepsInput.className = 'kj-cw-steps';
stepsInput.type = 'number';
stepsInput.min = '1';
stepsInput.max = '999';
stepsInput.title = 'Total sampling steps to scrub through';
bar.appendChild(playBtn);
bar.appendChild(scrubber);
bar.appendChild(stepsInput);
const info = document.createElement('div');
info.className = 'kj-cw-info';
const canvas = document.createElement('canvas');
canvas.className = 'kj-cw-canvas';
root.appendChild(bar);
root.appendChild(info);
root.appendChild(canvas);
node.addDOMWidget('context_windows_viz', 'div', root, { serialize: false });
const removeMiddleClickPan = addMiddleClickPan(root);
addWheelPassthrough(root);
const state = {
step: 0,
steps: 20,
viz: null,
latent: null, // converted params used for the viz
hoverWin: -1,
hoverFrame: -1,
mouseX: 0,
mouseY: 0,
layout: null, // hit-test geometry from last draw
};
const getWidgetValue = (name, fallback) => {
const w = node.widgets?.find((w) => w.name === name);
return w ? w.value : fallback;
};
const persist = () => {
node.properties = node.properties || {};
node.properties.cw_viz = { step: state.step, steps: state.steps };
};
const readParams = () => {
const units = getWidgetValue('frame_units', 'pixel');
const factor = Math.max(getWidgetValue('temporal_downscale', 4), 1);
let numFrames = getWidgetValue('num_frames', 161);
let length = getWidgetValue('context_length', 81);
let overlap = getWidgetValue('context_overlap', 30);
if (units === 'pixel') {
numFrames = Math.max(Math.floor((numFrames - 1) / factor) + 1, 1);
length = Math.max(Math.floor((length - 1) / factor) + 1, 1);
overlap = Math.max(Math.floor((overlap - 1) / factor) + 1, 0);
}
const schedule = getWidgetValue('context_schedule', 'standard_static');
// flag the oversized final seam plain standard_static can leave
const finalDelta = schedule === 'standard_static' ? staticFinalDelta(numFrames, length, overlap) : 0;
return {
units,
factor,
numFrames,
length,
overlap,
finalDelta,
schedule,
stride: getWidgetValue('context_stride', 1),
closedLoop: !!getWidgetValue('closed_loop', false),
fuse: getWidgetValue('fuse_method', 'pyramid'),
causalFix: !!getWidgetValue('causal_window_fix', false),
step: state.step,
};
};
const updateScrubberUI = () => {
const t = state.steps > 1 ? state.step / (state.steps - 1) : 0;
scrubberFill.style.width = (t * 100) + '%';
const stepIndependent = state.latent && !STEP_DEPENDENT.includes(state.latent.schedule);
scrubberText.textContent = `step ${state.step + 1} / ${state.steps}` + (stepIndependent ? ' (static schedule, no effect)' : '');
stepsInput.value = String(state.steps);
};
const updateInfo = () => {
if (!state.viz) return;
const v = state.viz;
if (v.errors.length) {
info.classList.add('error');
info.textContent = '⚠ ' + v.errors.join(' · ');
info.title = v.errors.join('\n');
return;
}
info.classList.remove('error');
const lat = state.latent;
const overhead = v.numFrames > 0 ? (v.evals / v.numFrames) : 0;
const px = lat.units === 'pixel' ? ` (${(v.numFrames - 1) * lat.factor + 1}px frames)` : '';
let txt = `${v.windows.length} windows over ${v.numFrames} latent frames${px} · ${v.evals} frame evals/step (${overhead.toFixed(2)}×)`;
if (v.missing.length) txt += ` · ⚠ ${v.missing.length} uncovered frames`;
if (lat.schedule === 'standard_static' && lat.finalDelta > 0) {
txt += ` · ⚠ last window overlaps +${lat.finalDelta} frames; standard_static_balanced spreads seams evenly`;
} else if (lat.schedule === 'standard_static_balanced') {
txt += ` · balanced placement: even seams (±1 frame)`;
} else if (lat.schedule === 'batched_shifted') {
const off = pythonRound(v.numFrames * orderedHalving(lat.step)) % lat.length;
txt += v.windows.length > 1 ? ` · zero-overlap rolling seams (step offset ${off})` : ` · zero-overlap`;
}
info.textContent = txt;
info.title = txt;
};
let rafPending = false;
const scheduleRender = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
draw();
});
};
const recompute = () => {
state.latent = readParams();
state.viz = computeViz(state.latent);
state.hoverWin = -1;
state.hoverFrame = -1;
updateScrubberUI();
updateInfo();
scheduleRender();
};
const draw = () => {
const viz = state.viz;
if (!viz) return;
const dpr = window.devicePixelRatio || 1;
const cw = canvas.clientWidth;
const ch = canvas.clientHeight;
if (cw <= 0 || ch <= 0) return;
const W = Math.floor(cw * dpr);
const H = Math.floor(ch * dpr);
if (canvas.width !== W) canvas.width = W;
if (canvas.height !== H) canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#181818';
ctx.fillRect(0, 0, cw, ch);
const n = viz.numFrames;
const pad = 8;
const gutter = 30;
const rulerH = 16;
const chartH = Math.max(50, Math.min(110, ch * 0.28));
const chartGap = 18;
const plotX = pad + gutter;
const plotW = cw - plotX - pad;
const rowsY = pad + rulerH + 4;
const chartY = ch - pad - chartH;
const rowsH = chartY - chartGap - rowsY;
if (plotW <= 0 || rowsH <= 10) return;
const cellW = plotW / n;
const barW = Math.max(cellW - (cellW > 3 ? 0.5 : 0), 0.5); // cell width minus a hairline gap
const wins = viz.windows;
const rowH = wins.length ? Math.min(20, Math.max(3, rowsH / wins.length)) : 0;
state.layout = { plotX, plotW, rowsY, rowH, chartY, chartH, n };
// ruler
ctx.font = '10px sans-serif';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#777';
ctx.strokeStyle = '#3a3a3a';
const tick = pickTickStep(n, plotW);
for (let f = 0; f < n; f += tick) {
const x = plotX + (f + 0.5) * cellW;
ctx.textAlign = 'center';
ctx.fillText(String(f), x, pad + rulerH / 2);
ctx.beginPath();
ctx.moveTo(x, rowsY);
ctx.lineTo(x, chartY + chartH);
ctx.stroke();
}
// uncovered frames
if (viz.missing.length) {
ctx.fillStyle = 'rgba(220,70,60,0.25)';
for (const f of viz.missing) ctx.fillRect(plotX + f * cellW, rowsY, cellW, (chartY + chartH) - rowsY);
}
// window rows
const hov = state.hoverWin;
let drawnRows = wins.length;
for (let i = 0; i < wins.length; i++) {
const w = wins[i];
const y = rowsY + i * rowH;
if (y + rowH > rowsY + rowsH + 1) { drawnRows = i; break; }
const dim = hov >= 0 && hov !== i;
const maxW = arrMax(w.weights);
// row label
if (rowH >= 9) {
ctx.fillStyle = dim ? '#555' : '#999';
ctx.textAlign = 'right';
ctx.fillText(String(i), plotX - 4, y + rowH / 2);
}
for (let pos = 0; pos < w.idxList.length; pos++) {
const f = w.idxList[pos];
const wt = maxW > 0 ? w.weights[pos] / maxW : 1;
const alpha = (0.25 + 0.7 * wt) * (dim ? 0.25 : 1);
// also encode weight as cell height when rows are tall
const hFrac = rowH >= 8 ? 0.35 + 0.65 * wt : 1;
const cellH = Math.max(1.5, (rowH - 1) * hFrac);
ctx.fillStyle = windowColor(i, 65, 60, alpha);
ctx.fillRect(plotX + f * cellW, y + (rowH - 1 - cellH), barW, cellH);
}
// causal anchor marker
if (w.anchor >= 0 && !dim) {
ctx.strokeStyle = windowColor(i, 65, 75, 0.9);
ctx.setLineDash([2, 2]);
ctx.strokeRect(plotX + w.anchor * cellW + 0.5, y + 0.5, Math.max(cellW - 1, 1), Math.max(rowH - 2, 1));
ctx.setLineDash([]);
}
if (hov === i) {
const lo = arrMin(w.idxList);
const hi = arrMax(w.idxList);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(plotX + lo * cellW, y, (hi - lo + 1) * cellW, rowH - 1);
}
}
state.layout.drawnRows = drawnRows;
// blend chart: stacked normalized contribution per frame
ctx.fillStyle = '#888';
ctx.textAlign = 'left';
ctx.fillText('blend (normalized contribution per frame)', plotX, chartY - 8);
if (drawnRows < wins.length) {
ctx.textAlign = 'right';
ctx.fillText(`…+${wins.length - drawnRows} more windows (enlarge node)`, plotX + plotW, chartY - 8);
}
ctx.strokeStyle = '#3a3a3a';
ctx.strokeRect(plotX, chartY, plotW, chartH);
const stackOffsets = new Float64Array(n);
for (let i = 0; i < wins.length; i++) {
const w = wins[i];
const dim = hov >= 0 && hov !== i;
ctx.fillStyle = windowColor(i, dim ? 25 : 65, dim ? 38 : 58, dim ? 0.6 : 0.92);
for (let pos = 0; pos < w.idxList.length; pos++) {
const f = w.idxList[pos];
const total = viz.totals[f];
if (total <= 0) continue;
const h = (w.weights[pos] / total) * chartH;
const yOff = (stackOffsets[f] / total) * chartH;
stackOffsets[f] += w.weights[pos];
ctx.fillRect(plotX + f * cellW, chartY + chartH - yOff - h, barW, h);
}
}
// hover guide + tooltip
if (state.hoverFrame >= 0) {
const x = plotX + (state.hoverFrame + 0.5) * cellW;
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.beginPath();
ctx.moveTo(x, rowsY);
ctx.lineTo(x, chartY + chartH);
ctx.stroke();
}
const lines = [];
if (hov >= 0 && hov < wins.length) {
const w = wins[hov];
const strided = w.idxList.length > 1 ? w.idxList[1] - w.idxList[0] : 1;
let l = `window ${hov + 1}/${wins.length}: ${w.idxList.length} frames [${w.idxList[0]}…${w.idxList[w.idxList.length - 1]}]`;
if (Math.abs(strided) > 1) l += ` stride ${strided}`;
if (w.rolls) l += ' (wraps)';
if (w.anchor >= 0) l += ` · anchor ${w.anchor}`;
lines.push(l);
}
if (state.hoverFrame >= 0) {
const f = state.hoverFrame;
const cover = [];
for (let i = 0; i < wins.length; i++) {
const pos = wins[i].idxList.indexOf(f);
if (pos >= 0) cover.push([i, wins[i].weights[pos]]);
}
const px = state.latent.units === 'pixel' ? ` (≈px ${f * state.latent.factor})` : '';
if (cover.length === 0) {
lines.push(`frame ${f}${px}: not covered by any window!`);
} else {
const total = viz.totals[f];
const shares = cover.map(([i, wt]) => `w${i + 1} ${(100 * wt / total).toFixed(0)}%`).join(', ');
lines.push(`frame ${f}${px}: ${shares}`);
}
}
if (lines.length) {
ctx.font = '11px sans-serif';
const tw = Math.max(...lines.map((l) => ctx.measureText(l).width)) + 12;
const th = lines.length * 15 + 8;
let tx = state.mouseX + 14;
let ty = state.mouseY + 14;
if (tx + tw > cw - 4) tx = state.mouseX - tw - 8;
if (ty + th > ch - 4) ty = state.mouseY - th - 8;
ctx.fillStyle = 'rgba(20,20,20,0.92)';
ctx.strokeStyle = '#555';
ctx.fillRect(tx, ty, tw, th);
ctx.strokeRect(tx, ty, tw, th);
ctx.fillStyle = '#ddd';
ctx.textAlign = 'left';
for (let i = 0; i < lines.length; i++) ctx.fillText(lines[i], tx + 6, ty + 12 + i * 15);
}
};
// hover handling
canvas.addEventListener('pointermove', (e) => {
if (!state.viz || !state.layout) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
state.mouseX = x;
state.mouseY = y;
const lay = state.layout;
let frame = Math.floor((x - lay.plotX) / (lay.plotW / lay.n));
if (x < lay.plotX || frame < 0 || frame >= lay.n) frame = -1;
let win = -1;
const visibleRows = Math.min(lay.drawnRows ?? state.viz.windows.length, state.viz.windows.length);
if (y >= lay.rowsY && y < lay.rowsY + lay.rowH * visibleRows && lay.rowH > 0) {
win = Math.floor((y - lay.rowsY) / lay.rowH);
if (win >= visibleRows) win = -1;
} else if (y >= lay.chartY && y <= lay.chartY + lay.chartH && frame >= 0) {
// pick the stacked band under the cursor
const total = state.viz.totals[frame];
if (total > 0) {
const fromBottom = (lay.chartY + lay.chartH - y) / lay.chartH * total;
let acc = 0;
for (let i = 0; i < state.viz.windows.length; i++) {
const w = state.viz.windows[i];
const pos = w.idxList.indexOf(frame);
if (pos < 0) continue;
acc += w.weights[pos];
if (fromBottom <= acc) { win = i; break; }
}
}
}
if (win !== state.hoverWin || frame !== state.hoverFrame) {
state.hoverWin = win;
state.hoverFrame = frame;
}
scheduleRender();
});
canvas.addEventListener('pointerleave', () => {
state.hoverWin = -1;
state.hoverFrame = -1;
scheduleRender();
});
// step scrubber
let scrubbing = false;
const scrubFromEvent = (e) => {
const rect = scrubber.getBoundingClientRect();
const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const step = Math.round(t * (state.steps - 1));
if (step !== state.step) {
state.step = step;
persist();
recompute();
}
};
scrubber.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
stopPlayback();
scrubbing = true;
scrubber.setPointerCapture(e.pointerId);
scrubFromEvent(e);
e.preventDefault();
});
scrubber.addEventListener('pointermove', (e) => {
if (scrubbing) scrubFromEvent(e);
});
const endScrub = (e) => {
if (!scrubbing) return;
scrubbing = false;
try { scrubber.releasePointerCapture(e.pointerId); } catch (_) { }
};
scrubber.addEventListener('pointerup', endScrub);
scrubber.addEventListener('pointercancel', endScrub);
stepsInput.addEventListener('change', () => {
const v = parseInt(stepsInput.value, 10);
if (Number.isFinite(v) && v >= 1 && v <= 999) {
state.steps = v;
if (state.step >= v) state.step = v - 1;
persist();
recompute();
} else {
stepsInput.value = String(state.steps);
}
});
stepsInput.addEventListener('pointerdown', (e) => e.stopPropagation());
// step playback
let playHandle = 0;
const stopPlayback = () => {
if (playHandle) {
clearInterval(playHandle);
playHandle = 0;
playBtn.textContent = '▶';
}
};
playBtn.addEventListener('click', () => {
if (playHandle) {
stopPlayback();
return;
}
playBtn.textContent = '⏸';
playHandle = setInterval(() => {
state.step = (state.step + 1) % state.steps;
persist();
recompute();
}, 300);
});
// live updates on widget changes
const WATCHED = ['frame_units', 'temporal_downscale', 'num_frames', 'context_length', 'context_overlap',
'context_schedule', 'context_stride', 'closed_loop', 'fuse_method', 'causal_window_fix'];
for (const name of WATCHED) {
const w = node.widgets?.find((w) => w.name === name);
if (!w) continue;
const orig = w.callback;
w.callback = function (...args) {
const r = orig ? orig.apply(this, args) : undefined;
recompute();
return r;
};
}
const ro = new ResizeObserver(() => scheduleRender());
ro.observe(canvas);
chainCallback(node, 'onConfigure', function () {
const saved = this.properties?.cw_viz;
if (saved) {
if (Number.isFinite(saved.steps)) state.steps = Math.min(999, Math.max(1, saved.steps));
if (Number.isFinite(saved.step)) state.step = Math.min(state.steps - 1, Math.max(0, saved.step));
}
recompute();
});
chainCallback(node, 'onRemoved', function () {
stopPlayback();
ro.disconnect();
removeMiddleClickPan();
});
recompute();
});
},
});
+121 -156
View File
@@ -1,182 +1,147 @@
const { app } = window.comfyAPI.app;
import { app } from "../../../scripts/app.js";
// Adds context menu entries, code partly from pyssssscustom-scripts
function addMenuHandler(nodeType, cb) {
const getOpts = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function () {
const r = getOpts.apply(this, arguments);
cb.apply(this, arguments);
return r;
};
}
function addNode(name, nextTo, options) {
console.log("name:", name);
console.log("nextTo:", nextTo);
options = { side: "left", select: true, shiftY: 0, shiftX: 0, ...(options || {}) };
const node = LiteGraph.createNode(name);
const graph = app.canvas?.graph || app.graph;
graph.add(node);
app.graph.add(node);
node.pos = [
options.side === "left" ? nextTo.pos[0] - (node.size[0] + options.offset): nextTo.pos[0] + nextTo.size[0] + options.offset,
nextTo.pos[1] + options.shiftY,
];
// Automatically connect nodes
if (options.side === "left") {
// New node on left: connect new node's first output to nextTo's first free input
if (node.outputs && node.outputs.length > 0 && nextTo.inputs && nextTo.inputs.length > 0) {
for (let i = 0; i < nextTo.inputs.length; i++) {
if (!nextTo.inputs[i].link) {
node.pos[1] += i * (node.size[1] + 32);
node.connect(0, nextTo, i);
break;
}
}
}
} else {
// New node on right: connect nextTo's first free output to new node's first free input
if (nextTo.outputs && nextTo.outputs.length > 0 && node.inputs && node.inputs.length > 0) {
for (let o = 0; o < nextTo.outputs.length; o++) {
if (!nextTo.outputs[o].links || nextTo.outputs[o].links.length === 0) {
// Offset vertically by slot index so multiple Set nodes don't overlap
node.pos[1] += o * (node.size[1] + 32);
for (let i = 0; i < node.inputs.length; i++) {
if (!node.inputs[i].link) {
nextTo.connect(o, node, i);
break;
}
}
break;
}
}
}
}
if (options.select) {
app.canvas.selectNode(node, false);
}
return node;
}
// Expose addNode for use in setgetnodes.js
window.kjNodes = window.kjNodes || {};
window.kjNodes.addNode = addNode;
app.registerExtension({
name: "KJNodes.Contextmenu",
settings: [
{
id: "KJNodes.helpPopup",
name: "Help popups",
category: ["KJNodes", "General", "Help popups"],
tooltip: "Show help popups when hovering over KJNodes",
defaultValue: true,
type: "boolean",
},
{
id: "KJNodes.showSetGetInConnectionMenu",
name: "Show Set/Get in connection menu",
category: ["KJNodes", "Set & Get", "Show Set/Get in connection menu"],
tooltip: "Add Set/Get entries to the slot connection menu (next to Add Reroute)",
type: "boolean",
defaultValue: true,
},
],
getNodeMenuItems(node) {
try {
const items = [];
if (node.inputs && node.inputs.length > 0) {
const selectedForConvert = window.kjNodes.snapshotSelectedNodes(node);
items.push(
name: "KJNodesContextmenu",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.input && nodeData.input.required) {
addMenuHandler(nodeType, function (_, options) {
options.unshift(
{
content: "Add GetNode",
callback: () => { addNode("GetNode", node, { side: "left", offset: 30 }); }
content: "Add GetNode",
callback: () => {addNode("GetNode", this, { side:"left", offset: 30});}
},
{
content: "Add SetNode",
callback: () => { addNode("SetNode", node, { side: "right", offset: 30 }); }
},
{
content: "Add PreviewAsTextNode",
callback: () => { addNode("PreviewAny", node, { side: "right", offset: 30 }); }
},
{
content: "Convert all outputs to Set/Get",
callback: () => {
for (const n of selectedForConvert) window.kjNodes.convertOutputsToSetGet(node.graph, n);
}
},
);
}
const cls = node.constructor?.comfyClass || node.comfyClass;
if (cls && window.kjNodes?.recreateNode) {
items.push({
content: "Recreate node",
has_submenu: true,
submenu: {
options: [
{ content: "Keep widget values", callback: () => window.kjNodes.recreateNode(node, cls, { resetValues: false }) },
{ content: "Reset widget values", callback: () => window.kjNodes.recreateNode(node, cls, { resetValues: true }) },
],
content: "Add SetNode",
callback: () => {addNode("SetNode", this, { side:"right", offset: 30 });
},
});
}
return items;
} catch (err) {
console.error("[KJNodes.Contextmenu] getNodeMenuItems failed:", err);
return [];
});
}
},
async setup(app) {
const origShowConnectionMenu = app.canvas.showConnectionMenu.bind(app.canvas);
// Inject "Add SetNode" / "Add GetNode" into the slot connection dropdown menu.
app.canvas.showConnectionMenu = function(optPass) {
const showSetGet = app.ui.settings.getSettingValue("KJNodes.showSetGetInConnectionMenu") ?? true;
if (!showSetGet) return origShowConnectionMenu(optPass);
const isFrom = optPass.nodeFrom && optPass.slotFrom != null;
const nodeType = isFrom ? "SetNode" : "GetNode";
const label = isFrom ? "Add SetNode" : "Add GetNode";
const OrigCM = LiteGraph.ContextMenu;
let interceptActive = true;
LiteGraph.ContextMenu = function(options, menuOpts) {
LiteGraph.ContextMenu = OrigCM;
if (!interceptActive) return new OrigCM(options, menuOpts);
interceptActive = false;
const idx = options.indexOf("Add Reroute");
if (idx !== -1) options.splice(idx + 1, 0, label);
const origCb = menuOpts.callback;
menuOpts.callback = function(v, cbOpts, e) {
if (v === label) {
const node = LiteGraph.createNode(nodeType);
if (node) {
const graph = app.canvas?.graph || app.graph;
node.pos = [optPass.e?.canvasX ?? 0, optPass.e?.canvasY ?? 0];
graph.add(node);
if (isFrom && optPass.nodeFrom) {
const slotIdx = typeof optPass.slotFrom === 'number'
? optPass.slotFrom
: optPass.nodeFrom.findOutputSlot(optPass.slotFrom.name);
optPass.nodeFrom.connect(slotIdx, node, 0);
} else if (optPass.nodeTo) {
const slotIdx = typeof optPass.slotTo === 'number'
? optPass.slotTo
: optPass.nodeTo.findInputSlot(optPass.slotTo.name);
node.connect(0, optPass.nodeTo, slotIdx);
}
graph.change();
app.canvas.setDirty(true, true);
async setup(app) {
const updateSlots = (value) => {
const valuesToAddToIn = ["GetNode"];
const valuesToAddToOut = ["SetNode"];
// Remove entries if they exist
for (const arr of Object.values(LiteGraph.slot_types_default_in)) {
for (const valueToAdd of valuesToAddToIn) {
const idx = arr.indexOf(valueToAdd);
if (idx !== -1) {
arr.splice(idx, 1);
}
return;
}
return origCb?.call(this, v, cbOpts, e);
};
return new OrigCM(options, menuOpts);
}
for (const arr of Object.values(LiteGraph.slot_types_default_out)) {
for (const valueToAdd of valuesToAddToOut) {
const idx = arr.indexOf(valueToAdd);
if (idx !== -1) {
arr.splice(idx, 1);
}
}
}
if (value!="disabled") {
for (const arr of Object.values(LiteGraph.slot_types_default_in)) {
for (const valueToAdd of valuesToAddToIn) {
const idx = arr.indexOf(valueToAdd);
if (idx !== -1) {
arr.splice(idx, 1);
}
if (value === "top") {
arr.unshift(valueToAdd);
} else {
arr.push(valueToAdd);
}
}
}
for (const arr of Object.values(LiteGraph.slot_types_default_out)) {
for (const valueToAdd of valuesToAddToOut) {
const idx = arr.indexOf(valueToAdd);
if (idx !== -1) {
arr.splice(idx, 1);
}
if (value === "top") {
arr.unshift(valueToAdd);
} else {
arr.push(valueToAdd);
}
}
}
}
};
LiteGraph.ContextMenu.prototype = OrigCM.prototype;
const result = origShowConnectionMenu(optPass);
LiteGraph.ContextMenu = OrigCM;
interceptActive = false;
return result;
};
}
app.ui.settings.addSetting({
id: "KJNodes.SetGetMenu",
name: "KJNodes: Make Set/Get -nodes defaults",
tooltip: 'Adds Set/Get nodes to the top or bottom of the list of available node suggestions.',
options: ['disabled', 'top', 'bottom'],
defaultValue: 'disabled',
type: "combo",
onChange: updateSlots,
});
app.ui.settings.addSetting({
id: "KJNodes.MiddleClickDefault",
name: "KJNodes: Middle click default node adding",
defaultValue: false,
type: "boolean",
onChange: (value) => {
LiteGraph.middle_click_slot_add_default_node = value;
},
});
app.ui.settings.addSetting({
id: "KJNodes.nodeAutoColor",
name: "KJNodes: Automatically set node colors",
type: "boolean",
defaultValue: true,
});
app.ui.settings.addSetting({
id: "KJNodes.helpPopup",
name: "KJNodes: Help popups",
defaultValue: true,
type: "boolean",
});
app.ui.settings.addSetting({
id: "KJNodes.disablePrefix",
name: "KJNodes: Disable automatic Set_ and Get_ prefix",
defaultValue: true,
type: "boolean",
});
app.ui.settings.addSetting({
id: "KJNodes.browserStatus",
name: "KJNodes: 🟢 Stoplight browser status icon 🔴",
defaultValue: false,
type: "boolean",
});
}
});
-770
View File
@@ -1,770 +0,0 @@
import { addMiddleClickPan, captureVideoFrame, chainCallback, makeUUID, watchImageInputs } from '../utility.js';
export function createEditorStylesheet(id, className) {
let styleTag = document.head.querySelector(`#${id}`)
if (!styleTag) {
styleTag = document.createElement('style')
styleTag.type = 'text/css'
styleTag.id = id
styleTag.innerHTML = `
.${className} {
position: absolute;
font: 12px monospace;
line-height: 1.5em;
padding: 10px;
z-index: 0;
overflow: hidden;
}
.${className} canvas {
position: relative;
z-index: 2;
}
`
document.head.appendChild(styleTag)
}
}
function styleMenuItem(menuItem) {
menuItem.style.display = "block";
menuItem.style.padding = "5px";
menuItem.style.color = "#FFF";
menuItem.style.fontFamily = "Arial, sans-serif";
menuItem.style.fontSize = "16px";
menuItem.style.textDecoration = "none";
menuItem.style.marginBottom = "5px";
}
function createMenuItem(id, textContent) {
let menuItem = document.createElement("a");
menuItem.href = "#";
menuItem.dataset.menuId = id;
menuItem.textContent = textContent;
styleMenuItem(menuItem);
return menuItem;
}
function setupMenuItems(contextMenu, menuItems) {
menuItems.forEach(mi => {
mi.addEventListener('mouseover', function () { this.style.backgroundColor = "gray"; });
mi.addEventListener('mouseout', function () { this.style.backgroundColor = "#202020"; });
contextMenu.appendChild(mi);
});
}
export function createContextMenuElement(className) {
const menu = document.createElement("div");
if (className) menu.className = className;
menu.id = `context-menu-${Math.random().toString(36).slice(2, 10)}`;
menu.style.display = "none";
menu.style.position = "absolute";
menu.style.backgroundColor = "#202020";
menu.style.minWidth = "100px";
menu.style.boxShadow = "0px 8px 16px 0px rgba(0,0,0,0.2)";
menu.style.zIndex = "100";
menu.style.padding = "5px";
return menu;
}
// ─── Base Editor Canvas ───
const maxDisplayDim = 1024;
const buttonRowHeight = 28;
export class BaseEditorCanvas {
constructor(context, reset = false) {
this.node = context;
this.reset = reset;
this.bgImage = null;
this.margin = 14;
this.dragIndex = -1;
this.dragType = null;
this.dragOffset = null;
this._uploadGeneration = 0;
}
// ─── Shared Methods ───
setNodeWidth(width) {
this.node.setSize([width, this.node.size[1]]);
const nodeEl = document.querySelector(`[data-node-id="${this.node.id}"]`);
if (nodeEl) nodeEl.style.setProperty('--node-width', `${width}px`);
}
// Scale factors: coord space → canvas space
get scaleX() { return this.width / this.coordWidth; }
get scaleY() { return this.height / this.coordHeight; }
// Returns mouse position in coord space
getLocalMouse(e) {
const rect = this.canvas.getBoundingClientRect();
const canvasScaleX = this.canvas.width / rect.width;
const canvasScaleY = this.canvas.height / rect.height;
const canvasX = (e.clientX - rect.left) * canvasScaleX - this.margin;
const canvasY = (e.clientY - rect.top) * canvasScaleY - this.margin;
return {
x: canvasX / this.scaleX,
y: canvasY / this.scaleY
};
}
// Clamp to coord space
clamp(x, y) {
return {
x: Math.max(0, Math.min(this.coordWidth, x)),
y: Math.max(0, Math.min(this.coordHeight, y))
};
}
// ─── Canvas Setup ───
createCanvas(parentElement) {
this.canvas = document.createElement('canvas');
this.canvas.width = this.width + this.margin * 2;
this.canvas.height = this.height + this.margin * 2;
this.ctx = this.canvas.getContext('2d');
parentElement.appendChild(this.canvas);
}
resizeCanvas() {
this.canvas.width = this.width + this.margin * 2;
this.canvas.height = this.height + this.margin * 2;
}
// ─── Event Listeners ───
setupEventListeners() {
this._onDragMove = (e) => this.onMouseMove(e);
this._onDragEnd = (e) => this.onMouseUp(e);
this._removeMiddleClickPan = addMiddleClickPan(this.canvas);
this._onCanvasPointerDown = (e) => { e.stopPropagation(); this.onMouseDown(e); };
this._onCanvasPointerMove = (e) => this.onMouseMove(e);
this._onCanvasContextMenu = (e) => { e.preventDefault(); e.stopPropagation(); };
this.canvas.addEventListener('pointerdown', this._onCanvasPointerDown);
this.canvas.addEventListener('pointermove', this._onCanvasPointerMove);
this.canvas.addEventListener('contextmenu', this._onCanvasContextMenu);
}
removeEventListeners() {
if (this.canvas) {
this.canvas.removeEventListener('pointerdown', this._onCanvasPointerDown);
this.canvas.removeEventListener('pointermove', this._onCanvasPointerMove);
this.canvas.removeEventListener('pointermove', this._onDragMove);
this.canvas.removeEventListener('pointerup', this._onDragEnd);
this.canvas.removeEventListener('contextmenu', this._onCanvasContextMenu);
}
document.removeEventListener('pointermove', this._onDragMove);
document.removeEventListener('pointerup', this._onDragEnd);
if (this._removeMiddleClickPan) this._removeMiddleClickPan();
}
startDocumentDrag(e) {
if (e && e.pointerId !== undefined) {
this.canvas.setPointerCapture(e.pointerId);
this._capturedPointerId = e.pointerId;
// With pointer capture, events fire on the canvas, not document
this.canvas.addEventListener('pointermove', this._onDragMove);
this.canvas.addEventListener('pointerup', this._onDragEnd);
} else {
document.addEventListener('pointermove', this._onDragMove);
document.addEventListener('pointerup', this._onDragEnd);
}
}
endDrag() {
this.dragIndex = -1;
this.dragType = null;
this.dragOffset = null;
this.canvas.style.cursor = 'default';
if (this._capturedPointerId !== undefined) {
try { this.canvas.releasePointerCapture(this._capturedPointerId); } catch (_) {}
this._capturedPointerId = undefined;
this.canvas.removeEventListener('pointermove', this._onDragMove);
this.canvas.removeEventListener('pointerup', this._onDragEnd);
} else {
document.removeEventListener('pointermove', this._onDragMove);
document.removeEventListener('pointerup', this._onDragEnd);
}
}
// ─── Render Batching ───
render() {
if (this._renderPending) return;
this._renderPending = true;
requestAnimationFrame(() => {
this._renderPending = false;
this._render();
});
}
// Subclasses must implement _render()
// ─── Common Render Helpers ───
// Convert coord-space point to canvas-space pixel
toCanvas(x, y) {
return { x: x * this.scaleX, y: y * this.scaleY };
}
// Call at start of _render() — clears and draws background
beginRender() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.save();
ctx.translate(this.margin, this.margin);
ctx.fillStyle = '#222';
ctx.fillRect(0, 0, this.width, this.height);
ctx.strokeStyle = 'gray';
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, this.width, this.height);
if (this.bgImage) {
ctx.drawImage(this.bgImage, 0, 0, this.width, this.height);
}
}
endRender() {
this.ctx.restore();
}
// ─── Image Handling ───
handleImageLoad = (img, downscaledImg) => {
// Set coord space to image dimensions, rescaling existing points if the space changed
const oldCoordW = this.coordWidth, oldCoordH = this.coordHeight;
this.coordWidth = img.width;
this.coordHeight = img.height;
this.widthWidget.value = img.width;
this.heightWidget.value = img.height;
if (oldCoordW && oldCoordH && (oldCoordW !== img.width || oldCoordH !== img.height)) {
this.onCoordSpaceResized?.(oldCoordW, oldCoordH);
}
this.onImageResize?.(img);
// Cap display size to the current node width if the user has already resized it,
// otherwise fall back to maxDisplayDim. This prevents the node from expanding to
// fill the image — instead the image scales to fit the node.
const nodeCanvasW = Math.max(64, Math.round(this.node.size[0] - 45));
const fitDim = Math.min(nodeCanvasW, maxDisplayDim);
let displayW = img.width, displayH = img.height;
if (displayW > fitDim || displayH > fitDim) {
const scale = fitDim / Math.max(displayW, displayH);
displayW = Math.round(displayW * scale);
displayH = Math.round(displayH * scale);
}
if (displayW !== this.width || displayH !== this.height) {
this.width = displayW;
this.height = displayH;
this.resizeCanvas();
// Only expand the node width if it's narrower than the image — never shrink it
if (displayW + 45 > this.node.size[0]) this.setNodeWidth(displayW + 45);
this.onSizeChanged();
if (this.node.graph) {
try { this.node.arrange?.(); } catch (_) {}
this.node.graph.setDirtyCanvas(true, true);
}
}
// Use downscaled image if available to avoid holding full-res in memory
this.bgImage = downscaledImg || img;
this.render();
this.onDataChanged();
};
// resizeCanvas: if true (default), resize editor canvas to match image.
// If false, just store the image and update the background without resizing.
processImage = (img, { resize = true } = {}) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let width = img.width, height = img.height;
if (width > maxDisplayDim || height > maxDisplayDim) {
const scale = maxDisplayDim / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
const { app } = window.comfyAPI.app;
const embed = app.ui.settings.getSettingValue("KJNodes.editors.embedBackgroundImage") ?? false;
// Use the downscaled canvas directly as bgImage — drawImage accepts canvas elements,
// avoids a data URL round-trip, and is immediately available (no async decode).
const onStored = () => {
if (resize) {
this.handleImageLoad(img, canvas);
} else {
this.bgImage = canvas;
this.render();
}
};
const gen = ++this._uploadGeneration;
if (embed) {
const base64String = canvas.toDataURL('image/webp', 0.5).replace(/^data:.+?,/, '');
if (gen !== this._uploadGeneration) return;
this.node.properties.imgData = { type: 'image/webp', base64: base64String };
onStored();
} else {
canvas.toBlob((blob) => {
if (gen !== this._uploadGeneration) return;
const filename = `editor_bg_${this.node.id}_${Date.now()}.webp`;
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'temp');
formData.append('overwrite', 'true');
fetch('/upload/image', { method: 'POST', body: formData })
.then(r => r.json())
.then(result => {
if (gen !== this._uploadGeneration) return;
this.node.properties.imgData = { type: 'temp', filename: result.name };
onStored();
})
.catch(e => console.error("Failed to upload editor background:", e));
}, 'image/webp', 0.5);
}
};
handleImageFile = (file) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); this.processImage(img); };
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
};
refreshBackgroundImage = () => {
const imgData = this.node.properties.imgData;
if (!imgData) return;
const img = new Image();
img.onerror = (e) => console.error("Background image failed to load:", e);
img.onload = () => {
// Just set the background — don't resize canvas, widget values are already correct from serialization
this.bgImage = img;
this.render();
};
if (imgData.base64) {
const mimeType = imgData.type || 'image/png';
img.src = `data:${mimeType};base64,${imgData.base64}`;
} else if (imgData.filename) {
img.src = `/view?filename=${encodeURIComponent(imgData.filename)}&type=temp&no-cache=${Date.now()}`;
}
};
// ─── Context Menu Helpers ───
// Set up document-level listeners for context menu behavior
setupContextMenuListeners(editorIdPrefix) {
this._onContextMenu = (e) => {
if (e.target.closest(`#${editorIdPrefix}-${this.node.uuid}`) ||
this.node.contextMenu.contains(e.target)) {
e.preventDefault();
}
};
this._onDocClick = (e) => {
if (!this.node.contextMenu.contains(e.target)) {
this.node.contextMenu.style.display = 'none';
}
};
document.addEventListener('contextmenu', this._onContextMenu);
document.addEventListener('click', this._onDocClick);
}
// Clean up previous editor instance
cleanupPreviousEditor(context) {
if (context.editor) {
context.editor.destroy();
}
}
// Full cleanup — override in subclass to add additional cleanup
destroy() {
this._uploadGeneration++; // Invalidate any pending async image uploads
this.removeEventListeners();
if (this._onContextMenu) document.removeEventListener('contextmenu', this._onContextMenu);
if (this._onDocClick) document.removeEventListener('click', this._onDocClick);
if (this._onKeyUp) document.removeEventListener('keyup', this._onKeyUp);
}
// Common menu actions
openImageFilePicker() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) this.handleImageFile(file);
});
fileInput.click();
}
clearBackgroundImage() {
this.bgImage = null;
this.node.properties.imgData = null;
this.render();
}
// Find a widget by name on the node
findWidget(name) {
const w = this.node.widgets.find(w => w.name === name);
if (!w) console.warn(`${this.constructor.name}: widget "${name}" not found`);
return w;
}
// Show context menu at mouse position
showContextMenu(e) {
this._updateMenuToggleStates();
const menu = this.node.contextMenu;
menu.style.display = 'block';
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
// Adjust if menu overflows viewport
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = `${Math.max(0, e.clientX - rect.width)}px`;
if (rect.bottom > window.innerHeight) menu.style.top = `${Math.max(0, e.clientY - rect.height)}px`;
}
// ─── Width/Height Resize ───
setupSizeCallbacks() {
this.widthWidget.callback = () => {
const oldCoordW = this.coordWidth;
this.coordWidth = this.widthWidget.value;
this.onCoordSpaceResized(oldCoordW, this.coordHeight);
this.render();
this.onDataChanged();
};
this.heightWidget.callback = () => {
const oldCoordH = this.coordHeight;
this.coordHeight = this.heightWidget.value;
this.onCoordSpaceResized(this.coordWidth, oldCoordH);
this.render();
this.onDataChanged();
};
}
// ─── imgData persistence ───
// Call once from onNodeCreated to ensure imgData survives save/reload
static setupImgDataPersistence(node) {
chainCallback(node, "onSerialize", function (o) {
if (this.properties.imgData && o.properties) {
o.properties.imgData = this.properties.imgData;
}
});
chainCallback(node, "onConfigure", function (info) {
if (info?.properties?.imgData) {
this.properties.imgData = info.properties.imgData;
}
});
}
// ─── Node registration helper ───
// Call from onNodeCreated to set up the common editor scaffolding.
// config: { editorClass, editorKey, heightKey, className, menuItems, hiddenWidgets, initialSize, extraProperties }
// editorClass: the editor constructor (e.g. SplineEditor)
// editorKey: node property name for the DOM widget (e.g. 'splineEditor')
// heightKey: node property name for editor height (e.g. 'splineEditorHeight')
// className: CSS class for the editor container
// menuItems: { id: { label, action(editor), toggle?(editor) }, ... } — context menu definition
// menuClassName: optional CSS class for the context menu
// hiddenWidgets: widget names to hide
// initialSize: [width, height] for the node
// extraProperties: array of [name, default, type] to register via addProperty on first create
static setupNode(node, nodeData, config) {
const { editorClass, editorKey, heightKey, className, menuItems,
menuClassName, hiddenWidgets, initialSize, extraProperties } = config;
if (!node.properties) node.properties = {};
if (node.properties.imgData === undefined) node.properties.imgData = null;
// File handling — set up once per node, delegate to current editor
node.pasteFile = (file) => {
if (node.editor && file.type.startsWith("image/")) {
node.editor.handleImageFile(file);
return true;
}
return false;
};
node.onDragOver = (e) => {
if (node.editor && e.dataTransfer && e.dataTransfer.items) {
return [...e.dataTransfer.items].some(f => f.kind === "file" && f.type.startsWith("image/"));
}
return false;
};
node.onDragDrop = (e) => {
if (!node.editor) return false;
let handled = false;
for (const file of e.dataTransfer.files) {
if (file.type.startsWith("image/")) {
node.editor.handleImageFile(file);
handled = true;
}
}
return handled;
};
for (const name of (hiddenWidgets || [])) {
const w = node.widgets.find(w => w.name === name);
if (w) w.hidden = true;
}
const element = document.createElement("div");
node.uuid = makeUUID();
element.id = `${className}-${node.uuid}`;
node.previewMediaType = 'image';
node[editorKey] = node.addDOMWidget(nodeData.name, `${editorClass.name}Widget`, element, {
serialize: false, hideOnZoom: false,
getMinHeight: () => node[heightKey] || 550,
getMaxHeight: () => node[heightKey] || 550,
getHeight: () => node[heightKey] || 550,
});
node[heightKey] = 550;
node.contextMenu = createContextMenuElement(menuClassName);
node._menuDef = menuItems;
const menuEls = Object.entries(menuItems).map(([id, def]) => createMenuItem(id, def.label || id));
setupMenuItems(node.contextMenu, menuEls);
document.body.appendChild(node.contextMenu);
// Shared helper — loads the stored background image into the editor.
// alignToImage: if true, resizes the node to fit the image first (old behaviour).
// if false, scales the image to fit the current node size (new behaviour).
const _reloadBgImage = (alignToImage) => {
const imgData = node.properties.imgData;
if (!imgData) return;
const img = new Image();
img.onload = () => {
if (!node.editor) return;
if (alignToImage) {
// Temporarily lift the node-size cap so processImage can expand the node
const savedSize = [node.size[0], node.size[1]];
node.setSize([Math.min(img.width, maxDisplayDim) + 45, savedSize[1]]);
}
node.editor.processImage(img);
};
if (imgData.base64) {
img.src = `data:${imgData.type || 'image/png'};base64,${imgData.base64}`;
} else if (imgData.filename) {
img.src = `/view?filename=${encodeURIComponent(imgData.filename)}&type=temp&no-cache=${Date.now()}`;
}
};
// Two side-by-side buttons in a single DOM widget row.
// "Reset canvas" — clears points and re-fits the image to the current node size.
// "Align to image" — resizes the node to match the image dimensions (legacy behaviour).
const buttonRow = document.createElement("div");
buttonRow.style.cssText = "display:flex;gap:4px;width:100%;box-sizing:border-box;";
const makeRowBtn = (label, onClick) => {
const btn = document.createElement("button");
btn.textContent = label;
btn.style.cssText = "flex:1;height:24px;padding:0 6px;font:12px sans-serif;cursor:pointer;";
btn.addEventListener("click", onClick);
return btn;
};
buttonRow.appendChild(makeRowBtn("Reset canvas", () => {
try {
node.editor = new editorClass(node, true);
_reloadBgImage(false);
} catch (error) { console.error(`Error creating ${editorClass.name}:`, error); }
}));
buttonRow.appendChild(makeRowBtn("Align to image", () => {
try {
if (!node.editor) node.editor = new editorClass(node);
_reloadBgImage(true);
} catch (error) { console.error(`Error aligning ${editorClass.name}:`, error); }
}));
node.addDOMWidget("editor_buttons", "editorButtonRow", buttonRow, {
serialize: false, hideOnZoom: false,
getMinHeight: () => buttonRowHeight, getMaxHeight: () => buttonRowHeight, getHeight: () => buttonRowHeight,
});
node.setSize(initialSize);
node[editorKey].parentEl = document.createElement("div");
node[editorKey].parentEl.className = className;
node[editorKey].parentEl.id = `${className}-${node.uuid}`;
element.appendChild(node[editorKey].parentEl);
// Auto-create for new nodes; onConfigure cancels this for saved workflows
node._autoCreatePending = setTimeout(() => {
if (!node.editor) {
try {
node.editor = new editorClass(node);
for (const [name, value, type] of (extraProperties || [])) {
node.addProperty(name, value, type);
}
} catch (error) { console.error(`Error creating ${editorClass.name}:`, error); }
}
}, 0);
BaseEditorCanvas.setupImgDataPersistence(node);
chainCallback(node, "onResize", function () {
const editor = this.editor;
if (!editor) return;
const newWidth = Math.max(64, Math.round(this.size[0] - 45));
if (newWidth === editor.width) return;
// Only change display size — coord space stays the same
editor.width = newWidth;
editor.height = Math.round(newWidth * (editor.coordHeight / editor.coordWidth));
editor.resizeCanvas();
editor.onSizeChanged();
editor.render();
});
chainCallback(node, "onConfigure", function () {
clearTimeout(this._autoCreatePending);
try { this.editor = new editorClass(this); }
catch (error) { console.error(`Error configuring ${editorClass.name}:`, error); }
// Allow watchImageInputs to operate only after configure + a delay,
// so refreshBackgroundImage has time to restore the saved image first.
node._bgWatchReady = false;
setTimeout(() => { node._bgWatchReady = true; }, 500);
});
chainCallback(node, "onExecuted", function (message) {
let bg_image = message["bg_image"];
if (Array.isArray(bg_image)) bg_image = bg_image[0];
if (bg_image) {
const img = new Image();
img.src = `data:image/jpeg;base64,${bg_image}`;
img.onload = () => {
if (this.editor) this.editor.processImage(img);
};
}
});
// Load background image from connected source node (LoadImage, VHS, etc.)
// _bgWatchReady is false during reload until the editor has restored its saved background.
// _bgFromConnectedSource tracks whether the current bg came from a connection (vs execution/paste).
node._bgFromConnectedSource = false;
node._bgWatchReady = true; // true for new nodes, set false during onConfigure
watchImageInputs(node, "bg_image", (sources) => {
if (!node.editor || !node._bgWatchReady) return;
const source = sources[0];
if (source?.isVideo && source.videoEl) {
// Capture the first/current frame from a VHS-style video preview
node._bgFromConnectedSource = true;
captureVideoFrame(source.videoEl, (canvas) => {
if (node.editor) node.editor.processImage(canvas);
});
} else if (source && !source.isVideo) {
node._bgFromConnectedSource = true;
const img = new Image();
img.crossOrigin = "anonymous";
img.onerror = (e) => console.error("[Editor] source image load error:", e);
img.onload = () => { if (node.editor) node.editor.processImage(img); };
img.src = source.url;
} else if (!source && node._bgFromConnectedSource) {
node._bgFromConnectedSource = false;
node.editor.clearBackgroundImage();
}
});
chainCallback(node, "onRemoved", function () {
if (this.editor) this.editor.destroy();
if (this.contextMenu?.parentNode) this.contextMenu.parentNode.removeChild(this.contextMenu);
});
}
// ─── Shared constructor flow ───
// Call from subclass constructor after setting up widgets and data.
// editorKey: e.g. 'pointsEditor' or 'splineEditor'
// heightKey: e.g. 'pointsEditorHeight' or 'splineEditorHeight'
// heightOffset: pixels added to canvas height for full node height (e.g. 310 or 460)
initEditor(editorKey, heightKey, heightOffset) {
this._editorKey = editorKey;
this._heightKey = heightKey;
this._heightOffset = heightOffset;
this.createCanvas(this.node[editorKey].element);
if (this.width > 256) this.setNodeWidth(this.width + 45);
this.node[heightKey] = this.height + 40;
this.node.setSize([this.node.size[0], this.height + heightOffset + buttonRowHeight]);
this.setupEventListeners();
this.render();
}
// Shared onSizeChanged — uses stored heightKey/heightOffset
onSizeChanged() {
this.node[this._heightKey] = this.height + 40;
this.node.setSize([this.node.size[0], this.height + this._heightOffset + buttonRowHeight]);
if (this.node.graph) this.node.graph.setDirtyCanvas(true, true);
}
// Shared constructor preamble — cleanup, reset, context menu, coord/display init
initEditorPreamble(editorKey, className) {
this._className = className;
this.cleanupPreviousEditor(this.node);
if (this.reset && this.node[editorKey].element) {
this.node[editorKey].element.innerHTML = '';
}
this.createContextMenu();
}
// Shared coord/display size init from widgets + saved node size
initDisplaySize() {
this.coordWidth = this.widthWidget.value;
this.coordHeight = this.heightWidget.value;
const savedWidth = Math.max(64, Math.round(this.node.size[0] - 45));
this.width = Math.min(savedWidth, maxDisplayDim);
this.height = Math.round(this.width * (this.coordHeight / this.coordWidth));
}
// Shared context menu creation — clone to clear stale listeners, wire up action handlers
createContextMenu() {
const oldMenu = this.node.contextMenu;
const newMenu = oldMenu.cloneNode(true);
oldMenu.parentNode.replaceChild(newMenu, oldMenu);
this.node.contextMenu = newMenu;
this.setupContextMenuListeners(this._className);
const self = this;
newMenu.addEventListener('click', (e) => {
e.preventDefault();
if (e.target.tagName !== 'A') return;
const id = e.target.dataset.menuId;
const def = this.node._menuDef[id];
if (def?.action) {
def.action(self);
self._updateMenuToggleStates();
}
newMenu.style.display = 'none';
});
}
// Update toggle item styling from menu definitions
_updateMenuToggleStates() {
const menuDef = this.node._menuDef;
this.node.contextMenu.querySelectorAll('a').forEach(item => {
const def = menuDef[item.dataset.menuId];
if (def?.toggle) {
const on = def.toggle(this);
item.style.color = on ? '#4fc3f7' : '#FFF';
item.style.borderLeft = on ? '3px solid #4fc3f7' : '3px solid transparent';
item.style.paddingLeft = '8px';
}
});
}
// ─── Hooks for subclasses ───
// Override these instead of duplicating logic:
// Called after data changes (render + update widgets)
onDataChanged() {}
// Called on image load for editor-specific state (e.g., drawRuler = false)
onImageResize() {}
// Called when coord space changes — override to rescale coordinates
onCoordSpaceResized(_oldWidth, _oldHeight) {}
}
-211
View File
@@ -1,211 +0,0 @@
// ─── Interpolation: SVG path d-string builders ───
// Ported from protovis basis/cardinal/monotone/hermite algorithms
const Interpolation = {
_basisPoint(t0, p0, t1, p1, t2, p2, t3, p3) {
return t0 * p0 + t1 * p1 + t2 * p2 + t3 * p3;
},
_pathBasis(p0, p1, p2, p3) {
const bp = Interpolation._basisPoint;
const x1 = bp(0, p0.x, 2/3, p1.x, 1/3, p2.x, 0, p3.x);
const y1 = bp(0, p0.y, 2/3, p1.y, 1/3, p2.y, 0, p3.y);
const x2 = bp(0, p0.x, 1/3, p1.x, 2/3, p2.x, 0, p3.x);
const y2 = bp(0, p0.y, 1/3, p1.y, 2/3, p2.y, 0, p3.y);
const x = bp(0, p0.x, 1/6, p1.x, 2/3, p2.x, 1/6, p3.x);
const y = bp(0, p0.y, 1/6, p1.y, 2/3, p2.y, 1/6, p3.y);
return `C${x1},${y1},${x2},${y2},${x},${y}`;
},
basis(points) {
if (points.length <= 2) return Interpolation.linear(points);
let d = '';
let b0 = points[0], b1 = points[0], b2 = points[0], b3 = points[1];
d += Interpolation._pathBasis(b0, b1, b2, b3);
for (let i = 2; i < points.length; i++) {
b0 = b1; b1 = b2; b2 = b3; b3 = points[i];
d += Interpolation._pathBasis(b0, b1, b2, b3);
}
b0 = b1; b1 = b2; b2 = b3;
d += Interpolation._pathBasis(b0, b1, b2, b3);
b0 = b1; b1 = b2;
d += Interpolation._pathBasis(b0, b1, b2, b3);
return d;
},
_cardinalTangents(points, tension) {
const alpha = (1 - tension) / 2;
const tangents = [];
let f = points[0], g = points[1], h = points[2];
for (let i = 3; i < points.length; i++) {
tangents.push({ x: alpha * (h.x - f.x), y: alpha * (h.y - f.y) });
f = g; g = h; h = points[i];
}
tangents.push({ x: alpha * (h.x - f.x), y: alpha * (h.y - f.y) });
return tangents;
},
_hermite(points, tangents) {
if (tangents.length < 1 ||
(points.length !== tangents.length && points.length !== tangents.length + 2))
return '';
const quad = points.length !== tangents.length;
let d = '';
let g = points[0], h = points[1], i = tangents[0], j = i, k = 1;
if (quad) {
d += `Q${h.x - i.x * 2/3},${h.y - i.y * 2/3},${h.x},${h.y}`;
g = points[1]; k = 2;
}
if (tangents.length > 1) {
j = tangents[1]; h = points[k]; k++;
d += `C${g.x + i.x},${g.y + i.y},${h.x - j.x},${h.y - j.y},${h.x},${h.y}`;
for (let idx = 2; idx < tangents.length; idx++, k++) {
h = points[k]; j = tangents[idx];
d += `S${h.x - j.x},${h.y - j.y},${h.x},${h.y}`;
}
}
if (quad) {
const last = points[k];
d += `Q${h.x + j.x * 2/3},${h.y + j.y * 2/3},${last.x},${last.y}`;
}
return d;
},
cardinal(points, tension) {
if (points.length <= 2) return Interpolation.linear(points);
return Interpolation._hermite(points, Interpolation._cardinalTangents(points, tension));
},
_monotoneTangents(points) {
const n = points.length;
const d = [], m = [], dx = [];
for (let i = 0; i < n - 1; i++) d.push((points[i + 1].y - points[i].y) / (points[i + 1].x - points[i].x));
m.push(d[0]);
for (let i = 1; i < n - 1; i++) m.push((d[i - 1] + d[i]) / 2);
m.push(d[n - 2]);
dx.push(points[1].x - points[0].x);
for (let i = 1; i < n - 1; i++) dx.push((points[i + 1].x - points[i - 1].x) / 2);
dx.push(points[n - 1].x - points[n - 2].x);
for (let i = 0; i < n - 1; i++) { if (Math.abs(d[i]) < 1e-7) { m[i] = 0; m[i + 1] = 0; } }
for (let i = 0; i < n - 1; i++) {
if (Math.abs(m[i]) >= 1e-5 && Math.abs(m[i + 1]) >= 1e-5) {
const alpha = m[i] / d[i], beta = m[i + 1] / d[i], sigma = alpha * alpha + beta * beta;
if (sigma > 9) { const k = 3 / Math.sqrt(sigma); m[i] = k * alpha * d[i]; m[i + 1] = k * beta * d[i]; }
}
}
const tangents = [];
for (let i = 0; i < n; i++) { const denom = 1 + m[i] * m[i]; tangents.push({ x: dx[i] / 3 / denom, y: m[i] * dx[i] / 3 / denom }); }
return tangents;
},
monotone(points) {
if (points.length <= 2) return Interpolation.linear(points);
return Interpolation._hermite(points, Interpolation._monotoneTangents(points));
},
linear(points) { let d = ''; for (let i = 1; i < points.length; i++) d += `L${points[i].x},${points[i].y}`; return d; },
stepBefore(points) { let d = ''; for (let i = 1; i < points.length; i++) d += `V${points[i].y}H${points[i].x}`; return d; },
stepAfter(points) { let d = ''; for (let i = 1; i < points.length; i++) d += `H${points[i].x}V${points[i].y}`; return d; },
bezier(points) {
let d = '';
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1], cur = points[i];
d += `C${prev.h2x ?? prev.x},${prev.h2y ?? prev.y},${cur.h1x ?? cur.x},${cur.h1y ?? cur.y},${cur.x},${cur.y}`;
}
return d;
},
ensureBezierHandles(points) {
for (let i = 0; i < points.length; i++) {
if (points[i].h1x !== undefined) continue;
const prev = points[Math.max(0, i - 1)], next = points[Math.min(points.length - 1, i + 1)];
const dx = (next.x - prev.x) * 0.25, dy = (next.y - prev.y) * 0.25;
points[i].h1x = points[i].x - dx; points[i].h1y = points[i].y - dy;
points[i].h2x = points[i].x + dx; points[i].h2y = points[i].y + dy;
}
},
buildPathD(points, interpolation, tension) {
if (!points || points.length === 0) return '';
let d = `M${points[0].x},${points[0].y}`;
if (points.length === 1) return d;
switch (interpolation) {
case 'basis': d += Interpolation.basis(points); break;
case 'cardinal': d += Interpolation.cardinal(points, tension); break;
case 'monotone': d += Interpolation.monotone(points); break;
case 'step-before': d += Interpolation.stepBefore(points); break;
case 'step-after': d += Interpolation.stepAfter(points); break;
case 'bezier': Interpolation.ensureBezierHandles(points); d += Interpolation.bezier(points); break;
case 'linear': default: d += Interpolation.linear(points); break;
}
return d;
},
drawOnCanvas(ctx, points, interpolation, tension) {
if (!points || points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
switch (interpolation) {
case 'basis':
if (points.length < 3) { ctx.lineTo(points[1].x, points[1].y); break; }
Interpolation._drawBasisCanvas(ctx, points); break;
case 'cardinal':
if (points.length < 3) { ctx.lineTo(points[1].x, points[1].y); break; }
Interpolation._drawHermiteCanvas(ctx, points, Interpolation._cardinalTangents(points, tension)); break;
case 'monotone':
if (points.length < 3) { ctx.lineTo(points[1].x, points[1].y); break; }
Interpolation._drawHermiteCanvas(ctx, points, Interpolation._monotoneTangents(points)); break;
case 'step-before':
for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i - 1].x, points[i].y); ctx.lineTo(points[i].x, points[i].y); } break;
case 'step-after':
for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i - 1].y); ctx.lineTo(points[i].x, points[i].y); } break;
case 'bezier':
Interpolation.ensureBezierHandles(points);
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1], cur = points[i];
ctx.bezierCurveTo(prev.h2x ?? prev.x, prev.h2y ?? prev.y, cur.h1x ?? cur.x, cur.h1y ?? cur.y, cur.x, cur.y);
} break;
case 'linear': default:
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y); break;
}
},
_drawBasisCanvas(ctx, points) {
const bp = Interpolation._basisPoint;
const drawSeg = (p0, p1, p2, p3) => {
ctx.bezierCurveTo(
bp(0,p0.x,2/3,p1.x,1/3,p2.x,0,p3.x), bp(0,p0.y,2/3,p1.y,1/3,p2.y,0,p3.y),
bp(0,p0.x,1/3,p1.x,2/3,p2.x,0,p3.x), bp(0,p0.y,1/3,p1.y,2/3,p2.y,0,p3.y),
bp(0,p0.x,1/6,p1.x,2/3,p2.x,1/6,p3.x), bp(0,p0.y,1/6,p1.y,2/3,p2.y,1/6,p3.y));
};
let b0 = points[0], b1 = points[0], b2 = points[0], b3 = points[1];
drawSeg(b0, b1, b2, b3);
for (let i = 2; i < points.length; i++) { b0 = b1; b1 = b2; b2 = b3; b3 = points[i]; drawSeg(b0, b1, b2, b3); }
b0 = b1; b1 = b2; b2 = b3; drawSeg(b0, b1, b2, b3);
b0 = b1; b1 = b2; drawSeg(b0, b1, b2, b3);
},
_drawHermiteCanvas(ctx, points, tangents) {
if (tangents.length < 1 || (points.length !== tangents.length && points.length !== tangents.length + 2)) return;
const quad = points.length !== tangents.length;
let g = points[0], h = points[1], i = tangents[0], j = i, k = 1;
let prevCp2x, prevCp2y;
if (quad) { ctx.quadraticCurveTo(h.x - i.x * 2/3, h.y - i.y * 2/3, h.x, h.y); g = points[1]; k = 2; }
if (tangents.length > 1) {
j = tangents[1]; h = points[k]; k++;
prevCp2x = h.x - j.x; prevCp2y = h.y - j.y;
ctx.bezierCurveTo(g.x + i.x, g.y + i.y, prevCp2x, prevCp2y, h.x, h.y);
for (let idx = 2; idx < tangents.length; idx++, k++) {
const prevH = h; h = points[k]; j = tangents[idx];
const rcp1x = 2 * prevH.x - prevCp2x, rcp1y = 2 * prevH.y - prevCp2y;
prevCp2x = h.x - j.x; prevCp2y = h.y - j.y;
ctx.bezierCurveTo(rcp1x, rcp1y, prevCp2x, prevCp2y, h.x, h.y);
}
}
if (quad) { const last = points[k]; ctx.quadraticCurveTo(h.x + j.x * 2/3, h.y + j.y * 2/3, last.x, last.y); }
}
};
export { Interpolation };
-370
View File
@@ -1,370 +0,0 @@
import { chainCallback, rectHitTest, cursorForBboxMode } from '../utility.js';
import { BaseEditorCanvas, createEditorStylesheet } from './editor_base.js';
const { app } = window.comfyAPI.app;
createEditorStylesheet('kj-pointseditor-stylesheet', 'points-editor');
app.registerExtension({
name: 'KJNodes.PointEditor',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name === 'PointsEditor') {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
BaseEditorCanvas.setupNode(this, nodeData, {
editorClass: PointsEditor,
editorKey: 'pointsEditor',
heightKey: 'pointsEditorHeight',
className: 'points-editor',
menuItems: {
"Load Image": { action: (ed) => ed.openImageFilePicker() },
"Clear Image": { action: (ed) => ed.clearBackgroundImage() },
},
hiddenWidgets: ["coordinates", "neg_coordinates", "bboxes"],
initialSize: [550, 550],
extraProperties: [
["points", this.constructor.type, "string"],
["neg_points", this.constructor.type, "string"],
],
});
});
}
}
});
class PointsEditor extends BaseEditorCanvas {
constructor(context, reset = false) {
super(context, reset);
this.initEditorPreamble('pointsEditor', 'points-editor');
this.pos_coordWidget = this.findWidget("coordinates");
this.neg_coordWidget = this.findWidget("neg_coordinates");
this.pointsStoreWidget = this.findWidget("points_store");
this.widthWidget = this.findWidget("width");
this.heightWidget = this.findWidget("height");
this.bboxStoreWidget = this.findWidget("bbox_store");
this.bboxWidget = this.findWidget("bboxes");
this.setupSizeCallbacks();
this.pointsStoreWidget.callback = () => {
try {
const parsed = JSON.parse(this.pointsStoreWidget.value);
this.points = parsed.positive || [];
this.neg_points = parsed.negative || [];
} catch (e) { console.error("Error parsing points data:", e); }
this.render();
this.updateData();
};
this.bboxStoreWidget.callback = () => {
try { this.bbox = JSON.parse(this.bboxStoreWidget.value); }
catch (e) { console.error("Error parsing bbox data:", e); }
this.render();
this.updateData();
};
this.initDisplaySize();
this.points = [];
this.neg_points = [];
this.bbox = [{}];
this.drawing = false;
this.selectedIndex = -1;
this.pointRadius = Math.max(4, Math.log(Math.max(1, Math.min(this.coordWidth, this.coordHeight))) * 4);
if (!reset && this.pointsStoreWidget.value !== "") {
try {
const parsed = JSON.parse(this.pointsStoreWidget.value);
this.points = parsed.positive || [];
this.neg_points = parsed.negative || [];
this.bbox = JSON.parse(this.bboxStoreWidget.value);
} catch (e) {
console.error("Error parsing stored points:", e);
this.points = [{ x: this.coordWidth / 2, y: this.coordHeight / 2 }];
this.neg_points = [];
this.bbox = [{}];
}
} else {
this.points = [{ x: this.coordWidth / 2, y: this.coordHeight / 2 }];
this.neg_points = [];
this.pointsStoreWidget.value = JSON.stringify({ positive: this.points, negative: this.neg_points });
this.bboxStoreWidget.value = JSON.stringify(this.bbox);
}
this.initEditor('pointsEditor', 'pointsEditorHeight', 310);
this.updateData();
this.refreshBackgroundImage();
}
onDataChanged() { this.updateData(); }
onCoordSpaceResized(oldWidth, oldHeight) {
const sx = this.coordWidth / oldWidth, sy = this.coordHeight / oldHeight;
const clampPt = (p) => { p.x = Math.max(0, Math.min(this.coordWidth, p.x * sx)); p.y = Math.max(0, Math.min(this.coordHeight, p.y * sy)); };
for (const p of this.points) clampPt(p);
for (const p of this.neg_points) clampPt(p);
for (const b of this.bbox) {
if (b.startX != null) {
b.startX = Math.max(0, Math.min(this.coordWidth, b.startX * sx));
b.startY = Math.max(0, Math.min(this.coordHeight, b.startY * sy));
b.endX = Math.max(0, Math.min(this.coordWidth, b.endX * sx));
b.endY = Math.max(0, Math.min(this.coordHeight, b.endY * sy));
}
}
this.pointRadius = Math.max(4, Math.log(Math.max(1, Math.min(this.coordWidth, this.coordHeight))) * 4);
}
// ─── Hit Testing ───
findPointAt(x, y, pointsArray) {
// Convert pixel radius to coord space
const r = this.pointRadius / Math.min(this.scaleX, this.scaleY);
for (let i = pointsArray.length - 1; i >= 0; i--) {
const p = pointsArray[i];
const dx = p.x - x, dy = p.y - y;
if (dx * dx + dy * dy <= r * r) return i;
}
return -1;
}
normBbox() {
const b = this.bbox[0];
if (!b || Object.keys(b).length === 0) return null;
return {
x1: Math.min(b.startX, b.endX), y1: Math.min(b.startY, b.endY),
x2: Math.max(b.startX, b.endX), y2: Math.max(b.startY, b.endY),
};
}
hitTestBbox(mx, my) {
const n = this.normBbox();
if (!n) return null;
return rectHitTest(mx, my, n.x1, n.y1, n.x2, n.y2, 10 / Math.min(this.scaleX, this.scaleY));
}
// ─── Mouse Handlers ───
onMouseDown(e) {
const mouse = this.getLocalMouse(e);
const clamped = this.clamp(mouse.x, mouse.y);
if (e.shiftKey && e.button === 2) {
this.neg_points.push({ x: clamped.x, y: clamped.y });
this.dragType = 'negative'; this.dragIndex = this.neg_points.length - 1;
this.selectedIndex = this.dragIndex;
this.dragOffset = { x: 0, y: 0 };
this.render(); this.updateData(); this.startDocumentDrag(e); return;
}
if (e.shiftKey && e.button === 0) {
this.points.push({ x: clamped.x, y: clamped.y });
this.dragType = 'positive'; this.dragIndex = this.points.length - 1;
this.selectedIndex = this.dragIndex;
this.dragOffset = { x: 0, y: 0 };
this.render(); this.updateData(); this.startDocumentDrag(e); return;
}
if (e.button === 2) {
const posIdx = this.findPointAt(mouse.x, mouse.y, this.points);
if (posIdx >= 0) { this.points.splice(posIdx, 1); this.render(); this.updateData(); return; }
const negIdx = this.findPointAt(mouse.x, mouse.y, this.neg_points);
if (negIdx >= 0) { this.neg_points.splice(negIdx, 1); this.render(); this.updateData(); return; }
const bboxHit = this.hitTestBbox(mouse.x, mouse.y);
if (bboxHit) { this.bbox = [{}]; this.render(); this.updateData(); return; }
this.showContextMenu(e);
return;
}
if (e.button !== 0) return;
if (e.ctrlKey) {
this.drawing = true;
this.dragType = 'bbox-draw';
this.bbox[0] = { startX: clamped.x, startY: clamped.y, endX: clamped.x, endY: clamped.y };
this.render(); this.startDocumentDrag(e); return;
}
const bboxHit = this.hitTestBbox(mouse.x, mouse.y);
if (bboxHit) {
const n = this.normBbox();
this.bboxAtDragStart = { startX: n.x1, startY: n.y1, endX: n.x2, endY: n.y2 };
this.dragStart = { x: mouse.x, y: mouse.y };
this.dragType = bboxHit === "move" ? 'bbox-move' : bboxHit;
this.dragIndex = 0;
this.startDocumentDrag(e); return;
}
const posIdx = this.findPointAt(mouse.x, mouse.y, this.points);
if (posIdx >= 0) {
this.dragType = 'positive'; this.dragIndex = posIdx; this.selectedIndex = posIdx;
this.dragOffset = { x: mouse.x - this.points[posIdx].x, y: mouse.y - this.points[posIdx].y };
this.render(); this.startDocumentDrag(e); return;
}
const negIdx = this.findPointAt(mouse.x, mouse.y, this.neg_points);
if (negIdx >= 0) {
this.dragType = 'negative'; this.dragIndex = negIdx; this.selectedIndex = negIdx;
this.dragOffset = { x: mouse.x - this.neg_points[negIdx].x, y: mouse.y - this.neg_points[negIdx].y };
this.render(); this.startDocumentDrag(e); return;
}
}
onMouseMove(e) {
const mouse = this.getLocalMouse(e);
const clamped = this.clamp(mouse.x, mouse.y);
if (!this.drawing && this.dragIndex < 0 && !this.dragType) {
const bboxHit = this.hitTestBbox(mouse.x, mouse.y);
const bboxCursor = cursorForBboxMode(bboxHit);
if (bboxCursor) { this.canvas.style.cursor = bboxCursor; }
else if (this.findPointAt(mouse.x, mouse.y, this.points) >= 0 ||
this.findPointAt(mouse.x, mouse.y, this.neg_points) >= 0) { this.canvas.style.cursor = "move"; }
else { this.canvas.style.cursor = "default"; }
return;
}
if (this.dragType === 'bbox-draw') {
this.bbox[0].endX = clamped.x; this.bbox[0].endY = clamped.y;
this.render(); return;
}
if (this.dragType === 'bbox-move' && this.bboxAtDragStart) {
const dx = mouse.x - this.dragStart.x, dy = mouse.y - this.dragStart.y;
const bs = this.bboxAtDragStart;
const bw = bs.endX - bs.startX, bh = bs.endY - bs.startY;
const nx = Math.max(0, Math.min(this.coordWidth - bw, bs.startX + dx));
const ny = Math.max(0, Math.min(this.coordHeight - bh, bs.startY + dy));
this.bbox[0] = { startX: nx, startY: ny, endX: nx + bw, endY: ny + bh };
this.render(); return;
}
if (this.dragType?.startsWith('resize-') && this.bboxAtDragStart) {
const bs = this.bboxAtDragStart;
const offX = this.dragStart.x - (this.dragType === 'resize-tl' || this.dragType === 'resize-bl' ? bs.startX : bs.endX);
const offY = this.dragStart.y - (this.dragType === 'resize-tl' || this.dragType === 'resize-tr' ? bs.startY : bs.endY);
const cx = Math.max(0, Math.min(this.coordWidth, mouse.x - offX));
const cy = Math.max(0, Math.min(this.coordHeight, mouse.y - offY));
if (this.dragType === 'resize-tl') this.bbox[0] = { startX: bs.endX, startY: bs.endY, endX: cx, endY: cy };
else if (this.dragType === 'resize-tr') this.bbox[0] = { startX: bs.startX, startY: bs.endY, endX: cx, endY: cy };
else if (this.dragType === 'resize-bl') this.bbox[0] = { startX: bs.endX, startY: bs.startY, endX: cx, endY: cy };
else this.bbox[0] = { startX: bs.startX, startY: bs.startY, endX: cx, endY: cy };
this.render(); return;
}
if (this.dragType === 'positive' && this.dragIndex >= 0 && this.dragIndex < this.points.length) {
const ox = this.dragOffset?.x || 0, oy = this.dragOffset?.y || 0;
const target = this.clamp(mouse.x - ox, mouse.y - oy);
this.points[this.dragIndex] = { x: target.x, y: target.y };
this.render();
} else if (this.dragType === 'negative' && this.dragIndex >= 0 && this.dragIndex < this.neg_points.length) {
const ox = this.dragOffset?.x || 0, oy = this.dragOffset?.y || 0;
const target = this.clamp(mouse.x - ox, mouse.y - oy);
this.neg_points[this.dragIndex] = { x: target.x, y: target.y };
this.render();
}
}
onMouseUp() {
if (this.drawing || this.dragType) {
this.drawing = false;
this.bboxAtDragStart = null;
this.dragStart = null;
this.endDrag();
this.updateData();
}
}
// ─── Rendering ───
_render() {
const ctx = this.ctx;
this.beginRender();
// Bounding box
const nb = this.normBbox();
if (nb) {
const tl = this.toCanvas(nb.x1, nb.y1), br = this.toCanvas(nb.x2, nb.y2);
const bw = br.x - tl.x, bh = br.y - tl.y;
ctx.fillStyle = 'rgba(70, 130, 180, 0.3)';
ctx.fillRect(tl.x, tl.y, bw, bh);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; ctx.lineWidth = 4;
ctx.strokeRect(tl.x, tl.y, bw, bh);
ctx.strokeStyle = 'steelblue'; ctx.lineWidth = 2;
ctx.strokeRect(tl.x, tl.y, bw, bh);
// Center cross
const cx = (tl.x + br.x) / 2, cy = (tl.y + br.y) / 2;
ctx.strokeStyle = 'steelblue'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(cx - 5, cy); ctx.lineTo(cx + 5, cy);
ctx.moveTo(cx, cy - 5); ctx.lineTo(cx, cy + 5); ctx.stroke();
// Corner handles
const hs = 5;
ctx.fillStyle = 'steelblue';
for (const [hx, hy] of [[tl.x, tl.y], [br.x, tl.y], [tl.x, br.y], [br.x, br.y]]) {
ctx.fillRect(hx - hs, hy - hs, hs * 2, hs * 2);
}
}
// Points
this._badgePositions = [];
this._drawPoints(ctx, this.points, '#139613', '#07f907', 'positive');
// Negative points
this._drawPoints(ctx, this.neg_points, '#891616', '#f91111', 'negative');
this.endRender();
}
_drawPoints(ctx, points, color, selectedColor, type) {
const r = this.pointRadius;
for (let i = 0; i < points.length; i++) {
const p = points[i];
const cp = this.toCanvas(p.x, p.y);
const isSelected = (this.dragType === type && this.dragIndex === i);
// Outer circle
ctx.beginPath(); ctx.arc(cp.x, cp.y, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(100, 100, 100, 0.25)'; ctx.fill();
ctx.strokeStyle = isSelected ? selectedColor : color; ctx.lineWidth = 2; ctx.stroke();
// Center dot
ctx.beginPath(); ctx.arc(cp.x, cp.y, 1.5, 0, Math.PI * 2);
ctx.fillStyle = 'red'; ctx.fill();
// Index badge
const label = i.toString();
const badgeR = label.length > 1 ? 8 : 7;
const off = r * 0.7;
const candidates = [
{ x: cp.x + off, y: cp.y - off },
{ x: cp.x - off, y: cp.y - off },
{ x: cp.x + off, y: cp.y + off },
{ x: cp.x - off, y: cp.y + off },
];
const valid = candidates.filter(c => c.x - badgeR >= 0 && c.x + badgeR <= this.width && c.y - badgeR >= 0 && c.y + badgeR <= this.height);
const pool = valid.length > 0 ? valid : candidates;
let badge = pool[0];
const minDist = badgeR * 2.2;
for (const c of pool) {
const overlaps = this._badgePositions.some(b => Math.abs(c.x - b.x) < minDist && Math.abs(c.y - b.y) < minDist);
if (!overlaps) { badge = c; break; }
}
this._badgePositions.push(badge);
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.beginPath(); ctx.arc(badge.x, badge.y, badgeR, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? selectedColor : color; ctx.fill();
ctx.fillStyle = '#fff';
ctx.fillText(label, badge.x, badge.y + 0.5);
ctx.textAlign = 'start'; ctx.textBaseline = 'alphabetic';
}
}
// ─── Data ───
updateData() {
const combinedPoints = { positive: this.points || [], negative: this.neg_points || [] };
this.pointsStoreWidget.value = JSON.stringify(combinedPoints);
this.pos_coordWidget.value = JSON.stringify(this.points || []);
this.neg_coordWidget.value = JSON.stringify(this.neg_points || []);
if (this.bbox.length !== 0) {
let bboxString = JSON.stringify(this.bbox);
this.bboxStoreWidget.value = bboxString;
this.bboxWidget.value = bboxString;
}
}
}
-720
View File
@@ -1,720 +0,0 @@
import { chainCallback } from '../utility.js';
import { BaseEditorCanvas, createEditorStylesheet } from './editor_base.js';
import { Interpolation } from './interpolation.js';
const { app } = window.comfyAPI.app;
createEditorStylesheet('kj-splineeditor-stylesheet', 'spline-editor');
// ─── Hidden SVG for path sampling (singleton) ───
let _sharedSampler = null;
let _samplerRefCount = 0;
class PathSampler {
constructor() {
const ns = 'http://www.w3.org/2000/svg';
this._svg = document.createElementNS(ns, 'svg');
this._svg.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;';
this._svg.setAttribute('aria-hidden', 'true');
document.body.appendChild(this._svg);
this._path = document.createElementNS(ns, 'path');
this._svg.appendChild(this._path);
}
static acquire() {
if (_sharedSampler) { _samplerRefCount++; return _sharedSampler; }
_sharedSampler = new PathSampler();
_samplerRefCount = 1;
return _sharedSampler;
}
setPath(d) { this._path.setAttribute('d', d); }
getTotalLength() { return this._path.getTotalLength(); }
getPointAtLength(len) { return this._path.getPointAtLength(len); }
release() {
_samplerRefCount--;
if (_samplerRefCount <= 0 && this._svg && this._svg.parentNode) {
this._svg.parentNode.removeChild(this._svg);
_sharedSampler = null;
}
}
findPointAtX(targetX, pathLength) {
let low = 0, high = pathLength, bestPoint = this.getPointAtLength(0);
const epsilon = 0.5;
while (high - low > epsilon) {
const mid = (low + high) / 2, point = this.getPointAtLength(mid);
if (Math.abs(point.x - targetX) < Math.abs(bestPoint.x - targetX)) bestPoint = point;
if (Math.abs(point.x - targetX) < 0.5) return point;
if (point.x < targetX) low = mid; else high = mid;
}
return bestPoint;
}
}
// ─── Extension Registration ───
app.registerExtension({
name: 'KJNodes.SplineEditor',
init() {
app.ui.settings.addSetting({
id: "KJNodes.splineEditor.showControlLines",
name: "Show control lines",
category: ["KJNodes", "Editors", "Show control lines"],
tooltip: "Display straight lines between control points",
type: "boolean",
defaultValue: false,
});
app.ui.settings.addSetting({
id: "KJNodes.splineEditor.showSamplePoints",
name: "Show sample points",
category: ["KJNodes", "Editors", "Show sample points"],
tooltip: "Display the sampled output points as red dots",
type: "boolean",
defaultValue: false,
});
app.ui.settings.addSetting({
id: "KJNodes.splineEditor.showArrows",
name: "Show direction arrows",
category: ["KJNodes", "Editors", "Show direction arrows"],
tooltip: "Display chevron arrows on control points showing curve direction",
type: "boolean",
defaultValue: true,
});
app.ui.settings.addSetting({
id: "KJNodes.editors.embedBackgroundImage",
name: "Embed background image in workflow",
category: ["KJNodes", "Editors", "Embed background image"],
tooltip: "When enabled, editor background images are embedded as base64 in the workflow file (portable, larger files). When disabled, images are stored as temp files on the server (small workflows, survives refresh, but not portable).",
type: "boolean",
defaultValue: false,
});
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name === 'SplineEditor') {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
BaseEditorCanvas.setupNode(this, nodeData, {
editorClass: SplineEditor,
editorKey: 'splineEditor',
heightKey: 'splineEditorHeight',
className: 'spline-editor',
menuClassName: 'spline-editor-context-menu',
menuItems: {
"Display control lines": { toggle: (ed) => ed.drawHandles, action: (ed) => { ed.drawHandles = !ed.drawHandles; app.ui.settings.setSettingValue("KJNodes.splineEditor.showControlLines", ed.drawHandles); ed.render(); } },
"Display sample points": { toggle: (ed) => ed.drawSamplePoints, action: (ed) => { ed.drawSamplePoints = !ed.drawSamplePoints; app.ui.settings.setSettingValue("KJNodes.splineEditor.showSamplePoints", ed.drawSamplePoints); ed.updatePath(); } },
"Display arrows": { toggle: (ed) => ed.showChevrons, action: (ed) => { ed.showChevrons = !ed.showChevrons; app.ui.settings.setSettingValue("KJNodes.splineEditor.showArrows", ed.showChevrons); ed.render(); } },
"Background image": { action: (ed) => ed.openImageFilePicker() },
"Invert point order": { action: (ed) => { ed.splines[ed.activeSplineIndex].points.reverse(); ed.render(); ed.updatePath(); } },
"Clear Image": { action: (ed) => { ed.clearBackgroundImage(); ed.drawRuler = true; } },
"Add new spline": { action: (ed) => { const idx = ed.splines.length; ed.splines.push({ points: [{ x: 0, y: ed.coordHeight }, { x: ed.coordWidth / 2, y: ed.coordHeight / 2 }, { x: ed.coordWidth, y: 0 }], color: ed.getSplineColor(idx), name: `Spline ${idx + 1}` }); ed.activeSplineIndex = idx; ed.render(); ed.updatePath(); } },
"Add new single point": { action: (ed) => { const idx = ed.splines.length; ed.splines.push({ points: [{ x: ed.lastContextMenuPos.x, y: ed.lastContextMenuPos.y }], color: ed.getSplineColor(idx), name: `Spline ${idx + 1}`, isSinglePoint: true }); ed.activeSplineIndex = idx; ed.render(); ed.updatePath(); } },
"Delete current spline": { action: (ed) => { if (ed.splines.length > 1) { ed.splines.splice(ed.activeSplineIndex, 1); ed.activeSplineIndex = Math.min(ed.activeSplineIndex, ed.splines.length - 1); ed.render(); ed.updatePath(); } } },
"Next spline": { action: (ed) => { ed.activeSplineIndex = (ed.activeSplineIndex + 1) % ed.splines.length; ed.render(); ed.updatePath(); } },
},
hiddenWidgets: ["coordinates"],
initialSize: [550, 1000],
extraProperties: [
["points", this.constructor.type, "string"],
],
});
});
}
}
});
// ─── SplineEditor class ───
class SplineEditor extends BaseEditorCanvas {
constructor(context, reset = false) {
super(context, reset);
this.initEditorPreamble('splineEditor', 'spline-editor');
this.sampler = PathSampler.acquire();
this.drawSamplePoints = app.ui.settings.getSettingValue("KJNodes.splineEditor.showSamplePoints") ?? false;
this.drawHandles = app.ui.settings.getSettingValue("KJNodes.splineEditor.showControlLines") ?? false;
this.showChevrons = app.ui.settings.getSettingValue("KJNodes.splineEditor.showArrows") ?? true;
this.drawRuler = true;
this.coordWidget = this.findWidget("coordinates");
this.interpolationWidget = this.findWidget("interpolation");
this.pointsWidget = this.findWidget("points_to_sample");
this.pointsStoreWidget = this.findWidget("points_store");
this.tensionWidget = this.findWidget("tension");
this.samplingMethodWidget = this.findWidget("sampling_method");
this.widthWidget = this.findWidget("mask_width");
this.heightWidget = this.findWidget("mask_height");
this.interpolationWidget.callback = () => this.updatePath(true);
this.samplingMethodWidget.callback = () => {
if (this.samplingMethod === "controlpoints") this.drawSamplePoints = true;
else this.drawSamplePoints = false;
if (this.samplingMethod === "path" || this.samplingMethod === "speed") this.showChevrons = true;
this.updatePath(true);
};
this.tensionWidget.callback = () => this.updatePath(true);
this.pointsWidget.callback = () => this.updatePath(true);
this.setupSizeCallbacks();
this.pointsStoreWidget.callback = () => {
this.parseSplineData();
this.render(); this.updatePath(true);
};
this.initDisplaySize();
this.splines = [];
this.activeSplineIndex = 0;
this.hoverSplineIndex = -1;
this.hoverIndex = -1;
this.sampledCoords = null;
this.lastContextMenuPos = { x: this.coordWidth / 2, y: this.coordHeight / 2 };
if (!reset && this.pointsStoreWidget.value !== "") {
this.parseSplineData();
} else {
this.initializeDefaultSplines();
this.pointsStoreWidget.value = JSON.stringify(this.splines);
}
this._onKeyUp = (e) => {
if (e.key === 'Control' && this.subdividePreview) { this.subdividePreview = null; this.render(); }
};
document.addEventListener('keyup', this._onKeyUp);
this.initEditor('splineEditor', 'splineEditorHeight', 460);
this.updatePath();
this.refreshBackgroundImage();
}
// ─── Widget value getters ───
get interpolation() { return this.interpolationWidget.value; }
get tension() { return this.tensionWidget.value; }
get points_to_sample() { return this.pointsWidget.value; }
get samplingMethod() { return this.samplingMethodWidget.value; }
// ─── Base class hooks ───
destroy() {
super.destroy();
if (this.sampler) this.sampler.release();
}
onDataChanged() { this.updatePath(true); }
onImageResize() { this.drawRuler = false; }
onCoordSpaceResized(oldWidth, oldHeight) {
const sx = this.coordWidth / oldWidth, sy = this.coordHeight / oldHeight;
const cw = this.coordWidth, ch = this.coordHeight;
const clamp = (v, max) => Math.max(0, Math.min(max, v));
for (const spline of this.splines) {
for (const p of spline.points) {
p.x = clamp(p.x * sx, cw); p.y = clamp(p.y * sy, ch);
if (p.h1x !== undefined) { p.h1x = clamp(p.h1x * sx, cw); p.h1y = clamp(p.h1y * sy, ch); }
if (p.h2x !== undefined) { p.h2x = clamp(p.h2x * sx, cw); p.h2y = clamp(p.h2y * sy, ch); }
}
}
}
// ─── Spline Helpers ───
parseSplineData() {
try {
const parsed = JSON.parse(this.pointsStoreWidget.value);
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].hasOwnProperty('points')) {
this.splines = parsed;
} else {
this.splines = [{ points: parsed, color: "#1f77b4", name: "Spline 1" }];
}
} catch (e) {
console.error("Error parsing spline data:", e);
this.initializeDefaultSplines();
}
if (this.activeSplineIndex >= this.splines.length) {
this.activeSplineIndex = Math.max(0, this.splines.length - 1);
}
}
initializeDefaultSplines() {
this.splines = [{
points: [
{ x: 0, y: this.coordHeight },
{ x: this.coordWidth / 5 * 2, y: 50 + Math.random() * (this.coordHeight - 100) },
{ x: this.coordWidth, y: 0 }
],
color: this.getSplineColor(0),
name: "Spline 1"
}];
}
getSplineColor(index) {
const colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"];
return colors[index % colors.length];
}
// ─── Hit Testing ───
findPointAt(x, y) {
const activePoints = this.splines[this.activeSplineIndex]?.points;
if (!activePoints) return -1;
const r = 12 / Math.min(this.scaleX, this.scaleY);
for (let i = activePoints.length - 1; i >= 0; i--) {
const dx = activePoints[i].x - x, dy = activePoints[i].y - y;
if (dx * dx + dy * dy <= r * r) return i;
}
return -1;
}
findHandleAt(x, y) {
if (this.interpolation !== 'bezier') return null;
const activePoints = this.splines[this.activeSplineIndex]?.points;
if (!activePoints) return null;
const r = 8 / Math.min(this.scaleX, this.scaleY);
for (let i = activePoints.length - 1; i >= 0; i--) {
const p = activePoints[i];
if (i > 0 && p.h1x !== undefined) { const dx = p.h1x - x, dy = p.h1y - y; if (dx * dx + dy * dy <= r * r) return { index: i, type: 'h1' }; }
if (i < activePoints.length - 1 && p.h2x !== undefined) { const dx = p.h2x - x, dy = p.h2y - y; if (dx * dx + dy * dy <= r * r) return { index: i, type: 'h2' }; }
}
return null;
}
findSplineAt(x, y) {
const threshold = 15 / Math.min(this.scaleX, this.scaleY);
let bestDist = Infinity, bestIdx = -1;
for (let si = 0; si < this.splines.length; si++) {
const spline = this.splines[si];
if (spline.isSinglePoint || (spline.points && spline.points.length === 1)) {
const p = spline.points[0], d = Math.sqrt((p.x - x) ** 2 + (p.y - y) ** 2);
if (d < threshold && d < bestDist) { bestDist = d; bestIdx = si; }
continue;
}
const pathD = Interpolation.buildPathD(spline.points, this.interpolation, this.tension);
this.sampler.setPath(pathD);
const len = this.sampler.getTotalLength();
const steps = Math.min(200, Math.max(50, Math.round(len / 3)));
for (let i = 0; i <= steps; i++) {
const pt = this.sampler.getPointAtLength(len * i / steps);
const d = Math.sqrt((pt.x - x) ** 2 + (pt.y - y) ** 2);
if (d < threshold && d < bestDist) { bestDist = d; bestIdx = si; }
}
}
return bestIdx;
}
findClosestPoints(points, clickedPoint) {
if (points.length < 2) return null;
let distances = points.map((point, idx) => {
const dx = clickedPoint.x - point.x, dy = clickedPoint.y - point.y;
return { index: idx, distance: Math.sqrt(dx * dx + dy * dy) };
});
distances.sort((a, b) => a.distance - b.distance);
let i1 = distances[0].index, i2 = distances[1].index;
if (i1 > i2) [i1, i2] = [i2, i1];
return { point1Index: i1, point2Index: i2 };
}
// ─── Mouse Handlers ───
onMouseDown(e) {
const mouse = this.getLocalMouse(e);
const clamped = this.clamp(mouse.x, mouse.y);
const activeSpline = this.splines[this.activeSplineIndex];
const activePoints = activeSpline?.points;
if (e.shiftKey && e.button === 0 && activePoints) {
activePoints.push({ x: clamped.x, y: clamped.y });
this.render(); this.updatePath(); return;
}
if (e.ctrlKey && e.button === 0 && activePoints && activePoints.length >= 2) {
const closest = this.findClosestPoints(activePoints, clamped);
if (closest) {
const p1 = activePoints[closest.point1Index], p2 = activePoints[closest.point2Index];
activePoints.splice(closest.point2Index, 0, { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 });
this.render(); this.updatePath();
}
return;
}
if (e.button === 2) {
const ptIdx = this.findPointAt(mouse.x, mouse.y);
if (activePoints && ptIdx > 0 && ptIdx < activePoints.length - 1) {
activePoints.splice(ptIdx, 1); this.render(); this.updatePath(); return;
}
this.lastContextMenuPos = { x: clamped.x, y: clamped.y };
this.showContextMenu(e);
return;
}
if (e.button !== 0) return;
// Bezier handle drag
const handle = this.findHandleAt(mouse.x, mouse.y);
if (handle && activePoints) {
this.dragIndex = handle.index; this.dragType = handle.type;
const p = activePoints[handle.index];
const hx = handle.type === 'h1' ? p.h1x : p.h2x, hy = handle.type === 'h1' ? p.h1y : p.h2y;
this.dragOffset = { x: mouse.x - hx, y: mouse.y - hy };
this.render(); this.startDocumentDrag(e); return;
}
// Point drag
const ptIdx = this.findPointAt(mouse.x, mouse.y);
if (ptIdx >= 0) {
this.dragIndex = ptIdx; this.dragType = 'point'; this.hoverIndex = ptIdx;
const p = activePoints[ptIdx];
this.dragOffset = { x: mouse.x - p.x, y: mouse.y - p.y };
this.render(); this.startDocumentDrag(e); return;
}
// Spline selection
const splineIdx = this.findSplineAt(mouse.x, mouse.y);
if (splineIdx >= 0 && splineIdx !== this.activeSplineIndex) {
this.activeSplineIndex = splineIdx; this.render(); this.updatePath(); return;
}
}
onMouseMove(e) {
const mouse = this.getLocalMouse(e);
const clamped = this.clamp(mouse.x, mouse.y);
// Bezier handle drag
if ((this.dragType === 'h1' || this.dragType === 'h2') && this.dragIndex >= 0) {
const activePoints = this.splines[this.activeSplineIndex]?.points;
if (activePoints && this.dragIndex < activePoints.length) {
const p = activePoints[this.dragIndex];
const clampH = (hx, hy) => ({ x: Math.max(0, Math.min(this.coordWidth, hx)), y: Math.max(0, Math.min(this.coordHeight, hy)) });
const ox = this.dragOffset?.x || 0, oy = this.dragOffset?.y || 0;
const prevH1x = p.h1x, prevH1y = p.h1y, prevH2x = p.h2x, prevH2y = p.h2y;
if (this.dragType === 'h1') {
const c = clampH(mouse.x - ox, mouse.y - oy);
p.h1x = c.x; p.h1y = c.y;
if (!e.altKey) { const dx = p.x - p.h1x, dy = p.y - p.h1y; const mc = clampH(p.x + dx, p.y + dy); p.h2x = mc.x; p.h2y = mc.y; }
} else {
const c = clampH(mouse.x - ox, mouse.y - oy);
p.h2x = c.x; p.h2y = c.y;
if (!e.altKey) { const dx = p.x - p.h2x, dy = p.y - p.h2y; const mc = clampH(p.x + dx, p.y + dy); p.h1x = mc.x; p.h1y = mc.y; }
}
if (p.h1x === prevH1x && p.h1y === prevH1y && p.h2x === prevH2x && p.h2y === prevH2y) return;
this.render();
}
return;
}
// Point drag
if (this.dragType === 'point' && this.dragIndex >= 0) {
const activePoints = this.splines[this.activeSplineIndex]?.points;
if (activePoints && this.dragIndex < activePoints.length) {
const p = activePoints[this.dragIndex];
const ox = this.dragOffset?.x || 0, oy = this.dragOffset?.y || 0;
const target = this.clamp(mouse.x - ox, mouse.y - oy);
if (target.x === p.x && target.y === p.y) return;
const dx = target.x - p.x, dy = target.y - p.y;
if (this.interpolation === 'bezier' && p.h1x !== undefined) {
p.h1x += dx; p.h1y += dy; p.h2x += dx; p.h2y += dy;
}
p.x = target.x; p.y = target.y;
this.render();
}
return;
}
// Hover detection
if (!this.dragType) {
const ptIdx = this.findPointAt(mouse.x, mouse.y);
const activePoints = this.splines[this.activeSplineIndex]?.points;
// Ctrl preview
if (e.ctrlKey && activePoints && activePoints.length >= 2) {
const closest = this.findClosestPoints(activePoints, clamped);
if (closest) {
const p1 = activePoints[closest.point1Index], p2 = activePoints[closest.point2Index];
this.subdividePreview = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
this.canvas.style.cursor = 'copy'; this.render(); return;
}
} else if (this.subdividePreview) { this.subdividePreview = null; this.render(); }
// Handle hover
const handleHit = this.findHandleAt(mouse.x, mouse.y);
if (handleHit) { this.canvas.style.cursor = 'crosshair'; if (ptIdx !== this.hoverIndex) { this.hoverIndex = ptIdx; this.render(); } return; }
if (ptIdx !== this.hoverIndex) {
this.hoverIndex = ptIdx;
this.canvas.style.cursor = ptIdx >= 0 ? 'move' : 'default';
this.render();
}
// Spline hover
if (ptIdx < 0 && this.splines.length > 1) {
const splineIdx = this.findSplineAt(mouse.x, mouse.y);
const newSplineHover = splineIdx >= 0 && splineIdx !== this.activeSplineIndex ? splineIdx : -1;
if (newSplineHover !== this.hoverSplineIndex) {
this.hoverSplineIndex = newSplineHover;
this.canvas.style.cursor = newSplineHover >= 0 ? 'pointer' : 'default';
this.render();
}
} else if (this.hoverSplineIndex >= 0) { this.hoverSplineIndex = -1; this.render(); }
}
}
onMouseUp() {
if (this.dragType) {
this.endDrag();
this.updatePath();
}
}
// ─── Path Building & Sampling ───
samplePoints(splineIndex, numSamples, samplingMethod) {
const spline = this.splines[splineIndex];
if (!spline || !spline.points || spline.points.length < 1) return [];
if (spline.isSinglePoint || spline.points.length === 1) {
const point = spline.points[0];
return Array(numSamples).fill().map(() => ({ x: point.x, y: point.y }));
}
if (numSamples < 2) {
const p = spline.points[0];
return [{ x: p.x, y: p.y }];
}
const pathD = Interpolation.buildPathD(spline.points, this.interpolation, this.tension);
if (!pathD) return [];
this.sampler.setPath(pathD);
const pathLength = this.sampler.getTotalLength();
const points = [];
if (samplingMethod === "speed") {
if (pathLength < 0.001) return [{ x: spline.points[0].x, y: spline.points[0].y }];
const controlPoints = spline.points;
// Pair each control point with its path position, then sort together
const cpWithPos = controlPoints.map(cp => {
let bestDist = Infinity, bestPos = 0;
for (let pos = 0; pos <= pathLength; pos += pathLength / 100) {
const pt = this.sampler.getPointAtLength(pos);
const dist = Math.sqrt((pt.x - cp.x) ** 2 + (pt.y - cp.y) ** 2);
if (dist < bestDist) { bestDist = dist; bestPos = pos; }
}
return { cp, pos: bestPos };
});
cpWithPos.sort((a, b) => a.pos - b.pos);
const pathPositions = cpWithPos.map(c => c.pos);
const densities = [];
let totalWeight = 0;
for (let i = 0; i < cpWithPos.length - 1; i++) {
const segLength = pathPositions[i + 1] - pathPositions[i];
const d = 1 / Math.max(segLength, 0.0001);
densities.push(d);
totalWeight += d;
}
const cumulativeWeights = [];
let cum = 0;
for (let i = 0; i < densities.length; i++) {
cum += densities[i] / totalWeight;
cumulativeWeights.push(cum);
}
const mapToPath = (t) => {
if (t === 0) return 0; if (t === 1) return pathLength;
let segIdx = cumulativeWeights.length - 1;
for (let i = 0; i < cumulativeWeights.length; i++) { if (t <= cumulativeWeights[i]) { segIdx = i; break; } }
const segStart = segIdx > 0 ? cumulativeWeights[segIdx - 1] : 0;
const segT = (t - segStart) / (cumulativeWeights[segIdx] - segStart);
return pathPositions[segIdx] + segT * (pathPositions[segIdx + 1] - pathPositions[segIdx]);
};
for (let i = 0; i < numSamples; i++) {
const pt = this.sampler.getPointAtLength(mapToPath(i / (numSamples - 1)));
points.push({ x: pt.x, y: pt.y });
}
return points;
}
for (let i = 0; i < numSamples; i++) {
let point;
if (samplingMethod === "time") {
point = this.sampler.findPointAtX((this.coordWidth / (numSamples - 1)) * i, pathLength);
} else {
point = this.sampler.getPointAtLength((pathLength / (numSamples - 1)) * i);
}
points.push({ x: point.x, y: point.y });
}
if (points.length > 0 && spline.points.length > 1) {
points[points.length - 1].y = spline.points[spline.points.length - 1].y;
}
return points;
}
updatePath(allDirty = false) {
if (!this.splines || this.splines.length === 0) return;
// Rebuild cache when spline count changes or full refresh requested
if (!this._sampledCache || this._sampledCache.length !== this.splines.length || allDirty) {
this._sampledCache = new Array(this.splines.length).fill(null);
}
const method = this.samplingMethod;
const useControlPoints = method === "controlpoints";
for (let i = 0; i < this.splines.length; i++) {
if (useControlPoints) {
this._sampledCache[i] = this.splines[i].points;
} else if (i === this.activeSplineIndex || !this._sampledCache[i]) {
this._sampledCache[i] = this.samplePoints(i, this.points_to_sample, method);
}
}
this.sampledCoords = this._sampledCache[this.activeSplineIndex] || [];
this.pointsStoreWidget.value = JSON.stringify(this.splines);
if (this.coordWidget) this.coordWidget.value = JSON.stringify(this._sampledCache);
this.render();
}
// ─── Rendering ───
_render() {
const ctx = this.ctx;
const h = this.coordHeight;
this.beginRender();
// Ruler lines
if (this.drawRuler && !this.bgImage) {
ctx.strokeStyle = 'rgba(128,128,128,0.3)'; ctx.lineWidth = 1;
for (let y = 64; y < h; y += 64) {
const cy = y * this.scaleY;
ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(this.width, cy); ctx.stroke();
}
}
const tc = (x, y) => this.toCanvas(x, y);
// Draw all splines — use scale transform for curves, toCanvas for discrete elements
for (let si = 0; si < this.splines.length; si++) {
const spline = this.splines[si];
const isActive = si === this.activeSplineIndex, isHover = si === this.hoverSplineIndex;
if (spline.isSinglePoint || (spline.points && spline.points.length === 1)) {
const cp = tc(spline.points[0].x, spline.points[0].y), sz = isActive ? 8 : 6;
ctx.fillStyle = spline.color; ctx.fillRect(cp.x - sz, cp.y - sz, sz * 2, sz * 2);
ctx.strokeStyle = 'black'; ctx.lineWidth = 2; ctx.strokeRect(cp.x - sz, cp.y - sz, sz * 2, sz * 2);
continue;
}
if (spline.points.length < 2) continue;
// Build path once, stroke twice (outline + color) to avoid recomputing interpolation
const pathD = Interpolation.buildPathD(spline.points, this.interpolation, this.tension);
const curvePath = new Path2D(pathD);
ctx.save(); ctx.scale(this.scaleX, this.scaleY);
ctx.lineWidth = (isActive ? 5 : isHover ? 4 : 3.5) / Math.min(this.scaleX, this.scaleY); ctx.strokeStyle = 'black';
ctx.stroke(curvePath);
ctx.lineWidth = (isActive ? 3 : isHover ? 2 : 1.5) / Math.min(this.scaleX, this.scaleY); ctx.strokeStyle = spline.color;
ctx.stroke(curvePath);
ctx.restore();
}
const activeSpline = this.splines[this.activeSplineIndex];
// Handle lines
if (this.drawHandles) {
const activePoints = this.splines[this.activeSplineIndex]?.points;
if (activePoints && activePoints.length >= 2) {
ctx.strokeStyle = '#ff7f0e'; ctx.lineWidth = 1; ctx.beginPath();
const c0 = tc(activePoints[0].x, activePoints[0].y);
ctx.moveTo(c0.x, c0.y);
for (let i = 1; i < activePoints.length; i++) { const ci = tc(activePoints[i].x, activePoints[i].y); ctx.lineTo(ci.x, ci.y); }
ctx.stroke();
}
}
// Control points for active spline
if (activeSpline && activeSpline.points && !activeSpline.isSinglePoint) {
const dotRadius = 12;
for (let i = 0; i < activeSpline.points.length; i++) {
const p = activeSpline.points[i];
const cp = tc(p.x, p.y);
const isHovered = this.hoverIndex === i;
ctx.fillStyle = 'rgba(100, 100, 100, 0.3)';
ctx.strokeStyle = isHovered ? '#ff7f0e' : '#1f77b4'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(cp.x, cp.y, dotRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
// Chevron
const isEndpoint = i === 0 || i === activeSpline.points.length - 1;
const showChevron = this.showChevrons && (
(this.samplingMethod === 'path' || this.samplingMethod === 'speed')
|| (this.samplingMethod === 'time' && isEndpoint));
if (showChevron && activeSpline.points.length > 1) {
let angle = 0;
if (i > 0 && i < activeSpline.points.length - 1) { const prev = activeSpline.points[i-1], next = activeSpline.points[i+1]; angle = Math.atan2((next.y - prev.y) * this.scaleY, (next.x - prev.x) * this.scaleX); }
else if (i === 0) { const next = activeSpline.points[1]; angle = Math.atan2((next.y - p.y) * this.scaleY, (next.x - p.x) * this.scaleX); }
else { const prev = activeSpline.points[i-1]; angle = Math.atan2((p.y - prev.y) * this.scaleY, (p.x - prev.x) * this.scaleX); }
ctx.save();
ctx.translate(cp.x, cp.y);
ctx.rotate(angle);
ctx.strokeStyle = isHovered ? '#fff' : 'rgba(255,255,255,0.7)';
ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(-dotRadius * 0.2, -dotRadius * 0.35);
ctx.lineTo(dotRadius * 0.3, 0);
ctx.lineTo(-dotRadius * 0.2, dotRadius * 0.35);
ctx.stroke();
ctx.lineCap = 'butt'; ctx.lineJoin = 'miter';
ctx.restore();
}
if (isHovered) {
ctx.font = '11px monospace';
const ax = String(Math.round(p.x)), ay = String(Math.round(p.y));
const nx = (p.x / this.coordWidth).toFixed(3), ny = (p.y / this.coordHeight).toFixed(3);
const ml = Math.max(ax.length, nx.length), mr = Math.max(ay.length, ny.length);
const pre = `${i}: `, L1 = `[${ax.padStart(ml)}, ${ay.padStart(mr)}]`, L2 = `[${nx.padStart(ml)}, ${ny.padStart(mr)}]`;
const preW = ctx.measureText(pre).width, totW = preW + Math.max(ctx.measureText(L1).width, ctx.measureText(L2).width);
const pad = dotRadius + 5, lineH = 13;
let lx = cp.x + pad, ly = cp.y - lineH;
if (lx + totW > this.width) lx = cp.x - pad - totW;
if (ly - lineH < 0) ly = cp.y + pad;
for (const [style, fill] of [['#000', 3], ['#fff', 0]]) {
ctx.strokeStyle = style; ctx.lineWidth = fill; ctx.fillStyle = style;
const draw = fill ? 'strokeText' : 'fillText';
ctx[draw](pre + L1, lx, ly);
ctx[draw](L2, lx + preW, ly + lineH);
}
}
}
}
// Bezier handles
if (this.interpolation === 'bezier' && activeSpline && activeSpline.points && !activeSpline.isSinglePoint) {
const pts = activeSpline.points;
Interpolation.ensureBezierHandles(pts);
const hr = 5;
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
const cp = tc(p.x, p.y);
if (i > 0 && p.h1x !== undefined) {
const h1 = tc(p.h1x, p.h1y);
ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(cp.x, cp.y); ctx.lineTo(h1.x, h1.y); ctx.stroke();
ctx.fillStyle = (this.dragType === 'h1' && this.dragIndex === i) ? '#fff' : 'rgba(255,255,255,0.8)';
ctx.strokeStyle = '#1f77b4'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(h1.x, h1.y, hr, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
if (i < pts.length - 1 && p.h2x !== undefined) {
const h2 = tc(p.h2x, p.h2y);
ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(cp.x, cp.y); ctx.lineTo(h2.x, h2.y); ctx.stroke();
ctx.fillStyle = (this.dragType === 'h2' && this.dragIndex === i) ? '#fff' : 'rgba(255,255,255,0.8)';
ctx.strokeStyle = '#ff7f0e'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(h2.x, h2.y, hr, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
}
}
// Sample points
if (this.drawSamplePoints && this.sampledCoords) {
ctx.fillStyle = 'red'; ctx.strokeStyle = 'black'; ctx.lineWidth = 1;
for (const pt of this.sampledCoords) { const cp = tc(pt.x, pt.y); ctx.beginPath(); ctx.arc(cp.x, cp.y, 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); }
}
// Subdivide preview
if (this.subdividePreview) {
const sp = tc(this.subdividePreview.x, this.subdividePreview.y);
ctx.beginPath(); ctx.arc(sp.x, sp.y, 10, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(0,255,0,0.3)'; ctx.lineWidth = 4; ctx.stroke();
ctx.beginPath(); ctx.arc(sp.x, sp.y, 8, 0, Math.PI * 2);
ctx.strokeStyle = '#0f0'; ctx.lineWidth = 2; ctx.stroke();
}
this.endRender();
}
}
+85 -30
View File
@@ -1,40 +1,95 @@
import { chainCallback } from './utility.js';
const { app } = window.comfyAPI.app;
const { api } = window.comfyAPI.api;
import { app } from '../../../scripts/app.js'
let execId = null;
api.addEventListener("executing", e => { execId = e.detail ?? null; });
//from melmass
export function makeUUID() {
let dt = new Date().getTime()
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = ((dt + Math.random() * 16) % 16) | 0
dt = Math.floor(dt / 16)
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
return uuid
}
function chainCallback(object, property, callback) {
if (object == undefined) {
//This should not happen.
console.error("Tried to add callback to non-existant object")
return;
}
if (property in object) {
const callback_orig = object[property]
object[property] = function () {
const r = callback_orig.apply(this, arguments);
callback.apply(this, arguments);
return r
};
} else {
object[property] = callback;
}
}
app.registerExtension({
name: 'KJNodes.FastPreview',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== 'FastPreview') return;
chainCallback(nodeType.prototype, "onNodeCreated", function () {
this.setSize([550, 550]);
const nodeRef = this;
if (nodeData?.name === 'FastPreview') {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
const show = blob => {
const img = new Image();
img.onload = () => { nodeRef.imgs = [img]; nodeRef.setDirtyCanvas(true); };
img.src = URL.createObjectURL(blob);
};
var element = document.createElement("div");
this.uuid = makeUUID()
element.id = `fast-preview-${this.uuid}`
const metaHandler = e => {
const { blob, nodeId, displayNodeId } = e.detail;
if (String(displayNodeId || nodeId) === String(nodeRef.id)) show(blob);
};
const plainHandler = e => {
if (api.serverSupportsFeature?.("supports_preview_metadata")) return;
if (String(execId) === String(nodeRef.id)) show(e.detail);
};
this.previewWidget = this.addDOMWidget(nodeData.name, "FastPreviewWidget", element, {
serialize: false,
hideOnZoom: false,
});
api.addEventListener("b_preview_with_metadata", metaHandler);
api.addEventListener("b_preview", plainHandler);
this.previewer = new Previewer(this);
chainCallback(nodeRef, "onRemoved", () => {
api.removeEventListener("b_preview_with_metadata", metaHandler);
api.removeEventListener("b_preview", plainHandler);
});
});
this.setSize([550, 550]);
this.resizable = false;
this.previewWidget.parentEl = document.createElement("div");
this.previewWidget.parentEl.className = "fast-preview";
this.previewWidget.parentEl.id = `fast-preview-${this.uuid}`
element.appendChild(this.previewWidget.parentEl);
chainCallback(this, "onExecuted", function (message) {
let bg_image = message["bg_image"];
this.properties.imgData = {
name: "bg_image",
base64: bg_image
};
this.previewer.refreshBackgroundImage(this);
});
}); // onAfterGraphConfigured
}//node created
} //before register
})//register
class Previewer {
constructor(context) {
this.node = context;
this.previousWidth = null;
this.previousHeight = null;
}
});
refreshBackgroundImage = () => {
const imgData = this.node?.properties?.imgData;
if (imgData?.base64) {
const base64String = imgData.base64;
const imageUrl = `data:${imgData.type};base64,${base64String}`;
const img = new Image();
img.src = imageUrl;
img.onload = () => {
const { width, height } = img;
if (width !== this.previousWidth || height !== this.previousHeight) {
this.node.setSize([width, height]);
this.previousWidth = width;
this.previousHeight = height;
}
this.node.previewWidget.element.style.backgroundImage = `url(${imageUrl})`;
};
}
};
}
-518
View File
@@ -1,518 +0,0 @@
import { chainCallback, addMiddleClickPan, addWheelPassthrough } from './utility.js';
const { app } = window.comfyAPI.app;
const { api } = window.comfyAPI.api;
const STYLE_ID = 'kj-batch-preview-style';
function ensureStyles() {
if (document.getElementById(STYLE_ID)) return;
const s = document.createElement('style');
s.id = STYLE_ID;
s.textContent = `
.kj-bp-root { width:100%; height:100%; position:relative; background:#1a1a1a; overflow:hidden; user-select:none; }
.kj-bp-grid { width:100%; height:100%; display:block; cursor:pointer; image-rendering:auto; }
.kj-bp-detail { position:absolute; inset:0; display:none; background:#0d0d0d; }
.kj-bp-detail.visible { display:flex; flex-direction:column; }
.kj-bp-detail-canvas { flex:1 1 auto; min-height:0; width:100%; display:block; }
.kj-bp-bar { display:flex; align-items:center; justify-content:space-between; padding:4px 8px; background:#222; color:#ddd; font:12px sans-serif; }
.kj-bp-btn { background:#333; color:#ddd; border:1px solid #444; padding:2px 10px; cursor:pointer; font:12px sans-serif; }
.kj-bp-btn:hover { background:#444; }
.kj-bp-btn:disabled { opacity:0.4; cursor:default; }
.kj-bp-scrubber { position:relative; flex:1 1 auto; height:22px; margin:0 8px; background:#2a2a2a; border:1px solid #444; cursor:ew-resize; user-select:none; touch-action:none; }
.kj-bp-scrubber-fill { position:absolute; top:0; bottom:0; left:0; width:0; background:#3a5a7a; pointer-events:none; }
.kj-bp-scrubber-text { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:#ddd; font:11px sans-serif; pointer-events:none; text-shadow:0 1px 2px rgba(0,0,0,0.6); }
.kj-bp-status { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:#888; font:12px sans-serif; pointer-events:none; }
`;
document.head.appendChild(s);
}
function pickGridLayout(count, aspect, containerW, containerH) {
let best = { cols: 1, rows: count, cellW: 0, cellH: 0, area: 0 };
for (let cols = 1; cols <= count; cols++) {
const rows = Math.ceil(count / cols);
const cellW = containerW / cols;
const cellH = containerH / rows;
const fitW = Math.min(cellW, cellH * aspect);
const fitH = fitW / aspect;
const area = fitW * fitH;
if (area > best.area) best = { cols, rows, cellW, cellH, area, fitW, fitH };
}
return best;
}
app.registerExtension({
name: 'KJNodes.FastPreviewBatch',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== 'FastPreviewBatch') return;
chainCallback(nodeType.prototype, 'onNodeCreated', function () {
ensureStyles();
this.setSize([520, 520]);
const root = document.createElement('div');
root.className = 'kj-bp-root';
const status = document.createElement('div');
status.className = 'kj-bp-status';
status.textContent = 'No preview yet';
root.appendChild(status);
const gridCanvas = document.createElement('canvas');
gridCanvas.className = 'kj-bp-grid';
gridCanvas.style.display = 'none';
root.appendChild(gridCanvas);
const detail = document.createElement('div');
detail.className = 'kj-bp-detail';
const detailCanvas = document.createElement('canvas');
detailCanvas.className = 'kj-bp-detail-canvas';
const bar = document.createElement('div');
bar.className = 'kj-bp-bar';
const prevBtn = document.createElement('button');
prevBtn.className = 'kj-bp-btn';
prevBtn.textContent = '❮';
const nextBtn = document.createElement('button');
nextBtn.className = 'kj-bp-btn';
nextBtn.textContent = '❯';
const scrubber = document.createElement('div');
scrubber.className = 'kj-bp-scrubber';
const scrubberFill = document.createElement('div');
scrubberFill.className = 'kj-bp-scrubber-fill';
const scrubberText = document.createElement('span');
scrubberText.className = 'kj-bp-scrubber-text';
scrubber.appendChild(scrubberFill);
scrubber.appendChild(scrubberText);
const playBtn = document.createElement('button');
playBtn.className = 'kj-bp-btn';
playBtn.textContent = '▶';
const closeBtn = document.createElement('button');
closeBtn.className = 'kj-bp-btn';
closeBtn.textContent = '✕';
bar.appendChild(playBtn);
bar.appendChild(prevBtn);
bar.appendChild(scrubber);
bar.appendChild(nextBtn);
bar.appendChild(closeBtn);
detail.appendChild(detailCanvas);
detail.appendChild(bar);
root.appendChild(detail);
this.addDOMWidget('fast_preview_batch', 'div', root, { serialize: false });
const removeMiddleClickPan = addMiddleClickPan(root);
addWheelPassthrough(root);
const state = {
video: null,
strip: null,
stripCols: 1,
stripCellW: 0,
stripCellH: 0,
frameCount: 0,
fps: 30,
thumbW: 0,
thumbH: 0,
layout: null,
gridReady: false,
currentIndex: 0,
hoverIndex: -1,
};
const setStatus = (msg) => {
if (msg) {
status.textContent = msg;
status.style.display = 'flex';
} else {
status.style.display = 'none';
}
};
const cleanupVideo = () => {
if (state.video) {
try { state.video.pause(); } catch (_) { }
state.video.removeAttribute('src');
try { state.video.load(); } catch (_) { }
if (state.video.parentNode) state.video.parentNode.removeChild(state.video);
state.video = null;
}
};
const seekToFrame = (i) => new Promise((resolve) => {
if (!state.video) return resolve();
const t = (i + 0.5) / state.fps;
const onSeeked = () => {
state.video.removeEventListener('seeked', onSeeked);
// give the decoder a tick to ensure frame is drawable
requestAnimationFrame(() => resolve());
};
state.video.addEventListener('seeked', onSeeked);
try {
state.video.currentTime = t;
} catch (_) {
state.video.removeEventListener('seeked', onSeeked);
resolve();
}
});
let drawGeneration = 0;
const updateScrubberUI = () => {
const t = state.frameCount > 1 ? state.currentIndex / (state.frameCount - 1) : 0;
scrubberFill.style.width = (t * 100) + '%';
scrubberText.textContent = `frame ${state.currentIndex + 1} / ${state.frameCount}`;
prevBtn.disabled = state.currentIndex <= 0;
nextBtn.disabled = state.currentIndex >= state.frameCount - 1;
};
const paintFrameToDetail = () => {
const v = state.video;
if (!v) return;
const w = detailCanvas.clientWidth;
const h = detailCanvas.clientHeight;
if (w <= 0 || h <= 0) return;
const dpr = window.devicePixelRatio || 1;
const targetW = Math.max(1, Math.floor(w * dpr));
const targetH = Math.max(1, Math.floor(h * dpr));
if (detailCanvas.width !== targetW) detailCanvas.width = targetW;
if (detailCanvas.height !== targetH) detailCanvas.height = targetH;
const ctx = detailCanvas.getContext('2d');
ctx.fillStyle = '#0d0d0d';
ctx.fillRect(0, 0, detailCanvas.width, detailCanvas.height);
const aspect = state.thumbW / state.thumbH;
let dw = detailCanvas.width;
let dh = dw / aspect;
if (dh > detailCanvas.height) {
dh = detailCanvas.height;
dw = dh * aspect;
}
const dx = (detailCanvas.width - dw) / 2;
const dy = (detailCanvas.height - dh) / 2;
ctx.drawImage(v, dx, dy, dw, dh);
};
const drawDetail = async () => {
if (!state.video || !state.frameCount) return;
updateScrubberUI();
const gen = ++drawGeneration;
await seekToFrame(state.currentIndex);
if (gen !== drawGeneration) return; // superseded by a newer scrub
paintFrameToDetail();
};
let playing = false;
let playHandle = 0;
const updatePlayBtn = () => { playBtn.textContent = playing ? '⏸' : '▶'; };
const stopPlayback = () => {
if (!playing) return;
playing = false;
playHandle++;
if (state.video) { try { state.video.pause(); } catch (_) { } }
updatePlayBtn();
};
const startPlayback = async () => {
if (!state.video || !state.frameCount || playing) return;
if (typeof state.video.requestVideoFrameCallback !== 'function') return;
if (state.currentIndex >= state.frameCount - 1) state.currentIndex = 0;
drawGeneration++; // invalidate any pending drawDetail
await seekToFrame(state.currentIndex);
paintFrameToDetail();
updateScrubberUI();
playing = true;
updatePlayBtn();
state.video.playbackRate = 1;
const myHandle = ++playHandle;
try { await state.video.play(); } catch (_) { stopPlayback(); return; }
if (myHandle !== playHandle) return;
const onFrame = (_now, metadata) => {
if (!playing || myHandle !== playHandle) return;
const idx = Math.min(state.frameCount - 1,
Math.floor(metadata.mediaTime * state.fps + 1e-6));
if (idx !== state.currentIndex) {
state.currentIndex = idx;
updateScrubberUI();
}
paintFrameToDetail();
if (idx >= state.frameCount - 1) { stopPlayback(); return; }
state.video.requestVideoFrameCallback(onFrame);
};
state.video.requestVideoFrameCallback(onFrame);
};
const renderGrid = () => {
if (!state.frameCount || !state.strip) return;
const containerW = root.clientWidth;
const containerH = root.clientHeight;
if (containerW <= 0 || containerH <= 0) return;
const dpr = window.devicePixelRatio || 1;
const aspect = state.thumbW / state.thumbH;
const layout = pickGridLayout(state.frameCount, aspect, containerW, containerH);
state.layout = layout;
gridCanvas.width = Math.floor(containerW * dpr);
gridCanvas.height = Math.floor(containerH * dpr);
gridCanvas.style.width = containerW + 'px';
gridCanvas.style.height = containerH + 'px';
const ctx = gridCanvas.getContext('2d');
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, gridCanvas.width, gridCanvas.height);
const cellW = layout.cellW * dpr;
const cellH = layout.cellH * dpr;
const fitW = layout.fitW * dpr;
const fitH = layout.fitH * dpr;
const total = state.frameCount;
const sw = state.stripCellW;
const sh = state.stripCellH;
const sCols = state.stripCols;
for (let i = 0; i < total; i++) {
const sx = (i % sCols) * sw;
const sy = Math.floor(i / sCols) * sh;
const dCol = i % layout.cols;
const dRow = Math.floor(i / layout.cols);
const dx = dCol * cellW + (cellW - fitW) / 2;
const dy = dRow * cellH + (cellH - fitH) / 2;
ctx.drawImage(state.strip, sx, sy, sw, sh, dx, dy, fitW, fitH);
}
if (state.hoverIndex >= 0 && state.hoverIndex < total) {
const i = state.hoverIndex;
const sx = (i % sCols) * sw;
const sy = Math.floor(i / sCols) * sh;
const dCol = i % layout.cols;
const dRow = Math.floor(i / layout.cols);
const cx = dCol * cellW + cellW / 2;
const cy = dRow * cellH + cellH / 2;
const scale = 1.15;
const zw = fitW * scale;
const zh = fitH * scale;
let dx = cx - zw / 2;
let dy = cy - zh / 2;
dx = Math.max(0, Math.min(gridCanvas.width - zw, dx));
dy = Math.max(0, Math.min(gridCanvas.height - zh, dy));
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.65)';
ctx.shadowBlur = 14 * dpr;
ctx.drawImage(state.strip, sx, sy, sw, sh, dx, dy, zw, zh);
ctx.restore();
ctx.strokeStyle = '#5a8ec4';
ctx.lineWidth = Math.max(2, 2 * dpr);
ctx.strokeRect(dx + ctx.lineWidth / 2, dy + ctx.lineWidth / 2, zw - ctx.lineWidth, zh - ctx.lineWidth);
}
state.gridReady = true;
setStatus('');
gridCanvas.style.display = 'block';
};
const enterDetail = (idx) => {
state.currentIndex = idx;
detail.classList.add('visible');
requestAnimationFrame(drawDetail);
};
const exitDetail = () => {
stopPlayback();
detail.classList.remove('visible');
};
playBtn.addEventListener('click', () => {
if (playing) stopPlayback();
else startPlayback();
});
const cellIndexFromEvent = (e) => {
if (!state.gridReady || !state.layout) return -1;
const rect = gridCanvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return -1;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const cellW = rect.width / state.layout.cols;
const cellH = rect.height / state.layout.rows;
const col = Math.floor(x / cellW);
const row = Math.floor(y / cellH);
if (col < 0 || col >= state.layout.cols || row < 0 || row >= state.layout.rows) return -1;
const idx = row * state.layout.cols + col;
return (idx >= 0 && idx < state.frameCount) ? idx : -1;
};
gridCanvas.addEventListener('click', (e) => {
const idx = cellIndexFromEvent(e);
if (idx >= 0) enterDetail(idx);
});
gridCanvas.addEventListener('pointermove', (e) => {
const idx = cellIndexFromEvent(e);
if (idx !== state.hoverIndex) {
state.hoverIndex = idx;
renderGrid();
}
});
gridCanvas.addEventListener('pointerleave', () => {
if (state.hoverIndex !== -1) {
state.hoverIndex = -1;
renderGrid();
}
});
prevBtn.addEventListener('click', () => {
if (state.currentIndex > 0) {
stopPlayback();
state.currentIndex--;
drawDetail();
}
});
nextBtn.addEventListener('click', () => {
if (state.currentIndex < state.frameCount - 1) {
stopPlayback();
state.currentIndex++;
drawDetail();
}
});
closeBtn.addEventListener('click', exitDetail);
let scrubbing = false;
const scrubFromEvent = (e) => {
if (!state.frameCount) return;
const rect = scrubber.getBoundingClientRect();
const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const idx = Math.round(t * (state.frameCount - 1));
if (idx !== state.currentIndex) {
state.currentIndex = idx;
drawDetail();
}
};
scrubber.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return; // let middle/right click bubble (canvas pan, etc.)
stopPlayback();
scrubbing = true;
scrubber.setPointerCapture(e.pointerId);
scrubFromEvent(e);
e.preventDefault();
});
scrubber.addEventListener('pointermove', (e) => {
if (scrubbing) scrubFromEvent(e);
});
const endScrub = (e) => {
if (!scrubbing) return;
scrubbing = false;
try { scrubber.releasePointerCapture(e.pointerId); } catch (_) { }
};
scrubber.addEventListener('pointerup', endScrub);
scrubber.addEventListener('pointercancel', endScrub);
const keyHandler = (e) => {
if (!detail.classList.contains('visible')) return;
if (e.key === 'Escape') { exitDetail(); e.stopPropagation(); }
else if (e.key === ' ') {
if (playing) stopPlayback(); else startPlayback();
e.preventDefault(); e.stopPropagation();
}
else if (e.key === 'ArrowLeft' && state.currentIndex > 0) {
stopPlayback(); state.currentIndex--; drawDetail(); e.stopPropagation();
} else if (e.key === 'ArrowRight' && state.currentIndex < state.frameCount - 1) {
stopPlayback(); state.currentIndex++; drawDetail(); e.stopPropagation();
}
};
window.addEventListener('keydown', keyHandler);
const ro = new ResizeObserver(() => {
if (detail.classList.contains('visible')) {
drawDetail();
} else if (state.strip) {
renderGrid();
}
});
ro.observe(root);
const buildViewUrl = (filename, subfolder, type) => {
const params = new URLSearchParams({
filename: filename,
subfolder: subfolder || '',
type: type || 'temp',
t: String(Date.now()),
});
return api.apiURL(`/view?${params.toString()}`);
};
const loadFromOutput = async (info) => {
stopPlayback();
cleanupVideo();
state.strip = null;
state.gridReady = false;
gridCanvas.style.display = 'none';
setStatus('Loading preview…');
state.frameCount = info.frame_count;
state.fps = info.fps || 30;
state.thumbW = info.thumb_w;
state.thumbH = info.thumb_h;
state.stripCols = info.strip_cols || 1;
state.stripCellW = info.strip_cell_w || info.thumb_w;
state.stripCellH = info.strip_cell_h || info.thumb_h;
if (state.currentIndex >= state.frameCount || state.currentIndex < 0) {
state.currentIndex = 0;
}
const v = document.createElement('video');
v.muted = true;
v.playsInline = true;
v.preload = 'auto';
v.style.position = 'absolute';
v.style.left = '-99999px';
v.style.width = '1px';
v.style.height = '1px';
document.body.appendChild(v);
state.video = v;
// Video loads in parallel for the detail view; failure is non-fatal
// for the grid (strip handles that). Caught to avoid unhandled-rejection.
new Promise((resolve, reject) => {
v.addEventListener('loadeddata', resolve, { once: true });
v.addEventListener('error', () => {
cleanupVideo();
reject(new Error('video load failed'));
}, { once: true });
v.src = buildViewUrl(info.filename, info.subfolder, info.type);
}).catch(() => { });
await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => { state.strip = img; resolve(); };
img.onerror = () => reject(new Error('strip load failed'));
img.src = buildViewUrl(info.strip_filename, info.subfolder, info.type);
});
renderGrid();
if (detail.classList.contains('visible')) {
// Refresh detail canvas with the new video; drawDetail awaits seek,
// which itself queues until the new video element finishes loading.
drawDetail();
}
};
const tryLoad = (info, onMissing) => {
loadFromOutput(info).catch((err) => {
console.warn('FastPreviewBatch load failed', err);
if (onMissing) onMissing();
else setStatus('Preview failed: ' + (err && err.message || err));
});
};
chainCallback(this, 'onExecuted', function (output) {
if (!output || !output.kj_batch_preview) return;
const info = output.kj_batch_preview[0];
if (!info) return;
this.properties = this.properties || {};
this.properties.kj_batch_preview = info;
tryLoad(info);
});
chainCallback(this, 'onConfigure', function () {
const info = this.properties && this.properties.kj_batch_preview;
if (!info) return;
tryLoad(info, () => {
setStatus('Preview unavailable (re-run to regenerate)');
delete this.properties.kj_batch_preview;
});
});
chainCallback(this, 'onRemoved', function () {
window.removeEventListener('keydown', keyHandler);
ro.disconnect();
removeMiddleClickPan();
cleanupVideo();
});
});
},
});
-225
View File
@@ -1,225 +0,0 @@
const { app } = window.comfyAPI.app;
import { getSlotPos } from "./utility.js";
/**
* Check type compatibility. Returns a match tier:
* 2 = exact single-type match, 1 = multi-type or wildcard match, -1 = incompatible
*/
function typeMatchTier(outType, inType) {
if (!LiteGraph.isValidConnection(outType, inType)) return -1;
if (typeof outType === "string" && typeof inType === "string"
&& !outType.includes(",") && !inType.includes(",")
&& outType.toUpperCase() === inType.toUpperCase()) return 2;
return 1;
}
/**
* Order selected nodes for connection.
* Respects existing links between them (topological sort),
* then inserts unconnected nodes by spatial position (left-to-right, top-to-bottom).
*/
function orderNodes(nodes, graph) {
const selectedIds = new Set(nodes.map(n => n.id));
const nodeById = new Map(nodes.map(n => [n.id, n]));
const outEdges = new Map();
const inDegree = new Map();
for (const n of nodes) {
outEdges.set(n.id, new Set());
inDegree.set(n.id, 0);
}
for (const node of nodes) {
for (const out of (node.outputs || [])) {
for (const linkId of (out.links || [])) {
const link = graph.getLink(linkId);
if (link && selectedIds.has(link.target_id) && link.target_id !== node.id) {
if (!outEdges.get(node.id).has(link.target_id)) {
outEdges.get(node.id).add(link.target_id);
inDegree.set(link.target_id, inDegree.get(link.target_id) + 1);
}
}
}
}
}
const bySpatial = (a, b) => a.pos[0] - b.pos[0] || a.pos[1] - b.pos[1];
const queue = nodes.filter(n => inDegree.get(n.id) === 0);
queue.sort(bySpatial);
const ordered = [];
while (queue.length > 0) {
const node = queue.shift();
ordered.push(node);
for (const targetId of outEdges.get(node.id)) {
const deg = inDegree.get(targetId) - 1;
inDegree.set(targetId, deg);
if (deg === 0 && nodeById.has(targetId)) {
queue.push(nodeById.get(targetId));
queue.sort(bySpatial);
}
}
}
// Cycle fallback: append remaining by position
if (ordered.length < nodes.length) {
const inOrdered = new Set(ordered.map(n => n.id));
const remaining = nodes.filter(n => !inOrdered.has(n.id));
remaining.sort(bySpatial);
ordered.push(...remaining);
}
return ordered;
}
/**
* Collect all candidate connections and assign globally by closest 2D distance.
* Each input and output is used at most once per invocation.
*/
function planConnections(ordered) {
// Collect all candidates globally across all target nodes
const candidates = [];
for (let b = 1; b < ordered.length; b++) {
const nodeB = ordered[b];
if (!nodeB.inputs) continue;
for (let inIdx = 0; inIdx < nodeB.inputs.length; inIdx++) {
const inp = nodeB.inputs[inIdx];
if (inp.link != null) continue;
const inPos = getSlotPos(nodeB, true, inIdx);
const inputCandidates = [];
let hasExact = false;
const inName = (inp.name || inp.label || "").toLowerCase();
for (let a = b - 1; a >= 0; a--) {
const nodeA = ordered[a];
if (!nodeA.outputs) continue;
for (let outIdx = 0; outIdx < nodeA.outputs.length; outIdx++) {
const out = nodeA.outputs[outIdx];
const tier = typeMatchTier(out.type, inp.type);
if (tier < 0) continue;
if (tier === 2) hasExact = true;
const outName = (out.name || out.label || "").toLowerCase();
const nameMatch = inName !== "" && inName === outName ? 1 : 0;
const outPos = getSlotPos(nodeA, false, outIdx);
const dx = outPos[0] - inPos[0];
const dy = outPos[1] - inPos[1];
inputCandidates.push({
targetNode: nodeB,
inIdx,
sourceNode: nodeA,
outIdx,
tier,
nameMatch,
dist: dx * dx + dy * dy,
});
}
}
for (const c of inputCandidates) {
c.hasExact = hasExact;
}
candidates.push(...inputCandidates);
}
}
// Filter out non-exact matches for inputs that have an exact candidate (keep name matches)
const filtered = candidates.filter(c => c.tier === 2 || c.nameMatch || !c.hasExact);
// Sort globally: name match first, then higher type tier, then closest pair
filtered.sort((x, y) => (y.nameMatch - x.nameMatch) || (y.tier - x.tier) || (x.dist - y.dist));
// Greedy global assignment — closest pairs first
const planned = [];
const usedInputs = new Set();
const usedOutputs = new Set();
for (const c of filtered) {
const inKey = `${c.targetNode.id}:${c.inIdx}`;
if (usedInputs.has(inKey)) continue;
const outKey = `${c.sourceNode.id}:${c.outIdx}`;
if (usedOutputs.has(outKey)) continue;
planned.push({
sourceNode: c.sourceNode,
outIdx: c.outIdx,
targetNode: c.targetNode,
inIdx: c.inIdx,
});
usedInputs.add(inKey);
usedOutputs.add(outKey);
}
return planned;
}
function connectPlanned(planned, graph) {
for (const p of planned) {
p.sourceNode.connect(p.outIdx, p.targetNode, p.inIdx);
}
graph.change();
}
function fillConnectSelected() {
if (!app.ui.settings.getSettingValue("KJNodes.fillConnectEnabled")) return;
const canvas = app.canvas;
const graph = canvas.graph;
const nodes = Object.values(canvas.selected_nodes || {});
if (nodes.length < 2) return;
const ordered = orderNodes(nodes, graph);
const planned = planConnections(ordered);
if (planned.length > 0) {
connectPlanned(planned, graph);
}
if (app.extensionManager?.toast) {
app.extensionManager.toast.add({
severity: planned.length > 0 ? "info" : "warn",
summary: planned.length > 0
? `Connected ${planned.length} link${planned.length > 1 ? "s" : ""}`
: "No compatible connections found",
life: 2000,
});
}
}
app.registerExtension({
name: "KJNodes.FillConnect",
settings: [
{
id: "KJNodes.fillConnectEnabled",
name: "Enable Fill-Connect hotkey",
category: ["KJNodes", "Fill-Connect"],
tooltip: "Fill-connect selected nodes by compatible types",
type: "boolean",
defaultValue: true,
},
],
commands: [
{
id: "KJNodes.fillConnectSelected",
label: "Fill-Connect Selected Nodes",
function: fillConnectSelected,
},
],
keybindings: [
{
commandId: "KJNodes.fillConnectSelected",
combo: { key: "f" },
targetElementId: "graph-canvas",
},
],
});
-93
View File
@@ -1,93 +0,0 @@
const { app } = window.comfyAPI.app;
function snapshotOld(oldNode) {
const data = oldNode.serialize();
const widgetValuesByName = {};
for (const w of oldNode.widgets || []) widgetValuesByName[w.name] = w.value;
const inputSnapshot = (oldNode.inputs || []).map(i => ({
name: i.name,
widgetName: i.widget?.name ?? null,
linkId: i.link ?? null,
}));
const outputLinksByName = {};
for (const o of oldNode.outputs || []) {
if (o.links?.length) outputLinksByName[o.name] = [...o.links];
}
return { data, widgetValuesByName, inputSnapshot, outputLinksByName };
}
function applyCosmetics(newNode, old) {
if (old.pos) newNode.pos = [...old.pos];
if (old.size) {
const min = newNode.computeSize?.() || [0, 0];
newNode.size = [Math.max(old.size[0], min[0]), Math.max(old.size[1], min[1])];
}
if (old.title) newNode.title = old.title;
if (old.color) newNode.color = old.color;
if (old.bgcolor) newNode.bgcolor = old.bgcolor;
if (old.flags) newNode.flags = { ...(newNode.flags || {}), ...old.flags };
if (typeof old.mode === "number") newNode.mode = old.mode;
if (old.properties) newNode.properties = { ...(newNode.properties || {}), ...old.properties };
}
function fixNode(oldNode, comfyClass, { resetValues = false } = {}) {
const graph = app.canvas.graph;
const snap = snapshotOld(oldNode);
const newNode = LiteGraph.createNode(comfyClass);
if (!newNode) {
console.error(`[KJNodes.FixNode] Unknown node type: ${comfyClass}`);
return null;
}
try {
graph.add(newNode, false);
applyCosmetics(newNode, snap.data);
// Restore widget values by name (live widgets, not widgets_values array).
if (!resetValues) {
for (const w of newNode.widgets || []) {
if (Object.prototype.hasOwnProperty.call(snap.widgetValuesByName, w.name)) {
try { w.value = snap.widgetValuesByName[w.name]; } catch {}
}
}
}
// Reconnect inputs by name.
for (const inp of snap.inputSnapshot) {
if (inp.linkId == null) continue;
const link = app.graph.links[inp.linkId];
if (!link) continue;
const src = app.graph.getNodeById(link.origin_id);
const newSlot = newNode.findInputSlot?.(inp.name);
if (!src || newSlot == null || newSlot < 0) continue;
try { src.connect(link.origin_slot, newNode, newSlot); } catch {}
}
// Reconnect outputs by name.
(newNode.outputs || []).forEach((out, i) => {
const linkIds = snap.outputLinksByName[out.name];
if (!linkIds) return;
for (const id of linkIds) {
const link = app.graph.links[id];
if (!link) continue;
const tgt = app.graph.getNodeById(link.target_id);
if (!tgt) continue;
try { newNode.connect(i, tgt, link.target_slot); } catch {}
}
});
graph.remove(oldNode);
app.graph.afterChange();
requestAnimationFrame(() => app.canvas.setDirty(true, true));
return newNode;
} catch (err) {
console.error("[KJNodes.FixNode] Aborting, rolling back:", err);
try { graph.remove(newNode); } catch {}
return null;
}
}
window.kjNodes = window.kjNodes || {};
window.kjNodes.recreateNode = fixNode;
-128
View File
@@ -1,128 +0,0 @@
import { registerPromptBuilder, ideoGrid, ideoFromEdges } from "./ideogram4_prompt_builder.js";
// FLUX.3 caption format (nodes/flux3_nodes.py): the prompt, one space, then a JSON list of box rows on the
// 0-1000 grid. Boxes live on canvases ("views"): 0 = output, k = reference input ref_k. Boxes sharing an id
// form one row: on a reference = src_bbox, on the output = tgt_bbox.
// Python's json.dumps(v, ensure_ascii=False) formatting, so the copied caption matches the node output.
function pyDumps(v) {
if (Array.isArray(v)) return "[" + v.map(pyDumps).join(", ") + "]";
if (v && typeof v === "object") return "{" + Object.entries(v).map(([k, x]) => JSON.stringify(k) + ": " + pyDumps(x)).join(", ") + "}";
return JSON.stringify(v);
}
// Editor boxes -> rows; mirrors _rows() in flux3_nodes.py.
function rows(ed) {
const slots = ed.refSlots(), rank = new Map(slots.map((k, i) => [k, i]));
const order = [], src = new Map(), tgt = new Map();
for (const b of ed.node._boxes) {
if (!b || b.nobbox) continue;
const view = b.view || 0;
if (view && !rank.has(view)) continue; // its reference is no longer connected
const id = String(b.id || "");
if (!src.has(id) && !tgt.has(id)) order.push(id);
const m = view ? src : tgt;
if (!m.has(id)) m.set(id, b);
}
return order.map((id) => {
const s = src.get(id), t = tgt.get(id);
const desc = (t && t.desc) || (s && s.desc) || "";
if (slots.length) {
return { id, from: s ? `ref_image_${rank.get(s.view)}` : "target",
src_bbox: s ? ideoGrid(s) : null, tgt_bbox: t ? ideoGrid(t) : null, desc };
}
return t ? { id, bbox: ideoGrid(t), desc } : null;
}).filter(Boolean);
}
// [y_min, x_min, y_max, x_max] on the 0-1000 grid -> {x, y, w, h}, or null.
function gridBox(bb) {
if (!Array.isArray(bb) || bb.length !== 4) return null;
const n = bb.map(Number);
return n.every(Number.isFinite) ? ideoFromEdges(n, 1000, 1000) : null;
}
registerPromptBuilder({
nodeName: "Flux3PromptBuilderKJ",
label: "FLUX.3",
title: "FLUX.3 editor",
noun: "box",
tplDir: "kjnodes/flux3/templates",
frontFirst: true,
maxElemColors: 0, // no colour fields: colours go in the description
maxStyleColors: 0,
format: "compact",
tokenCap: 0,
typeToggle: false,
textCaption: true, // plain text caption, not a JSON object
views: "references", // one canvas per connected reference (ref_1..)
linkKey: "id", // boxes with the same id on different canvases are one row
importInput: "import_caption",
captionWidgets: ["prompt"],
fields: [
{ key: "id", placeholder: "id — cite it as <id> in the prompt" },
{ key: "desc", grow: 3, placeholder: "description of this object" },
],
coords: { // same order in both: ymin, xmin, ymax, xmax
px: {
hint: "ymin, xmin, ymax, xmax", title: "Pixel box (of the current canvas): ymin, xmin, ymax, xmax — editable",
get: (b, W, H) => [Math.round(b.y * H), Math.round(b.x * W), Math.round((b.y + b.h) * H), Math.round((b.x + b.w) * W)],
set: (n, W, H) => ideoFromEdges(n, W, H),
},
out: {
hint: "ymin, xmin, ymax, xmax", title: "Exported box on the 0–1000 grid: ymin, xmin, ymax, xmax — editable",
get: (b) => ideoGrid(b),
set: (n) => ideoFromEdges(n, 1000, 1000),
},
},
tagText: (b) => b.id || "—",
// Where the row lives: placed (reference -> output), removed (reference only) or new (output only).
describe(b, ed) {
if (!ed.refSlots().length) return "";
const views = ed.node._boxes.filter((o) => o.id === b.id).map((o) => o.view || 0);
const src = views.find((v) => v > 0);
if (src && views.includes(0)) return `ref_${src} → output`;
if (src) return `ref_${src} only (removed; draw it on output to place it)`;
return "new in the output";
},
blankBox: () => ({ id: "", desc: "" }),
// A box drawn right after switching canvases joins the row that was selected; otherwise a fresh obj_N.
initBox(nb, ed, prev) {
const boxes = ed.node._boxes, view = nb.view || 0;
if (prev && prev.id && !boxes.some((o) => o !== nb && o.id === prev.id && (o.view || 0) === view)) {
nb.id = prev.id; nb.desc = prev.desc || "";
return;
}
let n = 0;
for (const o of boxes) { const m = /^obj_(\d+)$/.exec(o.id || ""); if (m) n = Math.max(n, +m[1]); }
nb.id = "obj_" + (n + 1);
},
// "prompt [rows]": the last "[" from which the rest parses as a JSON list; mirrors _split_caption().
parseCaption(t) {
if (!t) return null;
for (let i = t.length - 1; i >= 0; i--) {
if (t[i] !== "[") continue;
try { const r = JSON.parse(t.slice(i)); if (Array.isArray(r)) return { prompt: t.slice(0, i).trimEnd(), rows: r }; }
catch (e) {}
}
return { prompt: t.trim(), rows: [] };
},
elements: (cap) => (Array.isArray(cap.rows) ? cap.rows : []),
// One row -> its boxes; ref_image_N goes to the canvas of ref_{N+1}. Mirrors _rows_to_boxes().
elemToBox(el, idx) {
if (!el || typeof el !== "object") return null;
const base = { id: String(el.id || `obj_${(idx || 0) + 1}`), desc: String(el.desc || "") };
const out = [];
const m = /^ref_image_(\d+)$/.exec(String(el.from || ""));
const src = gridBox(el.src_bbox);
if (src && m) out.push({ ...src, ...base, view: +m[1] + 1 });
const tgt = gridBox("tgt_bbox" in el ? el.tgt_bbox : el.bbox);
if (tgt) out.push({ ...tgt, ...base, view: 0 });
return out;
},
buildCaption(ed) { // mirrors Flux3PromptBuilderKJ.execute
const prompt = ed.getW("prompt") || "", r = rows(ed);
return r.length ? prompt.trimEnd() + " " + pyDumps(r) : prompt;
},
applyCaption(cap, ed) { ed.setW("prompt", cap.prompt || ""); },
});
-574
View File
@@ -1,574 +0,0 @@
import { chainCallback, addMiddleClickPan, addWheelPassthrough } from './utility.js';
const { app } = window.comfyAPI.app;
// Shared across all HDR Preview nodes so synced nodes can drive each other.
const hdrSyncGroup = new Set();
const VERTEX_SHADER = `#version 300 es
out vec2 v_texCoord;
void main() {
vec2 verts[3] = vec2[](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
v_texCoord = verts[gl_VertexID] * 0.5 + 0.5;
v_texCoord.y = 1.0 - v_texCoord.y;
gl_Position = vec4(verts[gl_VertexID], 0.0, 1.0);
}`;
// LogC3 constants ported from ComfyUI-LTXVideo/hdr.py
const FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec2 v_texCoord;
out vec4 fragColor;
uniform sampler2D u_image;
uniform float u_exposure;
uniform float u_saturation;
uniform float u_linearScale;
uniform int u_space; // 0 = logc3, 1 = linear
const float LC_A = 5.555556;
const float LC_B = 0.052272;
const float LC_C = 0.247190;
const float LC_D = 0.385537;
const float LC_E = 5.367655;
const float LC_F = 0.092809;
const float LC_CUT = 0.010591;
const float LC_CUT_LOG = LC_E * LC_CUT + LC_F; // ~0.14966
vec3 logc3_decompress(vec3 logc) {
logc = clamp(logc, 0.0, 1.0);
vec3 lin_from_log = (pow(vec3(10.0), (logc - LC_D) / LC_C) - LC_B) / LC_A;
vec3 lin_from_lin = (logc - LC_F) / LC_E;
vec3 is_log = step(vec3(LC_CUT_LOG), logc);
return mix(lin_from_lin, lin_from_log, is_log);
}
vec3 linear_to_srgb(vec3 x) {
vec3 cutoff = vec3(0.0031308);
vec3 low = 12.92 * x;
vec3 high = 1.055 * pow(max(x, cutoff), vec3(1.0 / 2.4)) - 0.055;
return clamp(mix(low, high, step(cutoff, x)), 0.0, 1.0);
}
vec3 srgb_to_linear(vec3 x) {
vec3 cutoff = vec3(0.04045);
vec3 low = x / 12.92;
vec3 high = pow(max(x, vec3(0.0)) + 0.055, vec3(2.4)) / pow(vec3(1.055), vec3(2.4));
return mix(low, high, step(cutoff, x));
}
void main() {
vec3 col = texture(u_image, v_texCoord).rgb;
vec3 hdr;
if (u_space == 0) {
hdr = logc3_decompress(col);
} else if (u_space == 1) {
hdr = col * u_linearScale;
} else {
hdr = srgb_to_linear(col);
}
hdr = max(hdr, vec3(0.0));
vec3 exposed = hdr * exp2(u_exposure);
float luma = dot(exposed, vec3(0.2126, 0.7152, 0.0722));
vec3 saturated = max(mix(vec3(luma), exposed, u_saturation), vec3(0.0));
vec3 tm = (u_space == 2) ? clamp(saturated, 0.0, 1.0) : saturated / (1.0 + saturated);
fragColor = vec4(linear_to_srgb(tm), 1.0);
}`;
function compileShader(gl, type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(sh) || "shader compile failed";
gl.deleteShader(sh);
throw new Error(log);
}
return sh;
}
function createProgram(gl, vsSrc, fsSrc) {
const vs = compileShader(gl, gl.VERTEX_SHADER, vsSrc);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSrc);
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(prog) || "program link failed";
gl.deleteProgram(prog);
throw new Error(log);
}
gl.deleteShader(vs);
gl.deleteShader(fs);
return prog;
}
function loadImageBitmapFromView(filename, type) {
return fetch(`/view?filename=${encodeURIComponent(filename)}&type=${type}`)
.then(r => r.ok ? r.blob() : null)
.then(blob => blob ? createImageBitmap(blob, { colorSpaceConversion: "none", premultiplyAlpha: "none" }) : null)
.catch(() => null);
}
app.registerExtension({
name: "KJNodes.HDRPreview",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== "HDRPreviewKJ") return;
chainCallback(nodeType.prototype, "onNodeCreated", function () {
const node = this;
const state = {
frames: [],
frameCount: 0,
width: 512, height: 512,
fps: 24,
inputSpace: "logc3",
linearScale: 1.0,
exposure: 0.0,
saturation: 1.0,
currentFrame: 0,
playing: false,
playTimer: null,
gl: null, program: null, texture: null, vao: null,
uniforms: {},
uploadedFrame: -1,
needsRender: false,
execGen: 0,
};
node._hdrState = state;
let controlsHeight = 28; // measured from DOM after mount; this is the fallback
node._widgetHeight = controlsHeight;
const container = document.createElement("div");
container.style.cssText =
"position:relative;width:100%;background:#111;display:flex;flex-direction:column;align-items:center;overflow:hidden;";
const viewport = document.createElement("div");
// flex-shrink:0 — legacy container can be shorter than this height; without it the viewport collapses to 0.
viewport.style.cssText =
"position:relative;width:100%;height:0;background:#000;overflow:hidden;flex-shrink:0;";
const canvas = document.createElement("canvas");
canvas.style.cssText =
"display:block;width:100%;height:100%;";
viewport.appendChild(canvas);
const frameRow = document.createElement("div");
frameRow.style.cssText =
"display:flex;align-items:center;gap:6px;padding:3px 6px;color:#ccc;font-size:11px;background:#1a1a1a;width:100%;box-sizing:border-box;flex:0 0 auto;";
const playBtn = document.createElement("button");
playBtn.type = "button";
playBtn.textContent = "▶";
playBtn.style.cssText =
"background:#333;color:#ccc;border:1px solid #555;cursor:pointer;padding:1px 8px;font-size:11px;min-width:24px;";
const syncBtn = document.createElement("button");
syncBtn.type = "button";
syncBtn.textContent = "⛓";
syncBtn.title = "Sync playback with other HDR Preview nodes that have sync enabled.";
syncBtn.style.cssText =
"background:#333;color:#888;border:1px solid #555;cursor:pointer;padding:1px 6px;font-size:11px;min-width:22px;";
const frameSlider = document.createElement("input");
frameSlider.type = "range";
frameSlider.min = "0";
frameSlider.max = "0";
frameSlider.value = "0";
frameSlider.step = "1";
frameSlider.style.cssText = "flex:1;min-width:40px;accent-color:#5af;";
const frameLabel = document.createElement("span");
frameLabel.textContent = "0/0";
frameLabel.style.cssText = "min-width:50px;text-align:right;font-variant-numeric:tabular-nums;";
frameRow.appendChild(playBtn);
frameRow.appendChild(syncBtn);
frameRow.appendChild(frameSlider);
frameRow.appendChild(frameLabel);
container.appendChild(viewport);
container.appendChild(frameRow);
const stopProp = (e) => e.stopPropagation();
for (const el of [frameSlider, playBtn, syncBtn]) {
el.addEventListener("pointerdown", stopProp);
el.addEventListener("mousedown", stopProp);
}
addMiddleClickPan(container);
addWheelPassthrough(canvas);
function initGL() {
try {
const gl = canvas.getContext("webgl2", { antialias: false, premultipliedAlpha: false, alpha: false });
if (!gl) {
console.error("[HDRPreviewKJ] WebGL2 not available");
return false;
}
const program = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER);
gl.useProgram(program);
const uniforms = {
u_image: gl.getUniformLocation(program, "u_image"),
u_exposure: gl.getUniformLocation(program, "u_exposure"),
u_saturation: gl.getUniformLocation(program, "u_saturation"),
u_space: gl.getUniformLocation(program, "u_space"),
u_linearScale: gl.getUniformLocation(program, "u_linearScale"),
};
gl.uniform1i(uniforms.u_image, 0);
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
state.gl = gl;
state.program = program;
state.texture = texture;
state.vao = vao;
state.uniforms = uniforms;
return true;
} catch (err) {
console.error("[HDRPreviewKJ] WebGL init failed:", err);
return false;
}
}
function requestRender() {
if (state.needsRender) return;
state.needsRender = true;
requestAnimationFrame(() => {
state.needsRender = false;
render();
});
}
function render() {
if (!state.gl || !state.frames.length) return;
const frame = state.frames[state.currentFrame];
if (!frame) return;
const gl = state.gl;
// Backing buffer sized to source resolution for quality; CSS handles display scaling
if (canvas.width !== state.width || canvas.height !== state.height) {
canvas.width = state.width;
canvas.height = state.height;
}
gl.viewport(0, 0, canvas.width, canvas.height);
gl.useProgram(state.program);
gl.bindVertexArray(state.vao);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, state.texture);
if (state.uploadedFrame !== state.currentFrame) {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB8, gl.RGB, gl.UNSIGNED_BYTE, frame);
state.uploadedFrame = state.currentFrame;
}
gl.uniform1f(state.uniforms.u_exposure, state.exposure);
gl.uniform1f(state.uniforms.u_saturation, state.saturation);
const spaceIdx = state.inputSpace === "logc3" ? 0 : state.inputSpace === "srgb" ? 2 : 1;
gl.uniform1i(state.uniforms.u_space, spaceIdx);
gl.uniform1f(state.uniforms.u_linearScale, state.linearScale);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
function stopPlayback() {
if (state.playTimer !== null) {
clearInterval(state.playTimer);
state.playTimer = null;
}
state.playing = false;
playBtn.textContent = "▶";
}
function updateFrameLabel() {
const total = Math.max(state.frameCount, 1);
frameLabel.textContent = `${state.currentFrame + 1}/${total}`;
}
const exposureWidget = node.widgets?.find(w => w.name === "exposure");
const saturationWidget = node.widgets?.find(w => w.name === "saturation");
function hookLiveWidget(widget, stateKey) {
if (!widget) return;
state[stateKey] = isFinite(widget.value) ? widget.value : state[stateKey];
const original = widget.callback;
widget.callback = function (value) {
const num = isFinite(value) ? value : state[stateKey];
state[stateKey] = num;
requestRender();
return original?.apply(this, arguments);
};
}
hookLiveWidget(exposureWidget, "exposure");
hookLiveWidget(saturationWidget, "saturation");
// Sync handle: other nodes in the group call these to follow along.
let syncEnabled = false;
const syncHandle = {
setFrameFraction(fraction) {
if (state.frameCount <= 0) return;
const maxIdx = Math.max(state.frameCount - 1, 0);
const frame = Math.min(maxIdx, Math.max(0, Math.round(fraction * maxIdx)));
if (frame === state.currentFrame) return;
state.currentFrame = frame;
frameSlider.value = String(frame);
updateFrameLabel();
requestRender();
},
showPlaying(play) {
state.playing = play;
playBtn.textContent = play ? "■" : "▶";
if (!play && state.playTimer !== null) {
clearInterval(state.playTimer);
state.playTimer = null;
}
},
};
function broadcastFraction() {
if (!syncEnabled || state.frameCount <= 1) return;
const fraction = state.currentFrame / (state.frameCount - 1);
for (const other of hdrSyncGroup) {
if (other !== syncHandle) other.setFrameFraction(fraction);
}
}
function broadcastPlaying(play) {
if (!syncEnabled) return;
for (const other of hdrSyncGroup) {
if (other !== syncHandle) other.showPlaying(play);
}
}
syncBtn.addEventListener("click", () => {
syncEnabled = !syncEnabled;
syncBtn.style.color = syncEnabled ? "#5fa" : "#888";
syncBtn.style.borderColor = syncEnabled ? "#5fa" : "#555";
if (syncEnabled) {
hdrSyncGroup.add(syncHandle);
} else {
hdrSyncGroup.delete(syncHandle);
}
});
frameSlider.addEventListener("input", () => {
state.currentFrame = parseInt(frameSlider.value, 10) || 0;
updateFrameLabel();
requestRender();
broadcastFraction();
});
frameSlider.addEventListener("pointerdown", () => {
stopPlayback();
broadcastPlaying(false);
});
function persistCurrentFrame() {
if (!node.properties?.hdrLastPreview) return;
node.properties.hdrLastPreview.current_frame = state.currentFrame;
node.graph?.change?.();
try { app.extensionManager?.workflow?.activeWorkflow?.changeTracker?.checkState?.(); } catch {}
}
frameSlider.addEventListener("change", persistCurrentFrame);
playBtn.addEventListener("click", () => {
if (state.playing) {
stopPlayback();
broadcastPlaying(false);
persistCurrentFrame();
return;
}
if (state.frameCount <= 1) return;
// Ensure no other synced node has an active timer — we become the sole driver.
if (syncEnabled) {
for (const other of hdrSyncGroup) {
if (other !== syncHandle) other.showPlaying(false);
}
}
const intervalMs = Math.max(16, 1000 / Math.max(state.fps, 1));
state.playing = true;
playBtn.textContent = "■";
state.playTimer = setInterval(() => {
state.currentFrame = (state.currentFrame + 1) % state.frameCount;
frameSlider.value = String(state.currentFrame);
updateFrameLabel();
requestRender();
broadcastFraction();
}, intervalMs);
broadcastPlaying(true);
});
const domWidget = node.addDOMWidget("hdr_preview", "hdr_preview", container, {
serialize: false,
hideOnZoom: false,
margin: 0,
getMinHeight: () => node._widgetHeight,
getMaxHeight: () => node._widgetHeight,
getHeight: () => node._widgetHeight,
});
node.resizable = true;
// LiteGraph's computeSize uses a fixed per-widget height that ignores DOM widgets — sum manually.
const NATIVE_ROW_H = (LiteGraph?.NODE_WIDGET_HEIGHT ?? 20) + 4;
const TITLE_H = LiteGraph?.NODE_TITLE_HEIGHT ?? 30;
function computeNodeHeight() {
let nativeCount = 0;
for (const w of node.widgets || []) {
if (w === domWidget) continue;
if (w.hidden || w.type === "converted-widget") continue;
nativeCount++;
}
return TITLE_H + nativeCount * NATIVE_ROW_H + node._widgetHeight + 8;
}
// Don't gate on state.frames — a fire during async bitmap load would wipe _widgetHeight and collapse the node.
const controlsObserver = new ResizeObserver(() => {
const measured = frameRow.offsetHeight;
if (measured <= 0 || measured === controlsHeight) return;
controlsHeight = measured;
const viewportPx = parseInt(viewport.style.height, 10) || 0;
node._widgetHeight = viewportPx + controlsHeight;
node.setSize([node.size[0], computeNodeHeight()]);
node.graph?.setDirtyCanvas(true, true);
});
controlsObserver.observe(frameRow);
let resizing = false;
function resizeToFit() {
if (resizing) return;
resizing = true;
try {
const srcW = state.width || 512, srcH = state.height || 512;
const availW = Math.max(100, node.size[0] - 30);
const ratio = srcH / srcW;
const displayH = Math.round(availW * ratio);
const totalH = displayH + controlsHeight;
viewport.style.width = availW + "px";
viewport.style.height = displayH + "px";
// min-height keeps children visible when ComfyUI sets the container height from a stale computeSize.
container.style.minHeight = totalH + "px";
node._widgetHeight = totalH;
node.setSize([node.size[0], computeNodeHeight()]);
node.graph?.setDirtyCanvas(true, true);
} finally {
resizing = false;
}
}
chainCallback(node, "onResize", function () {
if (state.frames.length) resizeToFit();
});
async function applyPreviewData(data) {
const gen = ++state.execGen;
stopPlayback();
for (const f of state.frames) {
try { f.close?.(); } catch {}
}
state.frames = [];
state.uploadedFrame = -1;
state.frameCount = data.frame_count || 0;
state.width = data.width || 512;
state.height = data.height || 512;
state.fps = data.fps || 24;
state.inputSpace = data.input_space || "logc3";
state.linearScale = data.linear_scale || 1.0;
const restoredFrame = Number.isInteger(data.current_frame) ? data.current_frame : 0;
state.currentFrame = Math.min(Math.max(0, restoredFrame), Math.max(0, state.frameCount - 1));
frameSlider.max = String(Math.max(0, state.frameCount - 1));
frameSlider.value = String(state.currentFrame);
updateFrameLabel();
if (!state.gl) initGL();
resizeToFit();
const bitmaps = await Promise.all(
(data.frames || []).map(f => loadImageBitmapFromView(f.filename, f.type))
);
if (gen !== state.execGen) {
for (const b of bitmaps) {
try { b?.close?.(); } catch {}
}
return;
}
state.frames = bitmaps.filter(Boolean);
// Re-assert size in case the ResizeObserver fired mid-await with empty frames.
resizeToFit();
requestRender();
}
chainCallback(node, "onExecuted", async function (message) {
const data = message?.hdr_preview_data?.[0];
if (!data) return;
node.properties = node.properties || {};
node.properties.hdrLastPreview = {
frames: data.frames,
width: data.width,
height: data.height,
fps: data.fps,
input_space: data.input_space,
linear_scale: data.linear_scale,
frame_count: data.frame_count,
};
// Autosave snapshots before execution, so post-execute property updates need an explicit dirty nudge.
node.graph?.change?.();
try {
const ct = app.extensionManager?.workflow?.activeWorkflow?.changeTracker;
ct?.checkState?.();
} catch {}
try { app.workflowManager?.activeWorkflow?.changeTracker?.checkState?.(); } catch {}
await applyPreviewData(data);
});
chainCallback(node, "onConfigure", function () {
if (exposureWidget && isFinite(exposureWidget.value)) state.exposure = exposureWidget.value;
if (saturationWidget && isFinite(saturationWidget.value)) state.saturation = saturationWidget.value;
const saved = node.properties?.hdrLastPreview;
if (saved?.frames?.length) applyPreviewData(saved);
});
chainCallback(node, "onRemoved", function () {
stopPlayback();
controlsObserver.disconnect();
hdrSyncGroup.delete(syncHandle);
for (const f of state.frames) {
try { f.close?.(); } catch {}
}
state.frames = [];
if (state.gl) {
try {
state.gl.deleteProgram(state.program);
state.gl.deleteTexture(state.texture);
state.gl.deleteVertexArray(state.vao);
const lose = state.gl.getExtension("WEBGL_lose_context");
if (lose) lose.loseContext();
} catch {}
state.gl = null;
}
});
});
},
});
+192 -337
View File
@@ -1,4 +1,4 @@
const { app } = window.comfyAPI.app;
import { app } from "../../../scripts/app.js";
// code based on mtb nodes by Mel Massadian https://github.com/melMass/comfy_mtb/
export const loadScript = (
@@ -38,33 +38,24 @@ export const loadScript = (
})
}
// Only load the marked/DOMPurify fallback when the native renderer is absent.
if (!app.extensionManager?.renderMarkdownToHtml) {
loadScript('kjweb_async/marked.min.js').catch((e) => {
console.error(e)
})
loadScript('kjweb_async/purify.min.js').catch((e) => {
console.error(e)
})
}
loadScript('kjweb_async/marked.min.js').catch((e) => {
console.log(e)
})
loadScript('kjweb_async/purify.min.js').catch((e) => {
console.log(e)
})
const categories = ["KJNodes", "SUPIR", "VoiceCraft", "Marigold", "IC-Light", "WanVideoWrapper"];
const nodeDescriptions = new Map();
function isHelpPopupEnabled() {
return app.ui.settings.getSettingValue("KJNodes.helpPopup") !== false;
}
app.registerExtension({
name: "KJNodes.HelpPopup",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!isHelpPopupEnabled()) return;
if (app.ui.settings.getSettingValue("KJNodes.helpPopup") === false) {
return;
}
try {
categories.forEach(category => {
if (nodeData?.category?.startsWith(category)) {
if (nodeData.description) {
nodeDescriptions.set(nodeData.name, nodeData.description);
}
addDocumentation(nodeData, nodeType);
}
else return
@@ -73,22 +64,12 @@ app.registerExtension({
console.error("Error in registering KJNodes.HelpPopup", error);
}
},
nodeCreated(node) {
if (!isHelpPopupEnabled()) return;
const description = nodeDescriptions.get(node.type) || nodeDescriptions.get(node.comfyClass);
if (!description) return;
node._kjHelpDescription = description;
},
setup() {
if (!isHelpPopupEnabled()) return;
setupHelpObserver();
},
});
const create_documentation_stylesheet = () => {
const tag = 'kj-documentation-stylesheet'
let styleTag = document.getElementById(tag)
let styleTag = document.head.querySelector(tag)
if (!styleTag) {
styleTag = document.createElement('style')
@@ -124,7 +105,7 @@ const create_documentation_stylesheet = () => {
border-radius: 6px;
border: 3px solid var(--bg-color);
}
/* Scrollbar styling for Firefox */
scrollbar-width: thin;
scrollbar-color: var(--fg-color) var(--bg-color);
@@ -143,329 +124,203 @@ const create_documentation_stylesheet = () => {
}
}
/**
* Creates the documentation popup DOM and wires up resize/close interactions.
* Returns { docElement, contentWrapper }.
* @param {string} description - Markdown description text
* @param {AbortSignal} signal - Signal to clean up event listeners
* @param {() => void} onClose - Called when the close button is clicked
* @param {{ scaleResize: boolean }} opts - If scaleResize is true, resize deltas are divided by canvas scale
*/
function createDocPopup(description, signal, onClose, opts = {}) {
create_documentation_stylesheet()
/** Add documentation widget to the selected node */
export const addDocumentation = (
nodeData,
nodeType,
opts = { icon_size: 14, icon_margin: 4 },) => {
const docElement = document.createElement('div')
const contentWrapper = document.createElement('div')
docElement.appendChild(contentWrapper)
opts = opts || {}
const iconSize = opts.icon_size ? opts.icon_size : 14
const iconMargin = opts.icon_margin ? opts.icon_margin : 4
let docElement = null
let contentWrapper = null
//if no description in the node python code, don't do anything
if (!nodeData.description) {
return
}
contentWrapper.classList.add('content-wrapper')
docElement.classList.add('kj-documentation-popup')
// Try ComfyUI's built-in markdown renderer first (available after frontend PR #10700)
if (app.extensionManager?.renderMarkdownToHtml) {
contentWrapper.innerHTML = app.extensionManager.renderMarkdownToHtml(description)
} else if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
contentWrapper.innerHTML = DOMPurify.sanitize(marked.parse(description))
} else {
// Fallback: convert markdown links to <a> tags, auto-link bare URLs, preserve line breaks
const escaped = description
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
.replace(/(^|[^"'])(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank">$2</a>')
.replace(/\n/g, '<br>')
contentWrapper.innerHTML = escaped
}
const drawFg = nodeType.prototype.onDrawForeground
nodeType.prototype.onDrawForeground = function (ctx) {
const r = drawFg ? drawFg.apply(this, arguments) : undefined
if (this.flags.collapsed) return r
// resize handle
const resizeHandle = document.createElement('div')
resizeHandle.style.width = '0'
resizeHandle.style.height = '0'
resizeHandle.style.position = 'absolute'
resizeHandle.style.bottom = '0'
resizeHandle.style.right = '0'
resizeHandle.style.cursor = 'se-resize'
const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border-color').trim()
resizeHandle.style.borderTop = '10px solid transparent'
resizeHandle.style.borderLeft = '10px solid transparent'
resizeHandle.style.borderBottom = `10px solid ${borderColor}`
resizeHandle.style.borderRight = `10px solid ${borderColor}`
docElement.appendChild(resizeHandle)
// icon position
const x = this.size[0] - iconSize - iconMargin
// create the popup
if (this.show_doc && docElement === null) {
docElement = document.createElement('div')
contentWrapper = document.createElement('div');
docElement.appendChild(contentWrapper);
let isResizing = false
let startX, startY, startWidth, startHeight
resizeHandle.addEventListener('mousedown', (e) => {
e.preventDefault()
e.stopPropagation()
isResizing = true
startX = e.clientX
startY = e.clientY
startWidth = parseInt(document.defaultView.getComputedStyle(docElement).width, 10)
startHeight = parseInt(document.defaultView.getComputedStyle(docElement).height, 10)
}, { signal })
create_documentation_stylesheet()
contentWrapper.classList.add('content-wrapper');
docElement.classList.add('kj-documentation-popup')
//parse the string from the python node code to html with marked, and sanitize the html with DOMPurify
contentWrapper.innerHTML = DOMPurify.sanitize(marked.parse(nodeData.description,))
document.addEventListener('mousemove', (e) => {
if (!isResizing) return
const scaleFactor = opts.scaleResize ? app.canvas.ds.scale : 1
const newWidth = startWidth + (e.clientX - startX) / scaleFactor
const newHeight = startHeight + (e.clientY - startY) / scaleFactor
docElement.style.width = `${newWidth}px`
docElement.style.height = `${newHeight}px`
}, { signal })
// resize handle
const resizeHandle = document.createElement('div');
resizeHandle.style.width = '0';
resizeHandle.style.height = '0';
resizeHandle.style.position = 'absolute';
resizeHandle.style.bottom = '0';
resizeHandle.style.right = '0';
resizeHandle.style.cursor = 'se-resize';
// Add pseudo-elements to create a triangle shape
const borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border-color').trim();
resizeHandle.style.borderTop = '10px solid transparent';
resizeHandle.style.borderLeft = '10px solid transparent';
resizeHandle.style.borderBottom = `10px solid ${borderColor}`;
resizeHandle.style.borderRight = `10px solid ${borderColor}`;
document.addEventListener('mouseup', () => {
isResizing = false
}, { signal })
docElement.appendChild(resizeHandle)
let isResizing = false
let startX, startY, startWidth, startHeight
// close button
const closeButton = document.createElement('div')
closeButton.textContent = '❌'
closeButton.style.position = 'absolute'
closeButton.style.top = '0'
closeButton.style.right = '0'
closeButton.style.cursor = 'pointer'
closeButton.style.padding = '5px'
closeButton.style.color = 'red'
closeButton.style.fontSize = '12px'
docElement.appendChild(closeButton)
resizeHandle.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
startX = e.clientX;
startY = e.clientY;
startWidth = parseInt(document.defaultView.getComputedStyle(docElement).width, 10);
startHeight = parseInt(document.defaultView.getComputedStyle(docElement).height, 10);
},
{ signal: this.docCtrl.signal },
);
closeButton.addEventListener('mousedown', (e) => {
e.stopPropagation()
onClose()
}, { signal })
// close button
const closeButton = document.createElement('div');
closeButton.textContent = '❌';
closeButton.style.position = 'absolute';
closeButton.style.top = '0';
closeButton.style.right = '0';
closeButton.style.cursor = 'pointer';
closeButton.style.padding = '5px';
closeButton.style.color = 'red';
closeButton.style.fontSize = '12px';
document.body.appendChild(docElement)
return { docElement, contentWrapper }
}
docElement.appendChild(closeButton)
// ─── Legacy canvas mode (onDrawForeground) ───
/** Add documentation widget to the selected node (legacy canvas rendering) */
export const addDocumentation = (
nodeData,
nodeType,
opts = { icon_size: 14, icon_margin: 4 },) => {
opts = opts || {}
const iconSize = opts.icon_size ? opts.icon_size : 14
const iconMargin = opts.icon_margin ? opts.icon_margin : 4
let docElement = null
let contentWrapper = null
if (!nodeData.description) {
return
}
const drawFg = nodeType.prototype.onDrawForeground
nodeType.prototype.onDrawForeground = function (ctx) {
const r = drawFg ? drawFg.apply(this, arguments) : undefined
if (this.flags.collapsed) return r
const x = this.size[0] - iconSize - iconMargin
// create the popup
if (this.show_doc && docElement === null) {
const popup = createDocPopup(
nodeData.description,
this.docCtrl.signal,
() => {
closeButton.addEventListener('mousedown', (e) => {
e.stopPropagation();
this.show_doc = !this.show_doc
docElement.parentNode.removeChild(docElement)
docElement = null
contentWrapper = null
if (contentWrapper) {
contentWrapper.remove()
contentWrapper = null
}
},
{ signal: this.docCtrl.signal },
);
document.addEventListener('mousemove', function (e) {
if (!isResizing) return;
const scale = app.canvas.ds.scale;
const newWidth = startWidth + (e.clientX - startX) / scale;
const newHeight = startHeight + (e.clientY - startY) / scale;;
docElement.style.width = `${newWidth}px`;
docElement.style.height = `${newHeight}px`;
},
{ signal: this.docCtrl.signal },
);
document.addEventListener('mouseup', function () {
isResizing = false
},
{ scaleResize: true }
)
docElement = popup.docElement
contentWrapper = popup.contentWrapper
}
// close the popup
else if (!this.show_doc && docElement !== null) {
docElement.parentNode.removeChild(docElement)
docElement = null
}
// update position of the popup
if (this.show_doc && docElement !== null) {
const rect = ctx.canvas.getBoundingClientRect()
const scaleX = rect.width / ctx.canvas.width
const scaleY = rect.height / ctx.canvas.height
{ signal: this.docCtrl.signal },
)
const transform = new DOMMatrix()
.scaleSelf(scaleX, scaleY)
.multiplySelf(ctx.getTransform())
.translateSelf(this.size[0] * scaleX * Math.max(1.0,window.devicePixelRatio) , 0)
.translateSelf(10, -32)
const scale = new DOMMatrix()
.scaleSelf(transform.a, transform.d);
const bcr = app.canvas.canvas.getBoundingClientRect()
const styleObject = {
transformOrigin: '0 0',
transform: scale,
left: `${transform.a + bcr.x + transform.e}px`,
top: `${transform.d + bcr.y + transform.f}px`,
};
Object.assign(docElement.style, styleObject);
}
ctx.save()
ctx.translate(x - 2, iconSize - 34)
ctx.scale(iconSize / 32, iconSize / 32)
ctx.strokeStyle = 'rgba(255,255,255,0.3)'
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.lineWidth = 2.4
ctx.font = 'bold 36px monospace'
ctx.fillStyle = 'orange';
ctx.fillText('?', 0, 24)
ctx.restore()
return r
}
// handle clicking of the icon
const mouseDown = nodeType.prototype.onMouseDown
nodeType.prototype.onMouseDown = function (e, localPos, canvas) {
const r = mouseDown ? mouseDown.apply(this, arguments) : undefined
const iconX = this.size[0] - iconSize - iconMargin
const iconY = iconSize - 34
if (
localPos[0] > iconX &&
localPos[0] < iconX + iconSize &&
localPos[1] > iconY &&
localPos[1] < iconY + iconSize
) {
if (this.show_doc === undefined) {
this.show_doc = true
} else {
this.show_doc = !this.show_doc
document.body.appendChild(docElement)
}
if (this.show_doc) {
this.docCtrl = new AbortController()
} else {
this.docCtrl.abort()
// close the popup
else if (!this.show_doc && docElement !== null) {
docElement.parentNode.removeChild(docElement)
docElement = null
}
return true;
// update position of the popup
if (this.show_doc && docElement !== null) {
const rect = ctx.canvas.getBoundingClientRect()
const scaleX = rect.width / ctx.canvas.width
const scaleY = rect.height / ctx.canvas.height
const transform = new DOMMatrix()
.scaleSelf(scaleX, scaleY)
.multiplySelf(ctx.getTransform())
.translateSelf(this.size[0] * scaleX * Math.max(1.0,window.devicePixelRatio) , 0)
.translateSelf(10, -32)
const scale = new DOMMatrix()
.scaleSelf(transform.a, transform.d);
const bcr = app.canvas.canvas.getBoundingClientRect()
const styleObject = {
transformOrigin: '0 0',
transform: scale,
left: `${transform.a + bcr.x + transform.e}px`,
top: `${transform.d + bcr.y + transform.f}px`,
};
Object.assign(docElement.style, styleObject);
}
ctx.save()
ctx.translate(x - 2, iconSize - 34)
ctx.scale(iconSize / 32, iconSize / 32)
ctx.strokeStyle = 'rgba(255,255,255,0.3)'
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.lineWidth = 2.4
ctx.font = 'bold 36px monospace'
ctx.fillStyle = 'orange';
ctx.fillText('?', 0, 24)
ctx.restore()
return r
}
return r;
}
const onRem = nodeType.prototype.onRemoved
nodeType.prototype.onRemoved = function () {
const r = onRem ? onRem.apply(this, []) : undefined
if (docElement) {
docElement.remove()
docElement = null
contentWrapper = null
// handle clicking of the icon
const mouseDown = nodeType.prototype.onMouseDown
nodeType.prototype.onMouseDown = function (e, localPos, canvas) {
const r = mouseDown ? mouseDown.apply(this, arguments) : undefined
const iconX = this.size[0] - iconSize - iconMargin
const iconY = iconSize - 34
if (
localPos[0] > iconX &&
localPos[0] < iconX + iconSize &&
localPos[1] > iconY &&
localPos[1] < iconY + iconSize
) {
if (this.show_doc === undefined) {
this.show_doc = true
} else {
this.show_doc = !this.show_doc
}
if (this.show_doc) {
this.docCtrl = new AbortController()
} else {
this.docCtrl.abort()
}
return true;
}
return r;
}
return r
}
}
const onRem = nodeType.prototype.onRemoved
// ─── Vue nodes mode (DOM injection via MutationObserver) ───
/** Per-node popup state, keyed by node ID */
const popupState = new Map()
function getNodeById(nodeId) {
return app.graph?.getNodeById(nodeId)
}
function closeNodePopup(nodeId) {
const state = popupState.get(nodeId)
if (!state) return
if (state.docElement) {
state.docElement.remove()
}
if (state.abortCtrl) {
state.abortCtrl.abort()
}
if (state.animFrame) {
cancelAnimationFrame(state.animFrame)
}
popupState.delete(nodeId)
}
function openNodePopup(nodeId, description) {
closeNodePopup(nodeId)
const state = popupState.get(nodeId) || {}
popupState.set(nodeId, state)
state.abortCtrl = new AbortController()
const popup = createDocPopup(
description,
state.abortCtrl.signal,
() => closeNodePopup(nodeId),
{ scaleResize: false }
)
state.docElement = popup.docElement
function updatePosition() {
if (!state.docElement || !state.docElement.parentNode) return
const nodeEl = document.querySelector(`[data-node-id="${nodeId}"]`)
if (nodeEl) {
const rect = nodeEl.getBoundingClientRect()
state.docElement.style.left = `${rect.right + 10}px`
state.docElement.style.top = `${rect.top}px`
nodeType.prototype.onRemoved = function () {
const r = onRem ? onRem.apply(this, []) : undefined
if (docElement) {
docElement.remove()
docElement = null
}
if (contentWrapper) {
contentWrapper.remove()
contentWrapper = null
}
return r
}
state.animFrame = requestAnimationFrame(updatePosition)
}
state.animFrame = requestAnimationFrame(updatePosition)
}
/** Try to inject a "?" button into a Vue node header */
function tryInjectHelpButton(header) {
if (header.querySelector('.kj-help-btn')) return
const nodeEl = header.closest('[data-node-id]')
if (!nodeEl) return
const nodeId = nodeEl.dataset.nodeId
const node = getNodeById(nodeId)
if (!node) return
const description = node._kjHelpDescription
if (!description) return
const flexContainer = header.querySelector(':scope > div')
if (!flexContainer) return
const helpBtn = document.createElement('span')
helpBtn.className = 'kj-help-btn'
helpBtn.textContent = '?'
helpBtn.style.cssText = `
color: orange;
font-weight: bold;
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
padding: 0 4px;
line-height: 1;
user-select: none;
`
helpBtn.title = 'Show help'
helpBtn.addEventListener('click', (e) => {
e.stopPropagation()
const state = popupState.get(nodeId)
if (state?.docElement) {
closeNodePopup(nodeId)
} else {
openNodePopup(nodeId, description)
}
})
flexContainer.appendChild(helpBtn)
}
/** Observe the DOM for Vue node headers appearing and inject help buttons */
function setupHelpObserver() {
// Inject into any headers already in the DOM
document.querySelectorAll('.lg-node-header').forEach(tryInjectHelpButton)
let pending = false
const observer = new MutationObserver(() => {
if (pending) return
pending = true
requestAnimationFrame(() => {
pending = false
document.querySelectorAll('.lg-node-header:not(:has(.kj-help-btn))').forEach(tryInjectHelpButton)
})
})
observer.observe(document.body, { childList: true, subtree: true })
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+105 -57
View File
@@ -1,36 +1,5 @@
const { app } = window.comfyAPI.app;
const { applyTextReplacements } = window.comfyAPI.utils;
// ─── Dynamic input slot management for *Multi nodes ───
// Adds an "Update inputs" button that rebuilds prefix_1..prefix_N slots to match the count widget.
// Also wired to the count callback so API-format reload (bare callback) restores the slots; the
// canvas arg from interactive edits is the tell, so scrubbing the count doesn't reflow the node.
function setupDynamicInputs(node, { type, prefix, countWidget = "inputcount", slotOptions } = {}) {
const rebuild = () => {
if (!node.inputs) node.inputs = [];
const countW = node.widgets?.find(w => w.name === countWidget);
if (!countW) return;
const target = countW.value;
const current = node.inputs.filter(i => i.name?.startsWith(prefix)).length;
if (target === current) return;
if (target < current) {
for (let i = 0; i < current - target; i++) node.removeInput(node.inputs.length - 1);
} else {
for (let i = current + 1; i <= target; i++) node.addInput(`${prefix}${i}`, type, slotOptions);
}
};
node.addWidget("button", "Update inputs", null, rebuild);
const countW = node.widgets?.find(w => w.name === countWidget);
if (countW) {
const origCb = countW.callback; // guard: may be nullish
countW.callback = function (value, canvas) {
const r = origCb ? origCb.apply(this, arguments) : undefined;
if (!canvas) rebuild(); // bare = API reload; skip interactive scrub
return r;
};
}
return rebuild;
}
import { app } from "../../../scripts/app.js";
import { applyTextReplacements } from "../../../scripts/utils.js";
app.registerExtension({
name: "KJNodes.jsnodes",
@@ -41,34 +10,86 @@ app.registerExtension({
switch (nodeData.name) {
case "ConditioningMultiCombine":
nodeType.prototype.onNodeCreated = function () {
this.inputs_offset = nodeData.name.includes("selective")?1:0
setupDynamicInputs(this, { type: "CONDITIONING", prefix: "conditioning_" });
this._type = "CONDITIONING"
this.inputs_offset = nodeData.name.includes("selective")?1:0
this.addWidget("button", "Update inputs", null, () => {
if (!this.inputs) {
this.inputs = [];
}
const target_number_of_inputs = this.widgets.find(w => w.name === "inputcount")["value"];
const num_inputs = this.inputs.filter(input => input.type === this._type).length
if(target_number_of_inputs===num_inputs)return; // already set, do nothing
if(target_number_of_inputs < num_inputs){
const inputs_to_remove = num_inputs - target_number_of_inputs;
for(let i = 0; i < inputs_to_remove; i++) {
this.removeInput(this.inputs.length - 1);
}
}
else{
for(let i = num_inputs+1; i <= target_number_of_inputs; ++i)
this.addInput(`conditioning_${i}`, this._type)
}
});
}
break;
case "ImageBatchMulti":
case "ImageAddMulti":
case "ImageConcatMulti":
case "CrossFadeImagesMulti":
case "TransitionImagesMulti":
nodeType.prototype.onNodeCreated = function () {
setupDynamicInputs(this, { type: "IMAGE", prefix: "image_", slotOptions: {shape: 7} });
}
break;
case "ImageConcatMulti":
// Dynamic slots accept MASK too; name-prefix counting handles the mixed types.
nodeType.prototype.onNodeCreated = function () {
setupDynamicInputs(this, { type: "IMAGE,MASK", prefix: "image_", slotOptions: {shape: 7} });
this._type = "IMAGE"
this.addWidget("button", "Update inputs", null, () => {
if (!this.inputs) {
this.inputs = [];
}
const target_number_of_inputs = this.widgets.find(w => w.name === "inputcount")["value"];
const num_inputs = this.inputs.filter(input => input.type === this._type).length
if(target_number_of_inputs===num_inputs)return; // already set, do nothing
if(target_number_of_inputs < num_inputs){
const inputs_to_remove = num_inputs - target_number_of_inputs;
for(let i = 0; i < inputs_to_remove; i++) {
this.removeInput(this.inputs.length - 1);
}
}
else{
for(let i = num_inputs+1; i <= target_number_of_inputs; ++i)
this.addInput(`image_${i}`, this._type, {shape: 7});
}
});
}
break;
case "MaskBatchMulti":
nodeType.prototype.onNodeCreated = function () {
setupDynamicInputs(this, { type: "MASK", prefix: "mask_" });
}
break;
this._type = "MASK"
this.addWidget("button", "Update inputs", null, () => {
if (!this.inputs) {
this.inputs = [];
}
const target_number_of_inputs = this.widgets.find(w => w.name === "inputcount")["value"];
const num_inputs = this.inputs.filter(input => input.type === this._type).length
if(target_number_of_inputs===num_inputs)return; // already set, do nothing
if(target_number_of_inputs < num_inputs){
const inputs_to_remove = num_inputs - target_number_of_inputs;
for(let i = 0; i < inputs_to_remove; i++) {
this.removeInput(this.inputs.length - 1);
}
}
else{
for(let i = num_inputs+1; i <= target_number_of_inputs; ++i)
this.addInput(`mask_${i}`, this._type)
}
});
}
break;
case "FluxBlockLoraSelect":
case "HunyuanVideoBlockLoraSelect":
case "Wan21BlockLoraSelect":
case "LTX2BlockLoraSelect":
nodeType.prototype.onNodeCreated = function () {
this.addWidget("button", "Set all", null, () => {
const userInput = prompt("Enter the values to set for widgets (e.g., s0,1,2-7=2.0, d0,1,2-7=2.0, or 1.0):", "");
@@ -176,11 +197,9 @@ app.registerExtension({
console.log(this)
const v = onGetLatentConnectInput? onGetLatentConnectInput.apply(this, arguments): undefined
//console.log(this)
this.outputs[1]["label"] = "batch_size"
this.outputs[2]["label"] = "channels"
this.outputs[3]["label"] = "frames"
this.outputs[4]["label"] = "height"
this.outputs[5]["label"] = "width"
this.outputs[1]["label"] = "width"
this.outputs[2]["label"] = "height"
this.outputs[3]["label"] = "count"
return v;
}
//const onGetImageSizeExecuted = nodeType.prototype.onExecuted;
@@ -237,7 +256,27 @@ app.registerExtension({
const originalOnNodeCreated = nodeType.prototype.onNodeCreated || function() {};
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated.apply(this, arguments);
setupDynamicInputs(this, { type: "STRING", prefix: "string_", slotOptions: {shape: 7} });
this._type = "STRING";
this.addWidget("button", "Update inputs", null, () => {
if (!this.inputs) {
this.inputs = [];
}
const target_number_of_inputs = this.widgets.find(w => w.name === "inputcount")["value"];
const num_inputs = this.inputs.filter(input => input.name && input.name.toLowerCase().includes("string_")).length
if (target_number_of_inputs === num_inputs) return; // already set, do nothing
if(target_number_of_inputs < num_inputs){
const inputs_to_remove = num_inputs - target_number_of_inputs;
for(let i = 0; i < inputs_to_remove; i++) {
this.removeInput(this.inputs.length - 1);
}
}
else{
for(let i = num_inputs+1; i <= target_number_of_inputs; ++i)
this.addInput(`string_${i}`, this._type, {shape: 7});
}
});
}
break;
case "SoundReactive":
@@ -343,11 +382,6 @@ app.registerExtension({
};
break;
case "SaveImageKJ":
case "SaveImageWithAlpha":
case "SaveStringKJ":
case "DecodeAndSaveVideo":
case "ModelSaveKJ":
case "LoraExtractKJ":
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : void 0;
@@ -362,4 +396,18 @@ app.registerExtension({
}
},
async setup() {
// to keep Set/Get node virtual connections visible when offscreen
const originalComputeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes;
LGraphCanvas.prototype.computeVisibleNodes = function () {
const visibleNodesSet = new Set(originalComputeVisibleNodes.apply(this, arguments));
for (const node of this.graph._nodes) {
if ((node.type === "SetNode" || node.type === "GetNode") && node.drawConnection) {
visibleNodesSet.add(node);
}
}
return Array.from(visibleNodesSet);
};
}
});
-449
View File
@@ -1,449 +0,0 @@
const { app } = window.comfyAPI.app;
import { getSlotPos, clientToCanvas, getNodeAtPoint, typesCompatible, chainCallback } from "./utility.js";
// Max age of the last keydown for it to count as the command's trigger.
// Beyond this, treat the command as menu-fired (no release tracking).
const KEYDOWN_MAX_AGE_MS = 100;
const state = {
pointerDown: false,
hasMoved: false,
insertKeyDown: false,
activationKey: null,
draggedNode: null,
startPos: null,
insertTargetLink: null,
insertSlots: null,
insertOriginNode: null,
insertDestNode: null,
lastScanTime: 0,
animating: false,
};
let lastKeyDown = null;
let lastKeyDownTime = 0;
function bezierAt(p0, p1, p2, p3, t) {
const u = 1 - t;
const uu = u * u, uuu = uu * u;
const tt = t * t, ttt = tt * t;
return [
uuu * p0[0] + 3 * uu * t * p1[0] + 3 * u * tt * p2[0] + ttt * p3[0],
uuu * p0[1] + 3 * uu * t * p1[1] + 3 * u * tt * p2[1] + ttt * p3[1],
];
}
// Horizontal control-point offset for the link bezier; must match what
// LiteGraph draws so the hit-test region matches the visible curve.
const bezierOffsetX = (from, to) => Math.max(Math.abs(to[0] - from[0]) * 0.5, 50);
function findLinkUnderNode(graph, draggedNode) {
const bounds = draggedNode.getBounding?.();
const nodeX = bounds ? bounds[0] : draggedNode.pos[0];
const nodeY = bounds ? bounds[1] : draggedNode.pos[1];
const nodeW = bounds ? bounds[2] : (draggedNode.size[0] || 100);
const nodeH = bounds ? bounds[3] : (draggedNode.size[1] || 60);
const nodeCx = nodeX + nodeW / 2;
const nodeCy = nodeY + nodeH / 2;
let bestLink = null;
let bestDist = Infinity;
const links = graph.links;
if (!links) return null;
for (const link of links.values()) {
if (!link) continue;
if (link.origin_id === draggedNode.id || link.target_id === draggedNode.id) continue;
const originNode = graph.getNodeById(link.origin_id);
const targetNode = graph.getNodeById(link.target_id);
if (!originNode || !targetNode) continue;
// Reject on node bounds before resolving slot positions — the latter
// hits the DOM in Vue mode and is the expensive step.
const oW = originNode.size?.[0] || 100, oH = originNode.size?.[1] || 60;
const tW = targetNode.size?.[0] || 100, tH = targetNode.size?.[1] || 60;
const cMinX = Math.min(originNode.pos[0], targetNode.pos[0]);
const cMaxX = Math.max(originNode.pos[0] + oW, targetNode.pos[0] + tW);
const cMinY = Math.min(originNode.pos[1], targetNode.pos[1]) - 50;
const cMaxY = Math.max(originNode.pos[1] + oH, targetNode.pos[1] + tH) + 50;
if (cMaxX < nodeX || cMinX > nodeX + nodeW || cMaxY < nodeY || cMinY > nodeY + nodeH) continue;
const outPos = getSlotPos(originNode, false, link.origin_slot);
const inPos = getSlotPos(targetNode, true, link.target_slot);
const lMinX = Math.min(outPos[0], inPos[0]);
const lMaxX = Math.max(outPos[0], inPos[0]);
const lMinY = Math.min(outPos[1], inPos[1]) - 50; // bezier can sag
const lMaxY = Math.max(outPos[1], inPos[1]) + 50;
if (lMaxX < nodeX || lMinX > nodeX + nodeW || lMaxY < nodeY || lMinY > nodeY + nodeH) continue;
const offsetX = bezierOffsetX(outPos, inPos);
const p0 = outPos;
const p1 = [outPos[0] + offsetX, outPos[1]];
const p2 = [inPos[0] - offsetX, inPos[1]];
const p3 = inPos;
for (let i = 0; i <= 20; i++) {
const pt = bezierAt(p0, p1, p2, p3, i / 20);
if (pt[0] >= nodeX && pt[0] <= nodeX + nodeW && pt[1] >= nodeY && pt[1] <= nodeY + nodeH) {
const d = Math.hypot(pt[0] - nodeCx, pt[1] - nodeCy);
if (d < bestDist) {
bestDist = d;
bestLink = link;
}
break;
}
}
}
return bestLink;
}
function findInsertSlots(node, linkType) {
const inputSlot = node.inputs?.findIndex(i => i.link == null && typesCompatible(linkType, i.type)) ?? -1;
const outputSlot = node.outputs?.findIndex(o => typesCompatible(linkType, o.type)) ?? -1;
if (inputSlot === -1 || outputSlot === -1) return null;
return { inputSlot, outputSlot };
}
function executeNodeInsert(canvas, node, link) {
const graph = canvas.graph || app.graph;
const originNode = graph.getNodeById(link.origin_id);
const targetNode = graph.getNodeById(link.target_id);
if (!originNode || !targetNode) return;
const slots = findInsertSlots(node, link.type);
if (!slots) return;
const originSlot = link.origin_slot;
const targetSlot = link.target_slot;
targetNode.disconnectInput(targetSlot);
originNode.connect(originSlot, node, slots.inputSlot);
node.connect(slots.outputSlot, targetNode, targetSlot);
graph.change();
canvas.setDirty(true, true);
}
function setInsertTarget(link, graph, slots = null) {
if (link === state.insertTargetLink) return;
// `_dragging` is LiteGraph's own runtime flag for hiding a link mid-rewire.
// Not serialized, so save/copy/undo stay safe.
if (state.insertTargetLink) delete state.insertTargetLink._dragging;
if (link) {
link._dragging = true;
state.insertSlots = slots ?? findInsertSlots(state.draggedNode, link.type);
state.insertOriginNode = graph.getNodeById(link.origin_id);
state.insertDestNode = graph.getNodeById(link.target_id);
} else {
state.insertSlots = null;
state.insertOriginNode = null;
state.insertDestNode = null;
}
state.insertTargetLink = link;
}
function getTypeColor(lgCanvas, slotType) {
if (slotType && LGraphCanvas.link_type_colors && LGraphCanvas.link_type_colors[slotType]) {
return LGraphCanvas.link_type_colors[slotType];
}
return lgCanvas.default_link_color || "#AAA";
}
function drawGhostLink(ctx, from, to, color, alpha, drawTime) {
const offsetX = bezierOffsetX(from, to);
ctx.beginPath();
ctx.moveTo(from[0], from[1]);
ctx.bezierCurveTo(
from[0] + offsetX, from[1],
to[0] - offsetX, to[1],
to[0], to[1],
);
ctx.strokeStyle = color;
ctx.globalAlpha = alpha;
ctx.lineWidth = 2.5;
ctx.setLineDash([8, 4]);
ctx.lineDashOffset = -(drawTime ?? performance.now()) / 50;
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
function drawSlotHighlight(ctx, pos, color, alpha) {
ctx.beginPath();
ctx.arc(pos[0], pos[1], 6, 0, Math.PI * 2);
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.fill();
ctx.globalAlpha = 1;
}
function drawGhostSegment(ctx, from, to, color, now) {
drawGhostLink(ctx, from, to, color, 0.8, now);
drawSlotHighlight(ctx, from, color, 0.6);
drawSlotHighlight(ctx, to, color, 0.6);
}
function drawInsertPreview(ctx, lgCanvas) {
const { insertTargetLink: link, insertSlots: slots, insertOriginNode, insertDestNode, draggedNode } = state;
if (!link || !slots || !insertOriginNode || !insertDestNode || !draggedNode) return;
const now = performance.now();
const color = getTypeColor(lgCanvas, link.type);
drawGhostSegment(ctx,
getSlotPos(insertOriginNode, false, link.origin_slot),
getSlotPos(draggedNode, true, slots.inputSlot),
color, now);
drawGhostSegment(ctx,
getSlotPos(draggedNode, false, slots.outputSlot),
getSlotPos(insertDestNode, true, link.target_slot),
color, now);
}
function startAnimLoop(lgCanvas) {
if (state.animating) return;
state.animating = true;
function tick() {
const link = state.insertTargetLink;
if (!link) {
state.animating = false;
lgCanvas.setDirty(true, true);
return;
}
// If the link was removed externally (e.g. undo) mid-preview, our
// `_dragging` flag has nothing to land on — drop the preview.
const graph = lgCanvas.graph || app.graph;
const stored = graph?.links?.get(link.id);
if (stored !== link) {
setInsertTarget(null);
state.animating = false;
lgCanvas.setDirty(true, true);
return;
}
// Steady-state: only the dash offset changes — front canvas only.
lgCanvas.setDirty(true, false);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
function clearState() {
setInsertTarget(null);
state.pointerDown = false;
state.hasMoved = false;
state.draggedNode = null;
state.startPos = null;
}
app.registerExtension({
name: "KJNodes.NodeInsert",
settings: [
{
id: "KJNodes.nodeInsertMode",
name: "Node insert activation",
category: ["KJNodes", "Node Insert", "Activation mode"],
tooltip: "Always: dragging a compatible node onto a link previews insertion. Hotkey: only while the hotkey (default: D) is held. Disabled: feature off.",
type: "combo",
defaultValue: "hotkey",
options: ["always", "hotkey", "disabled"],
},
],
commands: [
{
id: "KJNodes.NodeInsertMode",
label: "Node insert mode (hold to activate)",
active: () => state.insertKeyDown,
function: () => {
state.insertKeyDown = true;
// Snapshot the key that triggered this so any rebind works.
state.activationKey = (performance.now() - lastKeyDownTime < KEYDOWN_MAX_AGE_MS)
? lastKeyDown
: null;
if (state.draggedNode) {
const graph = app.canvas?.graph || app.graph;
if (graph) {
const link = findLinkUnderNode(graph, state.draggedNode);
if (link && findInsertSlots(state.draggedNode, link.type)) {
setInsertTarget(link, graph);
startAnimLoop(app.canvas);
}
}
}
},
},
],
keybindings: [
{
commandId: "KJNodes.NodeInsertMode",
// Default only — release detection captures the actually-triggering
// key at command-fire time, so user rebinds work.
combo: { key: "d" },
targetElementId: "graph-canvas",
},
],
async setup() {
const lgCanvas = app.canvas;
const canvasEl = lgCanvas.canvas;
// Capture phase so `lastKeyDown` is set before ComfyUI fires the command.
document.addEventListener("keydown", (e) => {
if (e.repeat) return;
lastKeyDown = e.key?.toLowerCase() ?? null;
lastKeyDownTime = performance.now();
}, true);
document.addEventListener("keyup", (e) => {
const key = e.key?.toLowerCase() ?? null;
// Released key can't be a future trigger — invalidate so a stale
// keydown can't be picked up by a later menu activation.
if (key && key === lastKeyDown) lastKeyDownTime = 0;
if (!state.insertKeyDown) return;
// No activation key (menu-fired) → blur/pointercancel will clean up.
if (!state.activationKey || key !== state.activationKey) return;
state.insertKeyDown = false;
state.activationKey = null;
if (state.insertTargetLink) {
setInsertTarget(null);
lgCanvas.setDirty(false, true);
}
});
// Releases outside the window never reach our keyup/pointerup listeners,
// so without these the `_dragging` flag and hotkey state can stick.
const dropTransient = () => {
state.insertKeyDown = false;
state.activationKey = null;
clearState();
lgCanvas.setDirty(false, true);
};
window.addEventListener("blur", dropTransient);
document.addEventListener("visibilitychange", () => {
if (document.hidden) dropTransient();
});
document.addEventListener("pointercancel", dropTransient, true);
// Scope the Vue-mode node lookup to the graph canvas's container so
// stray `[data-node-id]` elements elsewhere can't activate this flow.
const graphRoot = canvasEl.parentElement;
document.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
const onCanvas = e.target === canvasEl;
const vueNodeEl = graphRoot?.contains(e.target)
? e.target?.closest?.("[data-node-id]")
: null;
if (!onCanvas && !vueNodeEl) return;
state.pointerDown = true;
state.hasMoved = false;
const graph = lgCanvas.graph || app.graph;
if (graph) {
let node = null;
if (vueNodeEl) {
const nodeId = parseInt(vueNodeEl.getAttribute("data-node-id"));
if (Number.isFinite(nodeId)) node = graph.getNodeById(nodeId);
} else {
const [cx, cy] = clientToCanvas(lgCanvas, e.clientX, e.clientY);
node = getNodeAtPoint(graph, cx, cy);
}
if (node && (node.inputs || node.outputs)) {
state.draggedNode = node;
state.startPos = node.pos ? [node.pos[0], node.pos[1]] : null;
}
}
}, true);
document.addEventListener("pointermove", () => {
if (!state.pointerDown) return;
if (lgCanvas.connecting_links?.length) return;
if (lgCanvas.resizing_node) return;
if (lgCanvas.node_widget) return;
const mode = app.ui.settings.getSettingValue("KJNodes.nodeInsertMode") ?? "always";
if (mode === "disabled") return;
state.hasMoved = true;
const active = mode === "always" || state.insertKeyDown;
if (!active) {
setInsertTarget(null);
return;
}
if (!state.draggedNode) return;
// Multi-node drags move several nodes together — inserting one of
// them into a link is almost never what the user wants.
const selected = lgCanvas.selected_nodes;
if (selected) {
const count = selected instanceof Map ? selected.size : Object.keys(selected).length;
if (count > 1) {
setInsertTarget(null);
return;
}
}
// Vue mode: pointermove fires on the document during widget drags
// (sliders etc.) too — require the node itself to have moved.
const np = state.draggedNode.pos;
if (!np || !state.startPos
|| (np[0] === state.startPos[0] && np[1] === state.startPos[1])) {
setInsertTarget(null);
return;
}
const graph = lgCanvas.graph || app.graph;
if (!graph) return;
const now = performance.now();
const nodeCount = graph._nodes?.length ?? 0;
const throttle = nodeCount > 200 ? 50 : nodeCount > 100 ? 32 : 16;
if (now - state.lastScanTime < throttle) return;
state.lastScanTime = now;
const link = findLinkUnderNode(graph, state.draggedNode);
const slots = link ? findInsertSlots(state.draggedNode, link.type) : null;
const wasIdle = !state.insertTargetLink;
if (link && slots) {
setInsertTarget(link, graph, slots);
if (wasIdle) startAnimLoop(lgCanvas);
} else {
setInsertTarget(null);
}
}, true);
document.addEventListener("pointerup", (e) => {
if (e.button !== 0) return;
if (!state.pointerDown) return;
if (state.insertTargetLink && state.draggedNode && state.hasMoved
&& !state.draggedNode.flags?.pinned) {
delete state.insertTargetLink._dragging;
executeNodeInsert(lgCanvas, state.draggedNode, state.insertTargetLink);
}
clearState();
}, true);
// onDrawForeground: ghost paints over the existing (hidden) link.
chainCallback(lgCanvas, "onDrawForeground", function (ctx) {
if (state.insertTargetLink) {
drawInsertPreview(ctx, lgCanvas);
}
});
},
});
-434
View File
@@ -1,434 +0,0 @@
const { app } = window.comfyAPI.app;
import { typesCompatible, clientToCanvas, getNodeAtPoint } from "./utility.js";
let swapTargetNode = null;
let swapDraggedNode = null;
let swapDragStartPos = null;
let swapAnimating = false;
let swapHasMoved = false;
let swapKeyDown = false;
/** Find the topmost node whose bounding box overlaps with draggedNode */
function getOverlappingNode(graph, draggedNode) {
const ax = draggedNode.pos[0];
const ay = draggedNode.pos[1];
const aw = draggedNode.size[0] || 100;
const ah = draggedNode.size[1] || 60;
for (let i = graph._nodes.length - 1; i >= 0; i--) {
const n = graph._nodes[i];
if (n === draggedNode) continue;
const bx = n.pos[0];
const by = n.pos[1];
if (ax < bx + (n.size[0] || 100) && ax + aw > bx &&
ay < by + (n.size[1] || 60) && ay + ah > by) {
return n;
}
}
return null;
}
function startHighlightAnim(lgCanvas) {
if (swapAnimating) return;
swapAnimating = true;
function tick() {
if (!swapTargetNode) {
swapAnimating = false;
return;
}
lgCanvas.setDirty(false, true);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
function clearSwapState() {
swapTargetNode = null;
swapDraggedNode = null;
swapDragStartPos = null;
swapHasMoved = false;
}
function snapshotConnections(graph, node) {
const inputs = [];
for (let i = 0; i < (node.inputs?.length ?? 0); i++) {
const inp = node.inputs[i];
if (inp.link == null) continue;
const link = graph.getLink(inp.link);
if (!link) continue;
inputs.push({
slotIndex: i,
type: inp.type,
name: inp.name,
originNodeId: link.origin_id,
originSlot: link.origin_slot,
});
}
const outputs = [];
for (let o = 0; o < (node.outputs?.length ?? 0); o++) {
const out = node.outputs[o];
if (!out.links?.length) continue;
const targets = out.links
.map((linkId) => graph.getLink(linkId))
.filter(Boolean)
.map((link) => ({ targetNodeId: link.target_id, targetSlot: link.target_slot }));
if (targets.length > 0) {
outputs.push({ slotIndex: o, type: out.type, name: out.name, targets });
}
}
return { inputs, outputs };
}
function disconnectAll(node) {
if (node.inputs) {
for (let i = node.inputs.length - 1; i >= 0; i--) {
if (node.inputs[i].link != null) node.disconnectInput(i);
}
}
if (node.outputs) {
for (let o = node.outputs.length - 1; o >= 0; o--) {
if (node.outputs[o].links?.length) node.disconnectOutput(o);
}
}
}
// Map each snapshot entry → a new-node slot. Name matches win first, then sameIndex,
// then first compatible. Each slot is claimed at most once so fallbacks can't stomp matches.
function resolveAssignments(snapshots, slots, isFree) {
const assignments = new Map();
if (!slots || !slots.length) return assignments;
const used = new Set();
for (const snap of snapshots) {
if (!snap.name) continue;
const nameLower = String(snap.name).toLowerCase();
for (let s = 0; s < slots.length; s++) {
if (used.has(s) || !isFree(s)) continue;
const slotName = slots[s].name;
if (slotName && String(slotName).toLowerCase() === nameLower &&
typesCompatible(snap.type, slots[s].type)) {
assignments.set(snap, s);
used.add(s);
break;
}
}
}
for (const snap of snapshots) {
if (assignments.has(snap)) continue;
const i = snap.slotIndex;
if (i < slots.length && !used.has(i) && isFree(i) &&
typesCompatible(snap.type, slots[i].type)) {
assignments.set(snap, i);
used.add(i);
continue;
}
for (let s = 0; s < slots.length; s++) {
if (used.has(s) || !isFree(s)) continue;
if (typesCompatible(snap.type, slots[s].type)) {
assignments.set(snap, s);
used.add(s);
break;
}
}
}
return assignments;
}
// Reconnect external connections from a snapshot onto newNode.
function reconnectExternal(graph, snapshot, newNode, otherNodeId) {
const inputAssignments = resolveAssignments(
snapshot.inputs, newNode.inputs || [],
(s) => newNode.inputs[s].link == null,
);
for (const inp of snapshot.inputs) {
if (inp.originNodeId === otherNodeId) continue;
if (!inputAssignments.has(inp)) continue;
const originNode = graph.getNodeById(inp.originNodeId);
if (!originNode) continue;
originNode.connect(inp.originSlot, newNode, inputAssignments.get(inp));
}
const outputAssignments = resolveAssignments(
snapshot.outputs, newNode.outputs || [],
() => true,
);
for (const out of snapshot.outputs) {
if (!outputAssignments.has(out)) continue;
const bestSlot = outputAssignments.get(out);
for (const tgt of out.targets) {
if (tgt.targetNodeId === otherNodeId) continue;
const targetNode = graph.getNodeById(tgt.targetNodeId);
if (!targetNode) continue;
newNode.connect(bestSlot, targetNode, tgt.targetSlot);
}
}
}
// Reconnect links that were between the two swapped nodes in one direction.
// A→B becomes B→A, if slot types are compatible.
function reconnectInternalOneDirection(snap, fromNodeId, srcNode, dstNode) {
for (const out of snap.outputs) {
for (const tgt of out.targets) {
if (tgt.targetNodeId !== fromNodeId) continue;
if (!srcNode.outputs || out.slotIndex >= srcNode.outputs.length) continue;
if (!dstNode.inputs || tgt.targetSlot >= dstNode.inputs.length) continue;
if (!typesCompatible(srcNode.outputs[out.slotIndex].type, dstNode.inputs[tgt.targetSlot].type)) continue;
srcNode.connect(out.slotIndex, dstNode, tgt.targetSlot);
}
}
}
function getNodeElement(nodeId) {
return document.querySelector(`[data-node-id="${nodeId}"]`);
}
function animateSwapVue(nodeA, toA, nodeB, toB, duration) {
const elA = getNodeElement(nodeA.id);
const elB = getNodeElement(nodeB.id);
const transition = `transform ${duration}ms cubic-bezier(0.65, 0, 0.35, 1)`;
if (elA) elA.style.transition = transition;
if (elB) elB.style.transition = transition;
nodeA.pos = [toA[0], toA[1]];
nodeB.pos = [toB[0], toB[1]];
app.canvas.setDirty(true, true);
setTimeout(() => {
if (elA) elA.style.transition = "";
if (elB) elB.style.transition = "";
}, duration + 16);
}
function animateSwapCanvas(nodeA, fromA, toA, nodeB, fromB, toB, duration) {
const start = performance.now();
function ease(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function frame(now) {
const t = Math.min((now - start) / duration, 1);
const e = ease(t);
nodeA.pos = [
fromA[0] + (toA[0] - fromA[0]) * e,
fromA[1] + (toA[1] - fromA[1]) * e,
];
nodeB.pos = [
fromB[0] + (toB[0] - fromB[0]) * e,
fromB[1] + (toB[1] - fromB[1]) * e,
];
app.canvas.setDirty(true, true);
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
function executeNodeSwap(canvas, nodeA, nodeB, originalPosA) {
const graph = canvas.graph || app.graph;
const snapA = snapshotConnections(graph, nodeA);
const snapB = snapshotConnections(graph, nodeB);
const posA = originalPosA || [nodeA.pos[0], nodeA.pos[1]];
const posB = [nodeB.pos[0], nodeB.pos[1]];
disconnectAll(nodeA);
disconnectAll(nodeB);
reconnectExternal(graph, snapA, nodeB, nodeA.id);
reconnectExternal(graph, snapB, nodeA, nodeB.id);
reconnectInternalOneDirection(snapA, nodeB.id, nodeB, nodeA);
reconnectInternalOneDirection(snapB, nodeA.id, nodeA, nodeB);
if (LiteGraph.vueNodesMode) {
animateSwapVue(nodeA, posB, nodeB, posA, 200);
} else {
animateSwapCanvas(nodeA, [nodeA.pos[0], nodeA.pos[1]], posB,
nodeB, [nodeB.pos[0], nodeB.pos[1]], posA, 200);
}
graph.change();
canvas.setDirty(true, true);
}
app.registerExtension({
name: "KJNodes.NodeSwap",
settings: [
{
id: "KJNodes.nodeSwapEnabled",
name: "Enable node swap on drag",
category: ["KJNodes", "Node Swap", "Enable"],
tooltip: "Hold swap key (default: S, rebindable in Keybindings) and drag a node onto another to swap their positions and reconnect links",
type: "boolean",
defaultValue: true,
},
],
commands: [
{
id: "KJNodes.ToggleNodeSwap",
label: "Toggle node swap on drag",
active: () => app.ui.settings.getSettingValue("KJNodes.nodeSwapEnabled"),
function: () => {
const cur = app.ui.settings.getSettingValue("KJNodes.nodeSwapEnabled");
app.ui.settings.setSettingValue("KJNodes.nodeSwapEnabled", !cur);
},
},
{
id: "KJNodes.NodeSwapMode",
label: "Node swap mode (hold to activate)",
active: () => swapKeyDown,
function: () => {
swapKeyDown = true;
// If already dragging a node, check for overlap immediately
// (handles case where node is already on top of another when key is pressed)
if (swapDraggedNode) {
const graph = app.canvas?.graph || app.graph;
if (graph) {
swapTargetNode = getOverlappingNode(graph, swapDraggedNode);
if (swapTargetNode) startHighlightAnim(app.canvas);
}
}
},
},
],
keybindings: [
{
commandId: "KJNodes.NodeSwapMode",
combo: { key: "s" },
targetElementId: "graph-canvas",
},
],
async setup() {
await new Promise((resolve) => {
function check() {
if (app.canvas) return resolve();
requestAnimationFrame(check);
}
check();
});
const lgCanvas = app.canvas;
const canvasEl = lgCanvas.canvas;
// The command system handles keydown (sets swapKeyDown = true).
// Any keyup clears it since the user must hold the key.
document.addEventListener("keyup", () => {
if (swapKeyDown) {
swapKeyDown = false;
if (swapTargetNode) {
swapTargetNode = null;
lgCanvas.setDirty(false, true);
}
}
});
// Pointermove/pointerup handlers — only attached while pointer is down.
function onPointerMove() {
swapHasMoved = true;
if (!swapKeyDown) {
if (swapTargetNode) {
swapTargetNode = null;
lgCanvas.setDirty(false, true);
}
return;
}
if (!swapDraggedNode) return;
const graph = lgCanvas.graph || app.graph;
if (!graph) return;
const prev = swapTargetNode;
swapTargetNode = getOverlappingNode(graph, swapDraggedNode);
if (swapTargetNode && !prev) {
startHighlightAnim(lgCanvas);
}
}
function onPointerUp(e) {
if (e.button !== 0) return;
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp, true);
const targetNode = swapTargetNode;
const draggedNode = swapDraggedNode;
const startPos = swapDragStartPos;
const hasMoved = swapHasMoved;
clearSwapState();
if (!hasMoved || !targetNode || !draggedNode || !swapKeyDown) return;
if (!targetNode.flags?.pinned && !draggedNode.flags?.pinned) {
executeNodeSwap(lgCanvas, draggedNode, targetNode, startPos);
}
}
// Record clicked node and its original position.
// Listen on document to catch events from both the canvas and Vue node overlays.
const graphContainer = canvasEl.closest(".graph-canvas-container") || canvasEl.parentElement;
document.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
if (!app.ui.settings.getSettingValue("KJNodes.nodeSwapEnabled")) return;
if (!graphContainer?.contains(e.target)) return;
swapHasMoved = false;
const graph = lgCanvas.graph || app.graph;
if (!graph) return;
const [cx, cy] = clientToCanvas(lgCanvas, e.clientX, e.clientY);
const node = getNodeAtPoint(graph, cx, cy);
if (node && (node.inputs || node.outputs)) {
swapDraggedNode = node;
swapDragStartPos = [node.pos[0], node.pos[1]];
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp, true);
}
}, true);
// Draw pulsing glow outline on the swap target node
const origOnDrawBg = lgCanvas.onDrawBackground;
lgCanvas.onDrawBackground = function (ctx, ...args) {
origOnDrawBg?.call(this, ctx, ...args);
const target = swapTargetNode;
if (!target) return;
const [x, y] = target.pos;
const w = target.size[0] || 100;
const h = target.size[1] || 60;
const titleH = LiteGraph.NODE_TITLE_HEIGHT || 20;
const pulse = 0.5 + 0.3 * Math.sin(Date.now() / 200);
const isPinned = target.flags?.pinned;
const color = isPinned ? "255, 80, 80" : "100, 200, 255";
const pad = 5;
ctx.save();
ctx.strokeStyle = `rgba(${color}, ${pulse})`;
ctx.lineWidth = 3;
ctx.shadowColor = `rgba(${color}, 0.8)`;
ctx.shadowBlur = 15;
ctx.beginPath();
ctx.roundRect(x - pad, y - titleH - pad, w + pad * 2, h + titleH + pad * 2, 8);
ctx.stroke();
ctx.fillStyle = `rgba(${color}, ${isPinned ? 0.08 : 0.05})`;
ctx.fill();
ctx.restore();
};
},
});
-286
View File
@@ -1,286 +0,0 @@
const { app } = window.comfyAPI.app;
const _savedRoundRadius = typeof LiteGraph !== "undefined" ? LiteGraph.ROUND_RADIUS : 8;
function getSetting(id, fallback) {
return app.ui.settings.getSettingValue(id) ?? fallback;
}
function toggleSetting(id) {
const cur = app.ui.settings.getSettingValue(id);
app.ui.settings.setSettingValue(id, !cur);
}
app.registerExtension({
name: "KJNodes.Performance",
commands: [
{
id: "KJNodes.perf.toggleSingleCanvasPan",
label: "Single-canvas mode during pan",
menubarLabel: "Single-canvas mode during pan",
function: () => toggleSetting("KJNodes.perf.singleCanvasPan"),
active: () => getSetting("KJNodes.perf.singleCanvasPan", false),
},
{
id: "KJNodes.perf.toggleDisableShadows",
label: "Disable node shadows",
menubarLabel: "Disable node shadows",
function: () => toggleSetting("KJNodes.perf.disableShadows"),
active: () => getSetting("KJNodes.perf.disableShadows", false),
},
{
id: "KJNodes.perf.toggleDisableConnectionBorders",
label: "Disable connection borders",
menubarLabel: "Disable connection borders",
function: () => toggleSetting("KJNodes.perf.disableConnectionBorders"),
active: () => getSetting("KJNodes.perf.disableConnectionBorders", false),
},
{
id: "KJNodes.perf.toggleDisableRoundedCorners",
label: "Disable rounded corners",
menubarLabel: "Disable rounded corners",
function: () => toggleSetting("KJNodes.perf.disableRoundedCorners"),
active: () => getSetting("KJNodes.perf.disableRoundedCorners", false),
},
{
id: "KJNodes.perf.toggleThrottleRenderInfo",
label: "Throttle info overlay",
menubarLabel: "Throttle info overlay",
function: () => toggleSetting("KJNodes.perf.throttleRenderInfo"),
active: () => getSetting("KJNodes.perf.throttleRenderInfo", false),
},
],
menuCommands: [
{
path: ["KJNodes", "Performance"],
commands: [
"KJNodes.perf.toggleSingleCanvasPan",
"KJNodes.perf.toggleDisableShadows",
"KJNodes.perf.toggleDisableConnectionBorders",
"KJNodes.perf.toggleDisableRoundedCorners",
"KJNodes.perf.toggleThrottleRenderInfo",
],
},
],
settings: [
{
id: "KJNodes.perf.singleCanvasPan",
name: "Single-canvas mode during pan",
category: ["KJNodes", "Performance", "Single-canvas mode during pan"],
tooltip: "Eliminates the expensive bgcanvas-to-canvas copy during panning. Significant improvement without hardware acceleration.",
type: "boolean",
defaultValue: false,
onChange: (value) => {
const canvas = app.canvas;
if (!canvas) return;
if (value) {
installSingleCanvasPan(canvas);
} else {
uninstallSingleCanvasPan(canvas);
}
},
},
{
id: "KJNodes.perf.disableShadows",
name: "Disable node shadows",
category: ["KJNodes", "Performance", "Disable node shadows"],
tooltip: "Disable shadow rendering on nodes for better performance without hardware acceleration.",
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (app.canvas) {
app.canvas.render_shadows = !value;
app.canvas.setDirty(true, true);
}
},
},
{
id: "KJNodes.perf.disableConnectionBorders",
name: "Disable connection borders",
category: ["KJNodes", "Performance", "Disable connection borders"],
tooltip: "Disable the outer border stroke on connection lines. Each link draws two strokes instead of one when enabled.",
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (app.canvas) {
app.canvas.render_connections_border = !value;
app.canvas.setDirty(true, true);
}
},
},
{
id: "KJNodes.perf.disableRoundedCorners",
name: "Disable rounded corners",
category: ["KJNodes", "Performance", "Disable rounded corners"],
tooltip: "Use square corners on nodes instead of rounded. Avoids roundRect calls in software rendering.",
type: "boolean",
defaultValue: false,
onChange: (value) => {
if (typeof LiteGraph !== "undefined") {
LiteGraph.ROUND_RADIUS = value ? 0 : _savedRoundRadius;
app.canvas?.setDirty(true, true);
}
},
},
{
id: "KJNodes.perf.throttleRenderInfo",
name: "Throttle info overlay",
category: ["KJNodes", "Performance", "Throttle info overlay"],
tooltip: "Cache the FPS/info text overlay and only re-render it a few times per second. Saves ~20ms per frame in software rendering.",
type: "boolean",
defaultValue: false,
onChange: (value) => {
const canvas = app.canvas;
if (!canvas) return;
if (value) {
installThrottleRenderInfo(canvas);
} else {
uninstallThrottleRenderInfo(canvas);
}
},
},
],
setup() {
const canvas = app.canvas;
if (!canvas) return;
if (getSetting("KJNodes.perf.singleCanvasPan", false)) {
installSingleCanvasPan(canvas);
}
if (getSetting("KJNodes.perf.disableShadows", false)) {
canvas.render_shadows = false;
}
if (getSetting("KJNodes.perf.disableConnectionBorders", false)) {
canvas.render_connections_border = false;
}
if (getSetting("KJNodes.perf.disableRoundedCorners", false)) {
LiteGraph.ROUND_RADIUS = 0;
}
if (getSetting("KJNodes.perf.throttleRenderInfo", false)) {
installThrottleRenderInfo(canvas);
}
},
});
// ── Single-canvas mode during pan ───────────────────────────────────────────
let _origDraw = null;
let _panInstalledOn = null;
function installSingleCanvasPan(canvas) {
if (_origDraw && _panInstalledOn === canvas) return;
if (_origDraw && _panInstalledOn !== canvas) {
// Canvas was replaced — discard stale reference
_origDraw = null;
}
_panInstalledOn = canvas;
_origDraw = canvas.draw;
let panning = false;
let savedBgCanvas = null;
let savedBgCtx = null;
canvas.draw = function (force_canvas, force_bgcanvas) {
if (this.dragging_canvas) {
if (!panning) {
// Flush any pending bg redraw before aliasing to avoid a stale-frame flash
if (this.dirty_bgcanvas) {
_origDraw.call(this, false, true);
}
savedBgCanvas = this.bgcanvas;
savedBgCtx = this.bgctx;
panning = true;
this.bgcanvas = this.canvas;
this.bgctx = this.ctx;
}
} else if (panning) {
panning = false;
this.bgcanvas = savedBgCanvas;
this.bgctx = savedBgCtx;
this.dirty_bgcanvas = true;
this.dirty_canvas = true;
}
_origDraw.call(this, force_canvas, force_bgcanvas);
};
}
function uninstallSingleCanvasPan(canvas) {
if (!_origDraw) return;
if (_panInstalledOn === canvas) {
canvas.draw = _origDraw;
}
_origDraw = null;
_panInstalledOn = null;
canvas.dirty_bgcanvas = true;
canvas.dirty_canvas = true;
}
// ── Throttle renderInfo ─────────────────────────────────────────────────────
let _origRenderInfo = null;
let _infoCanvas = null;
let _infoInstalledOn = null;
function installThrottleRenderInfo(canvas) {
if (_origRenderInfo && _infoInstalledOn === canvas) return;
if (_origRenderInfo && _infoInstalledOn !== canvas) {
_origRenderInfo = null;
_infoCanvas = null;
}
_infoInstalledOn = canvas;
_origRenderInfo = canvas.renderInfo;
_infoCanvas = document.createElement("canvas");
let infoCtx = _infoCanvas.getContext("2d");
let lastInfoTime = 0;
let cachedDpr = 0;
let _infoDrawX = 10;
let _infoDrawY = 0;
canvas.renderInfo = function (ctx, x, y) {
const dpr = window.devicePixelRatio || 1;
// Resize offscreen canvas if DPR changed
if (dpr !== cachedDpr) {
cachedDpr = dpr;
_infoCanvas.width = Math.ceil(200 * dpr);
_infoCanvas.height = Math.ceil(100 * dpr);
infoCtx = _infoCanvas.getContext("2d");
infoCtx.scale(dpr, dpr);
lastInfoTime = 0;
}
const now = performance.now();
if (now - lastInfoTime > 250) {
lastInfoTime = now;
infoCtx.clearRect(0, 0, 200, 100);
// Render with the same x/y the original would use, but offset
// into offscreen canvas coordinates
const origX = x || 10;
const lineHeight = 13;
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0);
const origY = y || (this.canvas.height / dpr - (lineCount + 1) * lineHeight);
// Store the computed position for blitting
_infoDrawX = origX;
_infoDrawY = origY;
// Draw into offscreen at (1, 1), text flows from there
_origRenderInfo.call(this, infoCtx, 1, 1);
}
// Blit cached text at the original position
ctx.drawImage(_infoCanvas, _infoDrawX - 1, _infoDrawY - 1, 200, 100);
};
}
function uninstallThrottleRenderInfo(canvas) {
if (!_origRenderInfo) return;
if (_infoInstalledOn === canvas) {
canvas.renderInfo = _origRenderInfo;
}
_origRenderInfo = null;
_infoCanvas = null;
_infoInstalledOn = null;
canvas.setDirty(true, true);
}
-94
View File
@@ -1,94 +0,0 @@
const { app } = window.comfyAPI.app;
const { api } = window.comfyAPI.api;
app.registerExtension({
name: "KJNodes.PlaySound",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "PlaySoundKJ") return;
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (output) {
onExecuted?.call(this, output);
const audios = output?.audio;
if (!audios?.length) return;
const modeWidget = this.widgets?.find(w => w.name === "mode");
const volumeWidget = this.widgets?.find(w => w.name === "volume");
const durationWidget = this.widgets?.find(w => w.name === "duration");
const mode = modeWidget?.value ?? "always";
const volume = volumeWidget?.value ?? 0.5;
const duration = durationWidget?.value ?? 0;
// on_change: skip if audio content hasn't changed
if (mode === "on_change") {
const audioHash = output?.audio_hash?.[0];
if (audioHash != null && this._kjLastAudioHash === audioHash) return;
this._kjLastAudioHash = audioHash;
}
// Clean up previous state
if (this._kjStatusListener) {
api.removeEventListener("status", this._kjStatusListener);
this._kjStatusListener = null;
}
clearTimeout(this._kjQueueDebounce);
this._kjPendingAudio = null;
if (this._kjPlayingAudio) {
this._kjPlayingAudio.pause();
this._kjPlayingAudio = null;
}
if (this._kjPlayTimer != null) {
clearTimeout(this._kjPlayTimer);
this._kjPlayTimer = null;
}
const startPlayback = () => {
const { filename, subfolder, type } = audios[0];
const params = new URLSearchParams({
filename: filename ?? "",
subfolder: subfolder ?? "",
type: type ?? "temp",
});
const url = api.apiURL(`/view?${params.toString()}`);
const audio = new Audio(url);
audio.volume = Math.max(0, Math.min(1, volume));
audio.play().catch(() => {});
this._kjPlayingAudio = audio;
if (duration > 0) {
this._kjPlayTimer = setTimeout(() => {
audio.pause();
this._kjPlayingAudio = null;
this._kjPlayTimer = null;
}, duration * 1000);
}
};
if (mode === "on_empty_queue") {
this._kjPendingAudio = startPlayback;
this._kjStatusListener = ({ detail }) => {
const remaining = detail?.exec_info?.queue_remaining ?? 0;
if (remaining === 0) {
// Debounce: confirm queue is truly empty
// (status can briefly show 0 between dispatches)
clearTimeout(this._kjQueueDebounce);
this._kjQueueDebounce = setTimeout(() => {
if (this._kjPendingAudio) {
this._kjPendingAudio();
this._kjPendingAudio = null;
}
api.removeEventListener("status", this._kjStatusListener);
this._kjStatusListener = null;
}, 1000);
} else {
clearTimeout(this._kjQueueDebounce);
}
};
api.addEventListener("status", this._kjStatusListener);
} else {
startPlayback();
}
};
},
});
+734
View File
@@ -0,0 +1,734 @@
import { app } from '../../../scripts/app.js'
//from melmass
export function makeUUID() {
let dt = new Date().getTime()
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = ((dt + Math.random() * 16) % 16) | 0
dt = Math.floor(dt / 16)
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
return uuid
}
export const loadScript = (
FILE_URL,
async = true,
type = 'text/javascript',
) => {
return new Promise((resolve, reject) => {
try {
// Check if the script already exists
const existingScript = document.querySelector(`script[src="${FILE_URL}"]`)
if (existingScript) {
resolve({ status: true, message: 'Script already loaded' })
return
}
const scriptEle = document.createElement('script')
scriptEle.type = type
scriptEle.async = async
scriptEle.src = FILE_URL
scriptEle.addEventListener('load', (ev) => {
resolve({ status: true })
})
scriptEle.addEventListener('error', (ev) => {
reject({
status: false,
message: `Failed to load the script ${FILE_URL}`,
})
})
document.body.appendChild(scriptEle)
} catch (error) {
reject(error)
}
})
}
const create_documentation_stylesheet = () => {
const tag = 'kj-pointseditor-stylesheet'
let styleTag = document.head.querySelector(tag)
if (!styleTag) {
styleTag = document.createElement('style')
styleTag.type = 'text/css'
styleTag.id = tag
styleTag.innerHTML = `
.points-editor {
position: absolute;
font: 12px monospace;
line-height: 1.5em;
padding: 10px;
z-index: 0;
overflow: hidden;
}
`
document.head.appendChild(styleTag)
}
}
loadScript('kjweb_async/svg-path-properties.min.js').catch((e) => {
console.log(e)
})
loadScript('kjweb_async/protovis.min.js').catch((e) => {
console.log(e)
})
create_documentation_stylesheet()
function chainCallback(object, property, callback) {
if (object == undefined) {
//This should not happen.
console.error("Tried to add callback to non-existant object")
return;
}
if (property in object) {
const callback_orig = object[property]
object[property] = function () {
const r = callback_orig.apply(this, arguments);
callback.apply(this, arguments);
return r
};
} else {
object[property] = callback;
}
}
app.registerExtension({
name: 'KJNodes.PointEditor',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name === 'PointsEditor') {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
hideWidgetForGood(this, this.widgets.find(w => w.name === "coordinates"))
hideWidgetForGood(this, this.widgets.find(w => w.name === "neg_coordinates"))
hideWidgetForGood(this, this.widgets.find(w => w.name === "bboxes"))
var element = document.createElement("div");
this.uuid = makeUUID()
element.id = `points-editor-${this.uuid}`
this.previewMediaType = 'image'
this.pointsEditor = this.addDOMWidget(nodeData.name, "PointsEditorWidget", element, {
serialize: false,
hideOnZoom: false,
});
// context menu
this.contextMenu = document.createElement("div");
this.contextMenu.id = "context-menu";
this.contextMenu.style.display = "none";
this.contextMenu.style.position = "absolute";
this.contextMenu.style.backgroundColor = "#202020";
this.contextMenu.style.minWidth = "100px";
this.contextMenu.style.boxShadow = "0px 8px 16px 0px rgba(0,0,0,0.2)";
this.contextMenu.style.zIndex = "100";
this.contextMenu.style.padding = "5px";
function styleMenuItem(menuItem) {
menuItem.style.display = "block";
menuItem.style.padding = "5px";
menuItem.style.color = "#FFF";
menuItem.style.fontFamily = "Arial, sans-serif";
menuItem.style.fontSize = "16px";
menuItem.style.textDecoration = "none";
menuItem.style.marginBottom = "5px";
}
function createMenuItem(id, textContent) {
let menuItem = document.createElement("a");
menuItem.href = "#";
menuItem.id = `menu-item-${id}`;
menuItem.textContent = textContent;
styleMenuItem(menuItem);
return menuItem;
}
// Create an array of menu items using the createMenuItem function
this.menuItems = [
createMenuItem(0, "Load Image"),
createMenuItem(1, "Clear Image"),
];
// Add mouseover and mouseout event listeners to each menu item for styling
this.menuItems.forEach(menuItem => {
menuItem.addEventListener('mouseover', function () {
this.style.backgroundColor = "gray";
});
menuItem.addEventListener('mouseout', function () {
this.style.backgroundColor = "#202020";
});
});
// Append each menu item to the context menu
this.menuItems.forEach(menuItem => {
this.contextMenu.appendChild(menuItem);
});
document.body.appendChild(this.contextMenu);
this.addWidget("button", "New canvas", null, () => {
if (!this.properties || !("points" in this.properties)) {
this.editor = new PointsEditor(this);
this.addProperty("points", this.constructor.type, "string");
this.addProperty("neg_points", this.constructor.type, "string");
}
else {
this.editor = new PointsEditor(this, true);
}
});
this.setSize([550, 550]);
this.resizable = false;
this.pointsEditor.parentEl = document.createElement("div");
this.pointsEditor.parentEl.className = "points-editor";
this.pointsEditor.parentEl.id = `points-editor-${this.uuid}`
element.appendChild(this.pointsEditor.parentEl);
chainCallback(this, "onConfigure", function () {
try {
this.editor = new PointsEditor(this);
} catch (error) {
console.error("An error occurred while configuring the editor:", error);
}
});
chainCallback(this, "onExecuted", function (message) {
let bg_image = message["bg_image"];
this.properties.imgData = {
name: "bg_image",
base64: bg_image
};
this.editor.refreshBackgroundImage(this);
});
}); // onAfterGraphConfigured
}//node created
} //before register
})//register
class PointsEditor {
constructor(context, reset = false) {
this.node = context;
this.reset = reset;
const self = this; // Keep a reference to the main class context
console.log("creatingPointEditor")
this.node.pasteFile = (file) => {
if (file.type.startsWith("image/")) {
this.handleImageFile(file);
return true;
}
return false;
};
this.node.onDragOver = function (e) {
if (e.dataTransfer && e.dataTransfer.items) {
return [...e.dataTransfer.items].some(f => f.kind === "file" && f.type.startsWith("image/"));
}
return false;
};
// On drop upload files
this.node.onDragDrop = (e) => {
console.log("onDragDrop called");
let handled = false;
for (const file of e.dataTransfer.files) {
if (file.type.startsWith("image/")) {
this.handleImageFile(file);
handled = true;
}
}
return handled;
};
// context menu
this.createContextMenu();
if (reset && context.pointsEditor.element) {
context.pointsEditor.element.innerHTML = ''; // Clear the container
}
this.pos_coordWidget = context.widgets.find(w => w.name === "coordinates");
this.neg_coordWidget = context.widgets.find(w => w.name === "neg_coordinates");
this.pointsStoreWidget = context.widgets.find(w => w.name === "points_store");
this.widthWidget = context.widgets.find(w => w.name === "width");
this.heightWidget = context.widgets.find(w => w.name === "height");
this.bboxStoreWidget = context.widgets.find(w => w.name === "bbox_store");
this.bboxWidget = context.widgets.find(w => w.name === "bboxes");
//widget callbacks
this.widthWidget.callback = () => {
this.width = this.widthWidget.value;
if (this.width > 256) {
context.setSize([this.width + 45, context.size[1]]);
}
this.vis.width(this.width);
this.updateData();
}
this.heightWidget.callback = () => {
this.height = this.heightWidget.value
this.vis.height(this.height)
context.setSize([context.size[0], this.height + 300]);
this.updateData();
}
this.pointsStoreWidget.callback = () => {
this.points = JSON.parse(pointsStoreWidget.value).positive;
this.neg_points = JSON.parse(pointsStoreWidget.value).negative;
this.updateData();
}
this.bboxStoreWidget.callback = () => {
this.bbox = JSON.parse(bboxStoreWidget.value)
this.updateData();
}
this.width = this.widthWidget.value;
this.height = this.heightWidget.value;
var i = 3;
this.points = [];
this.neg_points = [];
this.bbox = [{}];
var drawing = false;
// Initialize or reset points array
if (!reset && this.pointsStoreWidget.value != "") {
this.points = JSON.parse(this.pointsStoreWidget.value).positive;
this.neg_points = JSON.parse(this.pointsStoreWidget.value).negative;
this.bbox = JSON.parse(this.bboxStoreWidget.value);
console.log(this.bbox)
} else {
this.points = [
{
x: this.width / 2, // Middle point horizontally centered
y: this.height / 2 // Middle point vertically centered
}
];
this.neg_points = [
{
x: 0, // Middle point horizontally centered
y: 0 // Middle point vertically centered
}
];
const combinedPoints = {
positive: this.points,
negative: this.neg_points,
};
this.pointsStoreWidget.value = JSON.stringify(combinedPoints);
this.bboxStoreWidget.value = JSON.stringify(this.bbox);
}
//create main canvas panel
this.vis = new pv.Panel()
.width(this.width)
.height(this.height)
.fillStyle("#222")
.strokeStyle("gray")
.lineWidth(2)
.antialias(false)
.margin(10)
.event("mousedown", function () {
if (pv.event.shiftKey && pv.event.button === 2) { // Use pv.event to access the event object
let scaledMouse = {
x: this.mouse().x / app.canvas.ds.scale,
y: this.mouse().y / app.canvas.ds.scale
};
i = self.neg_points.push(scaledMouse) - 1;
self.updateData();
return this;
}
else if (pv.event.shiftKey) {
let scaledMouse = {
x: this.mouse().x / app.canvas.ds.scale,
y: this.mouse().y / app.canvas.ds.scale
};
i = self.points.push(scaledMouse) - 1;
self.updateData();
return this;
}
else if (pv.event.ctrlKey) {
console.log("start drawing at " + this.mouse().x / app.canvas.ds.scale + ", " + this.mouse().y / app.canvas.ds.scale);
drawing = true;
self.bbox[0].startX = this.mouse().x / app.canvas.ds.scale;
self.bbox[0].startY = this.mouse().y / app.canvas.ds.scale;
}
else if (pv.event.button === 2) {
self.node.contextMenu.style.display = 'block';
self.node.contextMenu.style.left = `${pv.event.clientX}px`;
self.node.contextMenu.style.top = `${pv.event.clientY}px`;
}
})
.event("mousemove", function () {
if (drawing) {
self.bbox[0].endX = this.mouse().x / app.canvas.ds.scale;
self.bbox[0].endY = this.mouse().y / app.canvas.ds.scale;
self.vis.render();
}
})
.event("mouseup", function () {
console.log("end drawing at " + this.mouse().x / app.canvas.ds.scale + ", " + this.mouse().y / app.canvas.ds.scale);
drawing = false;
self.updateData();
});
this.backgroundImage = this.vis.add(pv.Image).visible(false)
//create bounding box
this.bounding_box = this.vis.add(pv.Area)
.data(function () {
if (drawing || (self.bbox && self.bbox[0] && Object.keys(self.bbox[0]).length > 0)) {
return [self.bbox[0].startX, self.bbox[0].endX];
} else {
return [];
}
})
.bottom(function () {return self.height - Math.max(self.bbox[0].startY, self.bbox[0].endY); })
.left(function (d) {return d; })
.height(function () {return Math.abs(self.bbox[0].startY - self.bbox[0].endY);})
.fillStyle("rgba(70, 130, 180, 0.5)")
.strokeStyle("steelblue")
.visible(function () {return drawing || Object.keys(self.bbox[0]).length > 0; })
.add(pv.Dot)
.visible(function () {return drawing || Object.keys(self.bbox[0]).length > 0; })
.data(() => {
if (self.bbox && Object.keys(self.bbox[0]).length > 0) {
return [{
x: self.bbox[0].endX,
y: self.bbox[0].endY
}];
} else {
return [];
}
})
.left(d => d.x)
.top(d => d.y)
.radius(Math.log(Math.min(self.width, self.height)) * 1)
.shape("square")
.cursor("move")
.strokeStyle("steelblue")
.lineWidth(2)
.fillStyle(function () { return "rgba(100, 100, 100, 0.6)"; })
.event("mousedown", pv.Behavior.drag())
.event("drag", function () {
let adjustedX = this.mouse().x / app.canvas.ds.scale; // Adjust the new position by the inverse of the scale factor
let adjustedY = this.mouse().y / app.canvas.ds.scale;
// Adjust the new position if it would place the dot outside the bounds of the vis.Panel
adjustedX = Math.max(0, Math.min(self.vis.width(), adjustedX));
adjustedY = Math.max(0, Math.min(self.vis.height(), adjustedY));
self.bbox[0].endX = this.mouse().x / app.canvas.ds.scale;
self.bbox[0].endY = this.mouse().y / app.canvas.ds.scale;
self.vis.render();
})
.event("dragend", function () {
self.updateData();
});
//create positive points
this.vis.add(pv.Dot)
.data(() => this.points)
.left(d => d.x)
.top(d => d.y)
.radius(Math.log(Math.min(self.width, self.height)) * 4)
.shape("circle")
.cursor("move")
.strokeStyle(function () { return i == this.index ? "#07f907" : "#139613"; })
.lineWidth(4)
.fillStyle(function () { return "rgba(100, 100, 100, 0.6)"; })
.event("mousedown", pv.Behavior.drag())
.event("dragstart", function () {
i = this.index;
})
.event("dragend", function () {
if (pv.event.button === 2 && i !== 0 && i !== self.points.length - 1) {
this.index = i;
self.points.splice(i--, 1);
}
self.updateData();
})
.event("drag", function () {
let adjustedX = this.mouse().x / app.canvas.ds.scale; // Adjust the new X position by the inverse of the scale factor
let adjustedY = this.mouse().y / app.canvas.ds.scale; // Adjust the new Y position by the inverse of the scale factor
// Determine the bounds of the vis.Panel
const panelWidth = self.vis.width();
const panelHeight = self.vis.height();
// Adjust the new position if it would place the dot outside the bounds of the vis.Panel
adjustedX = Math.max(0, Math.min(panelWidth, adjustedX));
adjustedY = Math.max(0, Math.min(panelHeight, adjustedY));
self.points[this.index] = { x: adjustedX, y: adjustedY }; // Update the point's position
self.vis.render(); // Re-render the visualization to reflect the new position
})
.anchor("center")
.add(pv.Label)
.left(d => d.x < this.width / 2 ? d.x + 30 : d.x - 35) // Shift label to right if on left half, otherwise shift to left
.top(d => d.y < this.height / 2 ? d.y + 25 : d.y - 25) // Shift label down if on top half, otherwise shift up
.font(25 + "px sans-serif")
.text(d => {return this.points.indexOf(d); })
.textStyle("#139613")
.textShadow("2px 2px 2px black")
.add(pv.Dot) // Add smaller point in the center
.data(() => this.points)
.left(d => d.x)
.top(d => d.y)
.radius(2) // Smaller radius for the center point
.shape("circle")
.fillStyle("red") // Color for the center point
.lineWidth(1); // Stroke thickness for the center point
//create negative points
this.vis.add(pv.Dot)
.data(() => this.neg_points)
.left(d => d.x)
.top(d => d.y)
.radius(Math.log(Math.min(self.width, self.height)) * 4)
.shape("circle")
.cursor("move")
.strokeStyle(function () { return i == this.index ? "#f91111" : "#891616"; })
.lineWidth(4)
.fillStyle(function () { return "rgba(100, 100, 100, 0.6)"; })
.event("mousedown", pv.Behavior.drag())
.event("dragstart", function () {
i = this.index;
})
.event("dragend", function () {
if (pv.event.button === 2 && i !== 0 && i !== self.neg_points.length - 1) {
this.index = i;
self.neg_points.splice(i--, 1);
}
self.updateData();
})
.event("drag", function () {
let adjustedX = this.mouse().x / app.canvas.ds.scale; // Adjust the new X position by the inverse of the scale factor
let adjustedY = this.mouse().y / app.canvas.ds.scale; // Adjust the new Y position by the inverse of the scale factor
// Determine the bounds of the vis.Panel
const panelWidth = self.vis.width();
const panelHeight = self.vis.height();
// Adjust the new position if it would place the dot outside the bounds of the vis.Panel
adjustedX = Math.max(0, Math.min(panelWidth, adjustedX));
adjustedY = Math.max(0, Math.min(panelHeight, adjustedY));
self.neg_points[this.index] = { x: adjustedX, y: adjustedY }; // Update the point's position
self.vis.render(); // Re-render the visualization to reflect the new position
})
.anchor("center")
.add(pv.Label)
.left(d => d.x < this.width / 2 ? d.x + 30 : d.x - 35) // Shift label to right if on left half, otherwise shift to left
.top(d => d.y < this.height / 2 ? d.y + 25 : d.y - 25) // Shift label down if on top half, otherwise shift up
.font(25 + "px sans-serif")
.text(d => {return this.neg_points.indexOf(d); })
.textStyle("red")
.textShadow("2px 2px 2px black")
.add(pv.Dot) // Add smaller point in the center
.data(() => this.neg_points)
.left(d => d.x)
.top(d => d.y)
.radius(2) // Smaller radius for the center point
.shape("circle")
.fillStyle("red") // Color for the center point
.lineWidth(1); // Stroke thickness for the center point
if (this.points.length != 0) {
this.vis.render();
}
var svgElement = this.vis.canvas();
svgElement.style['zIndex'] = "2"
svgElement.style['position'] = "relative"
this.node.pointsEditor.element.appendChild(svgElement);
if (this.width > 256) {
this.node.setSize([this.width + 45, this.node.size[1]]);
}
this.node.setSize([this.node.size[0], this.height + 300]);
this.updateData();
this.refreshBackgroundImage();
}//end constructor
updateData = () => {
if (!this.points || this.points.length === 0) {
console.log("no points");
return;
}
const combinedPoints = {
positive: this.points,
negative: this.neg_points,
};
this.pointsStoreWidget.value = JSON.stringify(combinedPoints);
this.pos_coordWidget.value = JSON.stringify(this.points);
this.neg_coordWidget.value = JSON.stringify(this.neg_points);
if (this.bbox.length != 0) {
let bboxString = JSON.stringify(this.bbox);
this.bboxStoreWidget.value = bboxString;
this.bboxWidget.value = bboxString;
}
this.vis.render();
};
handleImageLoad = (img, file, base64String) => {
console.log(img.width, img.height); // Access width and height here
this.widthWidget.value = img.width;
this.heightWidget.value = img.height;
if (img.width != this.vis.width() || img.height != this.vis.height()) {
if (img.width > 256) {
this.node.setSize([img.width + 45, this.node.size[1]]);
}
this.node.setSize([this.node.size[0], img.height + 300]);
this.vis.width(img.width);
this.vis.height(img.height);
this.height = img.height;
this.width = img.width;
this.updateData();
}
this.backgroundImage.url(file ? URL.createObjectURL(file) : `data:${this.node.properties.imgData.type};base64,${base64String}`).visible(true).root.render();
};
processImage = (img, file) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const maxWidth = 800; // maximum width
const maxHeight = 600; // maximum height
let width = img.width;
let height = img.height;
// Calculate the new dimensions while preserving the aspect ratio
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
// Get the compressed image data as a Base64 string
const base64String = canvas.toDataURL('image/jpeg', 0.5).replace('data:', '').replace(/^.+,/, ''); // 0.5 is the quality from 0 to 1
this.node.properties.imgData = {
name: file.name,
lastModified: file.lastModified,
size: file.size,
type: file.type,
base64: base64String
};
handleImageLoad(img, file, base64String);
};
handleImageFile = (file) => {
const reader = new FileReader();
reader.onloadend = () => {
const img = new Image();
img.src = reader.result;
img.onload = () => processImage(img, file);
};
reader.readAsDataURL(file);
const imageUrl = URL.createObjectURL(file);
const img = new Image();
img.src = imageUrl;
img.onload = () => this.handleImageLoad(img, file, null);
};
refreshBackgroundImage = () => {
if (this.node.properties.imgData && this.node.properties.imgData.base64) {
const base64String = this.node.properties.imgData.base64;
const imageUrl = `data:${this.node.properties.imgData.type};base64,${base64String}`;
const img = new Image();
img.src = imageUrl;
img.onload = () => this.handleImageLoad(img, null, base64String);
}
};
createContextMenu = () => {
self = this;
document.addEventListener('contextmenu', function (e) {
e.preventDefault();
});
document.addEventListener('click', function (e) {
if (!self.node.contextMenu.contains(e.target)) {
self.node.contextMenu.style.display = 'none';
}
});
this.node.menuItems.forEach((menuItem, index) => {
self = this;
menuItem.addEventListener('click', function (e) {
e.preventDefault();
switch (index) {
case 0:
// Create file input element
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*'; // Accept only image files
// Listen for file selection
fileInput.addEventListener('change', function (event) {
const file = event.target.files[0]; // Get the selected file
if (file) {
const imageUrl = URL.createObjectURL(file);
let img = new Image();
img.src = imageUrl;
img.onload = () => self.handleImageLoad(img, file, null);
}
});
fileInput.click();
self.node.contextMenu.style.display = 'none';
break;
case 1:
self.backgroundImage.visible(false).root.render();
self.node.properties.imgData = null;
self.node.contextMenu.style.display = 'none';
break;
}
});
});
}//end createContextMenu
}//end class
//from melmass
export function hideWidgetForGood(node, widget, suffix = '') {
widget.origType = widget.type
widget.origComputeSize = widget.computeSize
widget.origSerializeValue = widget.serializeValue
widget.computeSize = () => [0, -4] // -4 is due to the gap litegraph adds between widgets automatically
widget.type = "converted-widget" + suffix
// widget.serializeValue = () => {
// // Prevent serializing the widget if we have no input linked
// const w = node.inputs?.find((i) => i.widget?.name === widget.name);
// if (w?.link == null) {
// return undefined;
// }
// return widget.origSerializeValue ? widget.origSerializeValue() : widget.value;
// };
// Hide any linked widgets, e.g. seed+seedControl
if (widget.linkedWidgets) {
for (const w of widget.linkedWidgets) {
hideWidgetForGood(node, w, ':' + widget.name)
}
}
}
@@ -1,222 +0,0 @@
/* Palette matched to ComfyUI-MemoryVisualization. */
.kj-pov-root {
width: 100%;
height: 100%;
position: relative;
background: #181818;
color: #b0b0b0;
font: 12px monospace;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
}
.kj-pov-image-area {
flex: 1 1 auto;
min-height: 80px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #0a0a0a;
overflow: hidden;
}
/* Absolute-fill so latent-resolution previews scale up rather than staying centred-tiny. */
.kj-pov-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: pixelated;
display: block;
}
.kj-pov-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #707070;
font: 12px monospace;
pointer-events: none;
}
.kj-pov-scrub {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background: rgba(0, 0, 0, 0.5);
cursor: pointer;
z-index: 5;
transition: height 80ms ease;
}
.kj-pov-scrub:hover { height: 10px; }
.kj-pov-scrub-fill {
height: 100%;
width: 0%;
background: #e67e22;
pointer-events: none;
}
.kj-pov-scrub.kj-pov-paused .kj-pov-scrub-fill {
background: #707070;
}
.kj-pov-panel-grip {
flex: 0 0 5px;
background: #2a2a2a;
cursor: ns-resize;
position: relative;
z-index: 4;
user-select: none;
}
.kj-pov-panel-grip::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 26px;
height: 2px;
background: #555;
border-radius: 1px;
}
.kj-pov-panel-grip:hover { background: #3a3a3a; }
.kj-pov-panel-grip:hover::after { background: #888; }
.kj-pov-panel {
flex: 0 0 auto;
height: 140px;
min-height: 60px;
background: #181818;
font: 11px monospace;
color: #b0b0b0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.kj-pov-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 10px;
gap: 8px;
background: #202020;
border-bottom: 1px solid #2a2a2a;
}
.kj-pov-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.kj-pov-panel-title { color: #b0b0b0; font-size: 11px; }
.kj-pov-panel-summary { color: #707070; font-size: 10px; }
.kj-pov-toggle {
background: #2a2a2a;
color: #888;
border: 1px solid #333;
border-radius: 3px;
padding: 1px 7px;
font: 10px monospace;
cursor: pointer;
user-select: none;
}
.kj-pov-toggle:hover { background: #333; color: #b0b0b0; }
.kj-pov-toggle.kj-pov-on {
background: #3a2a1a;
color: #e67e22;
border-color: #c66e1a;
}
.kj-pov-graphs-grid {
display: flex;
align-items: stretch;
padding: 6px 6px 7px;
gap: 6px;
flex: 1 1 auto;
min-height: 0;
box-sizing: border-box;
}
.kj-pov-graph-cell {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
background: #0e0e0e;
border: 1px solid #1c1c1c;
border-radius: 3px;
overflow: hidden;
}
.kj-pov-graph-head {
flex: 0 0 auto;
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 3px 6px 2px;
background: #161616;
border-bottom: 1px solid #1c1c1c;
font-size: 10px;
line-height: 1.1;
gap: 4px;
}
.kj-pov-graph-label {
color: #707070;
letter-spacing: 0.05em;
flex: 0 0 auto;
}
.kj-pov-graph-value {
color: #b0b0b0;
font-size: 10px;
text-align: right;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kj-pov-graph-canvas {
flex: 1 1 auto;
display: block;
width: 100%;
min-height: 0;
}
/* Audio strip under the graphs: latent heatmap or spectrogram with the clip playhead. */
.kj-pov-spec {
flex: 0 0 auto;
display: flex;
flex-direction: column;
border-top: 1px solid #2a2a2a;
background: #141414;
}
.kj-pov-spec-canvas {
display: block;
width: 100%;
height: 44px;
}
.kj-pov-audio-btn {
background: #262626;
color: #707070;
border: 1px solid #333;
border-radius: 3px;
font-size: 11px;
line-height: 1;
padding: 1px 4px;
cursor: pointer;
}
.kj-pov-audio-btn:hover { background: #333; color: #b0b0b0; }
.kj-pov-audio-btn.kj-pov-audio-on { color: #e67e22; border-color: #e67e22; }
File diff suppressed because it is too large Load Diff
-500
View File
@@ -1,500 +0,0 @@
import { addMiddleClickPan, addWheelPassthrough } from "./utility.js";
const { app } = window.comfyAPI.app;
app.registerExtension({
name: "KJNodes.ScreencapStream",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "ScreencapStream") return;
nodeType.prototype.onNodeCreated = function () {
const node = this;
const frameWidget = this.widgets.find(w => w.name === "frame_data");
frameWidget.computeSize = () => [0, -4];
// --- State ---
let stream = null;
let crop = null; // { x, y, w, h } in video pixel coords, or null
let previewVisible = true;
let previewInterval = null;
let bitmapInterval = null;
let cachedBitmap = null; // downscaled ImageBitmap for preview
let bitmapPending = false;
let drag = null;
let drawPending = false;
let cachedLayout = null;
let layoutDirty = true;
let updatingFromCrop = false;
let captureStartTime = null;
let timerInterval = null;
const EDGE = 8;
// separate canvases to avoid conflicts between preview and serialization
const serializeCanvas = document.createElement("canvas");
const serializeCtx = serializeCanvas.getContext("2d");
// --- DOM elements ---
const video = document.createElement("video");
video.autoplay = true;
video.muted = true;
video.style.cssText = "position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;overflow:hidden;";
const container = document.createElement("div");
container.style.cssText = "width:100%;height:100%;background:#000;border-radius:4px;overflow:hidden;position:relative;";
container.appendChild(video);
const overlay = document.createElement("canvas");
overlay.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;cursor:crosshair;";
container.appendChild(overlay);
const overlayCtx = overlay.getContext("2d");
addMiddleClickPan(container);
addWheelPassthrough(container);
// --- Widget references ---
const cropWWidget = node.widgets.find(w => w.name === "crop_width");
const cropHWidget = node.widgets.find(w => w.name === "crop_height");
const PREVIEW_MAX_RES = 960; // preview-only cap for performance
// --- Layout ---
function invalidateLayout() { layoutDirty = true; }
function getLayout() {
if (!layoutDirty && cachedLayout) return cachedLayout;
const oRect = overlay.getBoundingClientRect();
const vw = video.videoWidth, vh = video.videoHeight;
if (!vw || !vh || !oRect.width || !oRect.height) { cachedLayout = null; return null; }
const scale = Math.min(oRect.width / vw, oRect.height / vh);
cachedLayout = {
scale, vw, vh,
offX: (oRect.width - vw * scale) / 2,
offY: (oRect.height - vh * scale) / 2,
};
layoutDirty = false;
return cachedLayout;
}
function toVideoCoords(cx, cy) {
const L = getLayout();
if (!L) return { x: 0, y: 0 };
return {
x: Math.round(Math.max(0, Math.min(L.vw, (cx - L.offX) / L.scale))),
y: Math.round(Math.max(0, Math.min(L.vh, (cy - L.offY) / L.scale))),
};
}
function pointerToLocal(e) {
const r = overlay.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
// --- Output dimensions ---
// Single source of truth for what the output size will be.
function getOutputDimensions() {
const vw = video.videoWidth, vh = video.videoHeight;
if (!vw || !vh) return null;
const sw = crop ? crop.w : vw;
const sh = crop ? crop.h : vh;
return { w: sw, h: sh };
}
function updateSizeWidgets() {
updatingFromCrop = true;
const dims = getOutputDimensions();
if (dims) {
cropWWidget.value = dims.w;
cropHWidget.value = dims.h;
// clamp max to full capture dimensions
if (cropWWidget.options) cropWWidget.options.max = video.videoWidth || dims.w;
if (cropHWidget.options) cropHWidget.options.max = video.videoHeight || dims.h;
}
app.graph.setDirtyCanvas(true);
updatingFromCrop = false;
}
function updateCropFromWidgets() {
if (updatingFromCrop || !video.videoWidth || !crop) return;
const newW = Math.min(cropWWidget.value, video.videoWidth);
const newH = Math.min(cropHWidget.value, video.videoHeight);
if (newW <= 0 || newH <= 0) return;
const cx = crop.x + crop.w / 2;
const cy = crop.y + crop.h / 2;
let x = Math.round(cx - newW / 2);
let y = Math.round(cy - newH / 2);
x = Math.max(0, Math.min(x, video.videoWidth - newW));
y = Math.max(0, Math.min(y, video.videoHeight - newH));
crop = { x, y, w: newW, h: newH };
drawOverlay();
}
function clearCrop() {
if (video.videoWidth && video.videoHeight) {
crop = { x: 0, y: 0, w: video.videoWidth, h: video.videoHeight };
} else {
crop = null;
}
drawOverlay();
updateSizeWidgets();
}
// --- Cached bitmap ---
// Updated every 500ms for preview display.
// Downscaled to PREVIEW_MAX_RES for performance.
async function updateCachedBitmap() {
if (bitmapPending || !stream || !video.videoWidth) return;
bitmapPending = true;
try {
const vw = video.videoWidth, vh = video.videoHeight;
if (vw <= 0 || vh <= 0) { bitmapPending = false; return; }
// always full frame for preview — crop is drawn as overlay
let outW = vw, outH = vh;
if (Math.max(vw, vh) > PREVIEW_MAX_RES) {
const s = PREVIEW_MAX_RES / Math.max(vw, vh);
outW = Math.round(vw * s);
outH = Math.round(vh * s);
}
const bmp = await createImageBitmap(video, {
resizeWidth: outW,
resizeHeight: outH,
resizeQuality: "medium",
});
if (cachedBitmap) cachedBitmap.close();
cachedBitmap = bmp;
} catch { /* frame not ready */ }
bitmapPending = false;
}
function startBitmapLoop() {
stopBitmapLoop();
bitmapInterval = setInterval(updateCachedBitmap, 500);
}
function stopBitmapLoop() {
if (bitmapInterval) { clearInterval(bitmapInterval); bitmapInterval = null; }
}
// --- Preview drawing ---
function drawOverlay() {
if (drawPending) return;
drawPending = true;
requestAnimationFrame(() => {
drawPending = false;
renderOverlay();
});
}
function renderOverlay() {
const r = overlay.getBoundingClientRect();
const w = Math.round(r.width), h = Math.round(r.height);
if (overlay.width !== w || overlay.height !== h) {
overlay.width = w; overlay.height = h;
invalidateLayout();
}
overlayCtx.clearRect(0, 0, w, h);
if (!previewVisible) return;
const L = getLayout();
if (!L) return;
// draw from cached bitmap if available, else raw video
const src = cachedBitmap || video;
overlayCtx.drawImage(src, L.offX, L.offY, L.vw * L.scale, L.vh * L.scale);
if (!crop) return;
const rx = L.offX + crop.x * L.scale;
const ry = L.offY + crop.y * L.scale;
const rw = crop.w * L.scale;
const rh = crop.h * L.scale;
// darken outside crop
overlayCtx.fillStyle = "rgba(0,0,0,0.5)";
overlayCtx.fillRect(0, 0, w, ry);
overlayCtx.fillRect(0, ry + rh, w, h - ry - rh);
overlayCtx.fillRect(0, ry, rx, rh);
overlayCtx.fillRect(rx + rw, ry, w - rx - rw, rh);
overlayCtx.strokeStyle = "#fff";
overlayCtx.lineWidth = 1.5;
overlayCtx.setLineDash([4, 3]);
overlayCtx.strokeRect(rx, ry, rw, rh);
overlayCtx.setLineDash([]);
}
function startPreviewLoop() {
stopPreviewLoop();
previewInterval = setInterval(() => drawOverlay(), 100);
}
function stopPreviewLoop() {
if (previewInterval) { clearInterval(previewInterval); previewInterval = null; }
}
// --- Hit testing ---
function hitTest(lx, ly) {
if (!crop) return "draw";
const L = getLayout();
if (!L) return "draw";
const rx = L.offX + crop.x * L.scale;
const ry = L.offY + crop.y * L.scale;
const rw = crop.w * L.scale;
const rh = crop.h * L.scale;
const nearL = Math.abs(lx - rx) < EDGE;
const nearR = Math.abs(lx - (rx + rw)) < EDGE;
const nearT = Math.abs(ly - ry) < EDGE;
const nearB = Math.abs(ly - (ry + rh)) < EDGE;
const inX = lx > rx - EDGE && lx < rx + rw + EDGE;
const inY = ly > ry - EDGE && ly < ry + rh + EDGE;
if (nearT && nearL && inX && inY) return "nw";
if (nearT && nearR && inX && inY) return "ne";
if (nearB && nearL && inX && inY) return "sw";
if (nearB && nearR && inX && inY) return "se";
if (nearT && inX) return "n";
if (nearB && inX) return "s";
if (nearL && inY) return "w";
if (nearR && inY) return "e";
if (lx > rx && lx < rx + rw && ly > ry && ly < ry + rh) return "move";
return "draw";
}
const cursorMap = {
nw: "nwse-resize", se: "nwse-resize",
ne: "nesw-resize", sw: "nesw-resize",
n: "ns-resize", s: "ns-resize",
w: "ew-resize", e: "ew-resize",
move: "grab", draw: "crosshair",
};
// --- Widget callbacks ---
const origWCb = cropWWidget.callback;
const origHCb = cropHWidget.callback;
cropWWidget.callback = function() { origWCb?.apply(this, arguments); updateCropFromWidgets(); };
cropHWidget.callback = function() { origHCb?.apply(this, arguments); updateCropFromWidgets(); };
// --- Pointer events ---
overlay.addEventListener("pointermove", (e) => {
if (drag) {
e.stopPropagation();
e.preventDefault();
const p = pointerToLocal(e);
const v = toVideoCoords(p.x, p.y);
const dx = v.x - drag.startVX;
const dy = v.y - drag.startVY;
const o = drag.origCrop;
const maxW = video.videoWidth, maxH = video.videoHeight;
if (drag.type === "draw") {
let w = Math.abs(v.x - drag.startVX);
let h = Math.abs(v.y - drag.startVY);
if (e.shiftKey && w > 0 && h > 0) {
const aspect = drag.aspect || w / h;
drag.aspect = aspect;
if (w / h > aspect) w = Math.round(h * aspect);
else h = Math.round(w / aspect);
} else {
drag.aspect = null;
}
crop = {
x: v.x < drag.startVX ? drag.startVX - w : drag.startVX,
y: v.y < drag.startVY ? drag.startVY - h : drag.startVY,
w, h,
};
} else if (drag.type === "move") {
let nx = o.x + dx, ny = o.y + dy;
nx = Math.max(0, Math.min(maxW - o.w, nx));
ny = Math.max(0, Math.min(maxH - o.h, ny));
crop = { x: nx, y: ny, w: o.w, h: o.h };
} else {
let { x, y, w, h } = { ...o };
if (drag.type.includes("w")) { x = o.x + dx; w = o.w - dx; }
if (drag.type.includes("e")) { w = o.w + dx; }
if (drag.type.includes("n")) { y = o.y + dy; h = o.h - dy; }
if (drag.type.includes("s")) { h = o.h + dy; }
if (w < 0) { x += w; w = -w; }
if (h < 0) { y += h; h = -h; }
if (e.shiftKey && o.w > 0 && o.h > 0 && w > 0 && h > 0) {
const aspect = o.w / o.h;
if (drag.type.length === 2) {
if (w / h > aspect) w = Math.round(h * aspect);
else h = Math.round(w / aspect);
if (drag.type.includes("w")) x = o.x + o.w - w;
if (drag.type.includes("n")) y = o.y + o.h - h;
} else if (drag.type === "n" || drag.type === "s") {
w = Math.round(h * aspect);
x = o.x + Math.round((o.w - w) / 2);
} else {
h = Math.round(w / aspect);
y = o.y + Math.round((o.h - h) / 2);
}
}
x = Math.max(0, x); y = Math.max(0, y);
w = Math.min(w, maxW - x); h = Math.min(h, maxH - y);
crop = { x, y, w, h };
}
drawOverlay();
updateSizeWidgets();
return;
}
if (!previewVisible || !stream) return;
const p = pointerToLocal(e);
overlay.style.cursor = cursorMap[hitTest(p.x, p.y)];
});
overlay.addEventListener("pointerdown", (e) => {
if (!previewVisible || !stream) return;
e.stopPropagation();
e.preventDefault();
overlay.setPointerCapture(e.pointerId);
const p = pointerToLocal(e);
const type = hitTest(p.x, p.y);
const v = toVideoCoords(p.x, p.y);
drag = {
type,
startVX: v.x, startVY: v.y,
origCrop: crop ? { ...crop } : null,
};
if (type === "move") overlay.style.cursor = "grabbing";
});
overlay.addEventListener("pointerup", (e) => {
if (!drag) return;
e.stopPropagation();
if (drag.type === "move") overlay.style.cursor = "grab";
drag = null;
if (crop && (crop.w < 5 || crop.h < 5)) {
crop = { x: 0, y: 0, w: video.videoWidth, h: video.videoHeight };
}
drawOverlay();
updateSizeWidgets();
});
overlay.addEventListener("dblclick", (e) => {
e.stopPropagation();
e.preventDefault();
clearCrop();
});
overlay.addEventListener("contextmenu", (e) => {
e.stopPropagation();
e.preventDefault();
clearCrop();
});
// --- Resize observer ---
const resizeObs = new ResizeObserver(() => { invalidateLayout(); drawOverlay(); });
resizeObs.observe(container);
// --- Timer ---
function formatElapsed(ms) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const pad = (n) => String(n).padStart(2, "0");
if (h > 0) return `${h}:${pad(m % 60)}:${pad(s % 60)}`;
return `${m}:${pad(s % 60)}`;
}
function updateBtnTimer() {
if (!captureStartTime || !btnWidget) return;
const elapsed = formatElapsed(Date.now() - captureStartTime);
btnWidget.name = `\uD83D\uDD34 ${elapsed} - Stop capture`;
app.graph.setDirtyCanvas(true);
}
function startTimer() {
captureStartTime = Date.now();
timerInterval = setInterval(updateBtnTimer, 1000);
updateBtnTimer();
}
function stopTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
captureStartTime = null;
}
// --- Stream management ---
let btnWidget = null;
function stopStream() {
stopPreviewLoop();
stopBitmapLoop();
stopTimer();
if (cachedBitmap) { cachedBitmap.close(); cachedBitmap = null; }
if (stream) stream.getTracks().forEach(t => t.stop());
stream = null;
crop = null;
video.srcObject = null;
if (btnWidget) btnWidget.name = "Start capture";
app.graph.setDirtyCanvas(true);
}
btnWidget = this.addWidget("button", "Start capture", null, async () => {
if (stream) { stopStream(); return; }
try {
// capture at native resolution — browser constraints
// cause green tint. Preview is downscaled via
// createImageBitmap, output goes to Python at full res.
stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { ideal: 5, max: 10 } },
});
video.srcObject = stream;
startTimer();
const track = stream.getVideoTracks()[0];
track.addEventListener("ended", stopStream);
video.addEventListener("loadedmetadata", () => {
crop = { x: 0, y: 0, w: video.videoWidth, h: video.videoHeight };
invalidateLayout();
updateSizeWidgets();
startBitmapLoop();
if (previewVisible) startPreviewLoop();
}, { once: true });
} catch { /* user cancelled */ }
});
// --- DOM widget ---
this.addDOMWidget("preview", "ScreencapPreview", container, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 200,
});
const previewBtn = this.addWidget("button", "Hide preview", null, () => {
previewVisible = !previewVisible;
previewBtn.name = previewVisible ? "Hide preview" : "Show preview";
if (previewVisible && stream) {
invalidateLayout();
startBitmapLoop();
startPreviewLoop();
} else {
stopPreviewLoop();
stopBitmapLoop();
overlayCtx.clearRect(0, 0, overlay.width, overlay.height);
}
app.graph.setDirtyCanvas(true);
});
// --- Frame serialization ---
// Always send full crop resolution to Python.
// Python handles downscaling with Lanczos for better quality.
frameWidget.serializeValue = () => {
if (!stream || !video.videoWidth) return "";
const vw = video.videoWidth, vh = video.videoHeight;
const sx = crop ? Math.min(crop.x, vw) : 0;
const sy = crop ? Math.min(crop.y, vh) : 0;
const sw = crop ? Math.min(crop.w, vw - sx) : vw;
const sh = crop ? Math.min(crop.h, vh - sy) : vh;
if (sw <= 0 || sh <= 0) return "";
serializeCanvas.width = sw;
serializeCanvas.height = sh;
serializeCtx.drawImage(video, sx, sy, sw, sh, 0, 0, sw, sh);
return serializeCanvas.toDataURL("image/png");
};
// --- Cleanup ---
const origOnRemoved = this.onRemoved;
this.onRemoved = function () {
stopStream();
resizeObs.disconnect();
origOnRemoved?.apply(this, arguments);
};
};
},
});
+441 -1579
View File
File diff suppressed because it is too large Load Diff
-221
View File
@@ -1,221 +0,0 @@
const { app } = window.comfyAPI.app;
const state = {
pointerDown: false,
lastDelta: [0, 0],
lastPos: null,
reversalTimes: [],
lastScanTime: 0,
triggered: false,
};
// Cached from each setting's onChange to avoid per-pointermove lookups.
const settings = {
enabled: true,
reversalsNeeded: 3,
};
// Top-left, not center: center shifts during resize, pos only moves on drag.
function getNodeAnchor(node) {
return [node.pos[0], node.pos[1]];
}
function getDraggedNodes(lgCanvas) {
const items = lgCanvas.selectedItems;
if (!items || items.size === 0) return [];
return [...items].filter(n => n.inputs || n.outputs);
}
function nodeHasLinks(node) {
if (node.inputs?.some(i => i.link != null)) return true;
if (node.outputs?.some(o => o.links?.length > 0)) return true;
return false;
}
// Same end result as deleting all selected nodes except internal links between selected nodes are preserved.
function executeShakeBreak(draggedNodes, graph, lgCanvas) {
if (draggedNodes.length === 0) return;
const selectedIds = new Set(draggedNodes.map(n => n.id));
lgCanvas.emitBeforeChange?.();
graph.beforeChange?.();
try {
for (const node of draggedNodes) bypassExternal(node, selectedIds, graph);
for (const node of draggedNodes) disconnectExternal(node, selectedIds, graph);
} finally {
graph.afterChange?.();
lgCanvas.emitAfterChange?.();
}
lgCanvas.setDirty(true, true);
state.triggered = true;
state.reversalTimes = [];
}
function bypassExternal(node, selectedIds, graph) {
const inputs = node.inputs || [];
for (let i = 0; i < inputs.length; i++) {
if (inputs[i].link == null) continue;
const inLink = graph.getLink(inputs[i].link);
if (!inLink || selectedIds.has(inLink.origin_id)) continue;
const inNode = graph.getNodeById(inLink.origin_id);
if (!inNode) continue;
traceAndBypass(node, i, inNode, inLink, selectedIds, graph, new Set());
}
}
// At each step, follows output[slot] (matching the input slot we arrived on).
// Internal targets recurse; external targets get a direct upstream → target link.
function traceAndBypass(currentNode, slot, inNode, inLink, selectedIds, graph, visited) {
const key = `${currentNode.id}:${slot}`;
if (visited.has(key)) return;
visited.add(key);
const output = currentNode.outputs?.[slot];
if (!output?.links?.length) return;
for (const outLinkId of [...output.links]) {
const outLink = graph.getLink(outLinkId);
if (!outLink) continue;
if (selectedIds.has(outLink.target_id)) {
const nextNode = graph.getNodeById(outLink.target_id);
if (nextNode) {
traceAndBypass(nextNode, outLink.target_slot, inNode, inLink, selectedIds, graph, visited);
}
} else {
const outNode = graph.getNodeById(outLink.target_id);
if (outNode) {
inNode.connect(inLink.origin_slot, outNode, outLink.target_slot, inLink.parentId);
}
}
}
}
function disconnectExternal(node, selectedIds, graph) {
if (node.inputs) {
for (let i = 0; i < node.inputs.length; i++) {
const linkId = node.inputs[i].link;
if (linkId == null) continue;
const link = graph.getLink(linkId);
if (!link || selectedIds.has(link.origin_id)) continue;
node.disconnectInput(i, true);
}
}
if (node.outputs) {
for (let o = 0; o < node.outputs.length; o++) {
const links = node.outputs[o].links;
if (!links?.length) continue;
for (const linkId of [...links]) {
const link = graph.getLink(linkId);
if (!link || selectedIds.has(link.target_id)) continue;
const targetNode = graph.getNodeById(link.target_id);
if (targetNode) targetNode.disconnectInput(link.target_slot, true);
}
}
}
}
function clearState() {
state.pointerDown = false;
state.lastDelta = [0, 0];
state.lastPos = null;
state.reversalTimes = [];
state.lastScanTime = 0;
state.triggered = false;
}
app.registerExtension({
name: "KJNodes.ShakeToDisconnect",
settings: [
{
id: "KJNodes.shakeToDisconnect",
name: "Enable shake to disconnect",
category: ["KJNodes", "Shake to Disconnect", "Enabled"],
tooltip: "Shake a selected node back and forth quickly to remove its links. Pass-through bypass is applied (same as deleting the node); when multiple nodes are selected, links between them are preserved and bypass propagates through the chain.",
type: "boolean",
defaultValue: false,
onChange: (v) => { settings.enabled = !!v; },
},
{
id: "KJNodes.shakeReversals",
name: "Reversals required",
category: ["KJNodes", "Shake to Disconnect", "Reversals required"],
tooltip: "Number of back-and-forth direction reversals needed to trigger disconnect. Lower = more sensitive (triggers from a small wiggle), higher = needs a more deliberate shake.",
type: "slider",
defaultValue: 3,
attrs: { min: 2, max: 6, step: 1 },
onChange: (v) => { settings.reversalsNeeded = Number(v) || 3; },
},
],
setup() {
const lgCanvas = app.canvas;
const canvasEl = lgCanvas.canvas;
document.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
// Legacy mode targets the canvas; Vue mode targets a node DOM element.
const onCanvas = e.target === canvasEl;
const onVueNode = e.target?.closest?.("[data-node-id]");
if (!onCanvas && !onVueNode) return;
state.pointerDown = true;
state.lastDelta = [0, 0];
state.lastPos = null;
state.reversalTimes = [];
state.triggered = false;
}, true);
document.addEventListener("pointermove", () => {
if (!state.pointerDown) return;
if (state.triggered) return;
if (lgCanvas.connecting_links?.length) return;
if (lgCanvas.resizing_node) return;
if (lgCanvas.node_widget) return;
if (!settings.enabled) return;
const graph = lgCanvas.graph || app.graph;
if (!graph) return;
const now = performance.now();
const nodeCount = graph._nodes?.length ?? 0;
const throttle = nodeCount > 200 ? 50 : nodeCount > 100 ? 32 : 16;
if (now - state.lastScanTime < throttle) return;
state.lastScanTime = now;
const draggedNodes = getDraggedNodes(lgCanvas);
if (draggedNodes.length === 0) return;
const curPos = getNodeAnchor(draggedNodes[0]);
if (state.lastPos) {
// Thresholds are in screen-pixels; deltas are in graph-coords, so scale.
const scale = lgCanvas.ds?.scale ?? 1;
const sdx = (curPos[0] - state.lastPos[0]) * scale;
const sdy = (curPos[1] - state.lastPos[1]) * scale;
const dot = sdx * state.lastDelta[0] + sdy * state.lastDelta[1];
const magnitude = Math.sqrt(sdx * sdx + sdy * sdy);
if (dot < 0 && magnitude > 5) state.reversalTimes.push(now);
if (magnitude > 2) state.lastDelta = [sdx, sdy];
}
state.lastPos = curPos;
const cutoff = now - 600;
while (state.reversalTimes.length && state.reversalTimes[0] < cutoff) state.reversalTimes.shift();
if (state.reversalTimes.length >= settings.reversalsNeeded && draggedNodes.some(nodeHasLinks)) {
executeShakeBreak(draggedNodes, graph, lgCanvas);
}
}, true);
document.addEventListener("pointerup", (e) => {
if (e.button !== 0) return;
clearState();
}, true);
},
});
File diff suppressed because it is too large Load Diff
-273
View File
@@ -1,273 +0,0 @@
const { app } = window.comfyAPI.app;
// ─── General-purpose helpers shared across KJNodes JS ───
export function makeUUID() {
let dt = new Date().getTime()
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = ((dt + Math.random() * 16) % 16) | 0
dt = Math.floor(dt / 16)
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
return uuid
}
export function chainCallback(object, property, callback) {
if (object == undefined) {
console.error("Tried to add callback to non-existant object")
return;
}
if (property in object) {
const callback_orig = object[property]
object[property] = function () {
const r = callback_orig.apply(this, arguments);
callback.apply(this, arguments);
return r
};
} else {
object[property] = callback;
}
}
export function hideWidgetForGood(node, widget, suffix = '') {
widget.origType = widget.type
widget.origComputeSize = widget.computeSize
widget.origSerializeValue = widget.serializeValue
widget.computeSize = () => [0, -4]
widget.type = "converted-widget" + suffix
if (widget.linkedWidgets) {
for (const w of widget.linkedWidgets) {
hideWidgetForGood(node, w, ':' + widget.name)
}
}
}
// ─── Graph coordinate helpers ───
export function clientToCanvas(lgCanvas, clientX, clientY) {
const rect = lgCanvas.canvas.getBoundingClientRect();
return [
(clientX - rect.left) / lgCanvas.ds.scale - lgCanvas.ds.offset[0],
(clientY - rect.top) / lgCanvas.ds.scale - lgCanvas.ds.offset[1],
];
}
export function getNodeAtPoint(graph, cx, cy) {
for (let i = graph._nodes.length - 1; i >= 0; i--) {
if (graph._nodes[i].isPointInside(cx, cy)) return graph._nodes[i];
}
return null;
}
export function typesCompatible(a, b) {
if (a === "*" || b === "*") return true;
if (a === b) return true;
if (typeof a !== "string" || typeof b !== "string") return false;
if (a.toUpperCase() === b.toUpperCase()) return true;
// ComfyUI accepts union types like "STRING,INT" — match if any token overlaps
if (a.includes(",") || b.includes(",")) {
const aTokens = a.toUpperCase().split(",").map(s => s.trim()).filter(Boolean);
const bTokens = b.toUpperCase().split(",").map(s => s.trim()).filter(Boolean);
if (aTokens.includes("*") || bTokens.includes("*")) return true;
return aTokens.some(t => bTokens.includes(t));
}
return false;
}
// ─── Slot position helper ───
// Uses modern API (DOM-registered positions in Vue mode) with legacy fallback
export function getSlotPos(node, isInput, slotIdx) {
if (isInput && node.getInputPos) return node.getInputPos(slotIdx);
if (!isInput && node.getOutputPos) return node.getOutputPos(slotIdx);
const out = [0, 0];
node.getConnectionPos(isInput, slotIdx, out);
return out;
}
// ─── Middle-click pan passthrough for DOM widgets ───
// Allows panning the LiteGraph canvas via middle-click drag on any DOM element
export function addMiddleClickPan(element) {
const onMouseDown = (e) => {
if (e.button !== 1) return;
e.preventDefault();
const ds = app.canvas?.ds;
if (!ds) return;
const startX = e.clientX, startY = e.clientY;
const startOffsetX = ds.offset[0], startOffsetY = ds.offset[1];
const onMove = (me) => {
const scale = ds.scale || 1; // offset is graph-space; LiteGraph pans by (screen delta / scale)
ds.offset[0] = startOffsetX + (me.clientX - startX) / scale;
ds.offset[1] = startOffsetY + (me.clientY - startY) / scale;
app.canvas.setDirty(true, true);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
element.addEventListener('mousedown', onMouseDown);
return () => element.removeEventListener('mousedown', onMouseDown);
}
// ─── Resolve preview image/video from a connected source node ───
// Walks the graph link to find what's connected to node.inputs[inputSlot]
// and returns { url, isVideo, videoEl } or null
export function resolveSourcePreview(node, inputSlot) {
if (!node.graph) return null;
const input = node.inputs?.[inputSlot];
if (!input || input.link == null) return null;
const link = node.graph.links?.get(input.link);
if (!link) return null;
const srcNode = node.graph.getNodeById(link.origin_id);
if (!srcNode) return null;
// Check for VHS-style video preview widget
const vpWidget = srcNode.widgets?.find((w) => w.name === "videopreview");
if (vpWidget?.videoEl?.src) {
return { isVideo: true, videoEl: vpWidget.videoEl };
}
// Look for a LoadImage "image" widget or LoadVideo "video" widget
const w = srcNode.widgets?.find((w) => w.name === "image" || w.name === "video");
if (w?.value) {
let subfolder = "", fname = w.value;
const lastSlash = fname.lastIndexOf("/");
if (lastSlash >= 0) { subfolder = fname.substring(0, lastSlash); fname = fname.substring(lastSlash + 1); }
const isVideo = w.name === "video";
const url = `/view?filename=${encodeURIComponent(fname)}&type=input&subfolder=${encodeURIComponent(subfolder)}`;
return { url, isVideo };
}
// Fallback: check node.imgs (set after execution)
if (srcNode.imgs?.length > 0 && srcNode.imgs[0].src) {
return { url: srcNode.imgs[0].src, isVideo: false };
}
return null;
}
// ─── Source input watcher ───
// Watches IMAGE inputs for connection changes and source widget changes.
// Calls onChange(sources) when connections change or the source node's image/video widget changes.
// inputName: an input name, a predicate (input) => bool, or null for every IMAGE input.
// sources: array of { url, isVideo, videoEl, name } per connected matching input (name = that input's name).
// Returns a cleanup function.
export function watchImageInputs(node, inputName, onChange) {
let watchedWidgets = [];
const matches = (inp) => typeof inputName === "function" ? inputName(inp) : inputName ? inp.name === inputName : inp.type === "IMAGE";
const matchingSlots = () => node.inputs.map((inp, i) => matches(inp) ? i : -1).filter(i => i >= 0);
function unwatchWidgets() {
for (const { widget, origCb } of watchedWidgets) widget.callback = origCb;
watchedWidgets = [];
}
function resolve() {
if (!node.inputs) return [];
return matchingSlots().map(i => {
const s = resolveSourcePreview(node, i);
return s && { ...s, name: node.inputs[i].name };
}).filter(s => s);
}
function watch() {
unwatchWidgets();
if (!node.inputs || !node.graph) return;
const slots = matchingSlots();
for (const slotIdx of slots) {
const input = node.inputs[slotIdx];
if (!input || input.link == null) continue;
const link = node.graph.links?.get(input.link);
if (!link) continue;
const srcNode = node.graph.getNodeById(link.origin_id);
if (!srcNode) continue;
const w = srcNode.widgets?.find(w => w.name === "image" || w.name === "video");
if (!w) continue;
const origCb = w.callback;
w.callback = function (...args) {
if (origCb) origCb.apply(this, args);
setTimeout(() => onChange(resolve()), 100);
};
watchedWidgets.push({ widget: w, origCb });
}
}
chainCallback(node, "onConnectionsChange", function (type) {
// Only react to input connection changes, not output
if (type != null && type !== 1) return;
setTimeout(() => {
watch();
onChange(resolve());
}, 100);
});
chainCallback(node, "onRemoved", unwatchWidgets);
return unwatchWidgets;
}
// ─── Video frame capture ───
// Draws the current frame of a video element to a fresh canvas at native resolution.
// If the video isn't decoded yet (readyState < 2), waits once for "loadeddata" before capturing.
// Calls callback(canvas) with the resulting canvas. Caller decides whether to use it directly
// (drawImage accepts canvas) or convert via toDataURL.
export function captureVideoFrame(videoEl, callback) {
const capture = () => {
if (!videoEl.videoWidth || !videoEl.videoHeight) return;
const c = document.createElement("canvas");
c.width = videoEl.videoWidth;
c.height = videoEl.videoHeight;
c.getContext("2d").drawImage(videoEl, 0, 0);
callback(c);
};
if (videoEl.readyState >= 2) {
capture();
} else {
const onReady = () => { videoEl.removeEventListener("loadeddata", onReady); capture(); };
videoEl.addEventListener("loadeddata", onReady);
}
}
// ─── Bounding box hit test ───
// Tests whether (mx, my) hits a corner handle or the interior of a rect defined by (x1, y1)–(x2, y2).
// Returns "resize-tl", "resize-tr", "resize-bl", "resize-br", "resize-t", "resize-b", "resize-l", "resize-r", "move", or null.
export function rectHitTest(mx, my, x1, y1, x2, y2, radius) {
const hit = (cx, cy) => Math.abs(mx - cx) < radius && Math.abs(my - cy) < radius;
// Corners first (higher priority)
if (hit(x1, y1)) return "resize-tl";
if (hit(x2, y1)) return "resize-tr";
if (hit(x1, y2)) return "resize-bl";
if (hit(x2, y2)) return "resize-br";
// Edges
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < radius) return "resize-t";
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < radius) return "resize-b";
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < radius) return "resize-l";
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < radius) return "resize-r";
// Interior
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return "move";
return null;
}
// Returns the appropriate CSS cursor for a bbox hit mode string.
export function cursorForBboxMode(mode) {
if (mode === "move") return "move";
if (mode === "resize-tl" || mode === "resize-br") return "nwse-resize";
if (mode === "resize-tr" || mode === "resize-bl") return "nesw-resize";
if (mode === "resize-t" || mode === "resize-b") return "ns-resize";
if (mode === "resize-l" || mode === "resize-r") return "ew-resize";
return null;
}
// ─── Wheel zoom passthrough for DOM widgets ───
// Re-dispatches wheel events to the LiteGraph canvas for zoom
export function addWheelPassthrough(element) {
element.addEventListener("wheel", (e) => {
const gc = document.getElementById("graph-canvas");
if (gc) {
gc.dispatchEvent(new WheelEvent(e.type, e));
e.preventDefault();
}
}, { passive: false });
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB