Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
698d958085 |
@@ -0,0 +1,2 @@
|
||||
github: [kijai]
|
||||
custom: ["https://www.paypal.me/kijaidesign"]
|
||||
@@ -9,5 +9,3 @@ types
|
||||
models
|
||||
jsconfig.json
|
||||
custom_dimensions.json
|
||||
.claude
|
||||
.tests
|
||||
|
||||
@@ -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.
|
||||

|
||||
|
||||
- **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
|
||||

|
||||
|
||||
- **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:
|
||||

|
||||
|
||||
- **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
|
||||

|
||||
|
||||
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
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
source for the loras:
|
||||
https://github.com/duxiaodan/intrinsic-lora
|
||||
|
||||
Renamed and conveted to .safetensors
|
||||
Vendored
+277
@@ -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};
|
||||
+2
File diff suppressed because one or more lines are too long
+16
-28
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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]})
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+64
-326
@@ -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),)
|
||||
|
||||
@@ -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))
|
||||
+1119
-1796
File diff suppressed because it is too large
Load Diff
+117
-897
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -1,5 +1,7 @@
|
||||
pillow>=10.3.0
|
||||
scipy
|
||||
color-matcher
|
||||
matplotlib
|
||||
huggingface_hub
|
||||
mss
|
||||
opencv-python-headless
|
||||
opencv-python
|
||||
+3
-94
@@ -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
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -1,4 +1,4 @@
|
||||
const { app } = window.comfyAPI.app;
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "KJNodes.appearance",
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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})`;
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 || ""); },
|
||||
});
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.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
@@ -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);
|
||||
};
|
||||
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Reference in New Issue
Block a user