1 Commits
Author SHA1 Message Date
陈浩杰 e4110e8300 feat: init git 2024-10-11 16:01:13 +08:00
97 changed files with 73592 additions and 3574 deletions
-16
View File
@@ -1,16 +0,0 @@
__pycache__/
*.pyc
*.pyo
*.pyd
venv/
env/
*.egg-info/
*.log
*.tmp
*.temp
.DS_Store
Thumbs.db
@@ -0,0 +1,74 @@
model:
target: CRM.imagedream.ldm.interface.LatentDiffusionInterface
params:
linear_start: 0.00085
linear_end: 0.0120
timesteps: 1000
scale_factor: 0.18215
parameterization: "eps"
zero_snr: true
unet_config:
target: CRM.imagedream.ldm.modules.diffusionmodules.openaimodel.MultiViewUNetModelStage2
params:
image_size: 32 # unused
in_channels: 8
out_channels: 4
model_channels: 320
attention_resolutions: [ 4, 2, 1 ]
num_res_blocks: 2
channel_mult: [ 1, 2, 4, 4 ]
num_head_channels: 64 # need to fix for flash-attn
use_spatial_transformer: True
use_linear_in_transformer: True
transformer_depth: 1
context_dim: 1024
use_checkpoint: False
legacy: False
camera_dim: 16
with_ip: True
ip_dim: 16 # ip token length
ip_mode: "local_resample"
vae_config:
target: CRM.imagedream.ldm.models.autoencoder.AutoencoderKL
params:
embed_dim: 4
monitor: val/rec_loss
ddconfig:
#attn_type: "vanilla-xformers"
double_z: true
z_channels: 4
resolution: 256
in_channels: 3
out_ch: 3
ch: 128
ch_mult:
- 1
- 2
- 4
- 4
num_res_blocks: 2
attn_resolutions: []
dropout: 0.0
lossconfig:
target: torch.nn.Identity
clip_config:
target: CRM.imagedream.ldm.modules.encoders.modules.FrozenOpenCLIPEmbedder
params:
freeze: True
layer: "penultimate"
ip_mode: "local_resample"
sampler:
target: CRM.libs.sample.ImageDreamDiffusionStage2
params:
mode: pixel
num_frames: 6
camera_views: [1, 2, 3, 4, 5, 0]
ref_position: null
random_background: false
offset_noise: true
resize_rate: 1.0
@@ -0,0 +1,73 @@
model:
target: CRM.imagedream.ldm.interface.LatentDiffusionInterface
params:
linear_start: 0.00085
linear_end: 0.0120
timesteps: 1000
scale_factor: 0.18215
parameterization: "eps"
zero_snr: true
unet_config:
target: CRM.imagedream.ldm.modules.diffusionmodules.openaimodel.MultiViewUNetModel
params:
image_size: 32 # unused
in_channels: 4
out_channels: 4
model_channels: 320
attention_resolutions: [ 4, 2, 1 ]
num_res_blocks: 2
channel_mult: [ 1, 2, 4, 4 ]
num_head_channels: 64 # need to fix for flash-attn
use_spatial_transformer: True
use_linear_in_transformer: True
transformer_depth: 1
context_dim: 1024
use_checkpoint: False
legacy: False
camera_dim: 16
with_ip: True
ip_dim: 16 # ip token length
ip_mode: "local_resample"
vae_config:
target: CRM.imagedream.ldm.models.autoencoder.AutoencoderKL
params:
embed_dim: 4
monitor: val/rec_loss
ddconfig:
#attn_type: "vanilla-xformers"
double_z: true
z_channels: 4
resolution: 256
in_channels: 3
out_ch: 3
ch: 128
ch_mult:
- 1
- 2
- 4
- 4
num_res_blocks: 2
attn_resolutions: []
dropout: 0.0
lossconfig:
target: torch.nn.Identity
clip_config:
target: CRM.imagedream.ldm.modules.encoders.modules.FrozenOpenCLIPEmbedder
params:
freeze: True
layer: "penultimate"
ip_mode: "local_resample"
sampler:
target: CRM.libs.sample.ImageDreamDiffusion
params:
mode: pixel
num_frames: 7
camera_views: [1, 2, 3, 4, 5, 0, 0]
ref_position: 6
random_background: false
offset_noise: true
resize_rate: 1.0
@@ -0,0 +1,57 @@
{
"Input": {
"img_num": 16,
"class": "all",
"camera_angle_num": 8,
"tet_grid_size": 80,
"validate_num": 16,
"scale": 0.95,
"radius": 3,
"resolution": [256, 256]
},
"Pretrain": {
"mode": null,
"sdf_threshold": 0.1,
"sdf_scale": 10,
"batch_infer": false,
"lr": 1e-4,
"radius": 0.5
},
"Train": {
"mode": "rnd",
"num_epochs": 500,
"grad_acc": 1,
"warm_up": 0,
"decay": 0.000,
"learning_rate": {
"init": 1e-4,
"sdf_decay": 1,
"rgb_decay": 1
},
"batch_size": 4,
"eva_iter": 80,
"eva_all_epoch": 10,
"tex_sup_mode": "blender",
"exp_uv_mesh": false,
"doub": false,
"random_bg": false,
"shift": 0,
"aug_shift": 0,
"geo_type": "flex"
},
"ArchSpecs": {
"unet_type": "diffusers",
"use_3D_aware": false,
"fea_concat": false,
"mlp_bias": true
},
"DecoderSpecs": {
"c_dim": 32,
"plane_resolution": 256
}
}
@@ -0,0 +1,23 @@
pretrained_model_path: "stabilityai/stable-diffusion-2-1"
image_encoder_path: "./2D_Stage/models/image_encoder"
ckpt_dir: "./2D_Stage/models/checkpoint"
validation:
use_inv_latent: False
video_length: 4
use_pose_guider: True
use_noise: False
use_shifted_noise: False
unet_condition_type: image
unet_from_pretrained_kwargs:
camera_embedding_type: 'e_de_da_sincos'
projection_class_embeddings_input_dim: 10 # modify
joint_attention: false # modify
num_views: 4
sample_size: 96
zero_init_conv_in: false
zero_init_camera_projection: false
in_channels: 4
use_safetensors: true
@@ -0,0 +1,104 @@
system_cls: CharacterGen.Stage_3D.lrm.systems.multiview_lrm.MultiviewLRM
data:
cond_width: 504
cond_height: 504
system:
weights: 3D_Stage/models/lrm.ckpt
weights_ignore_modules:
- decoder.heads.density
check_train_every_n_steps: 100
camera_embedder_cls: CharacterGen.Stage_3D.lrm.models.camera.LinearCameraEmbedder
camera_embedder:
in_channels: 16
out_channels: 768
conditions:
- c2w_cond
# image tokenizer transforms input images to tokens
image_tokenizer_cls: CharacterGen.Stage_3D.lrm.models.tokenizers.image.DINOV2SingleImageTokenizer
image_tokenizer:
pretrained_model_name_or_path: "3D_Stage/models/base"
freeze_backbone_params: false
enable_memory_efficient_attention: true
enable_gradient_checkpointing: true
# camera modulation to the DINO transformer layers
modulation: true
modulation_zero_init: true
modulation_single_layer: true
modulation_cond_dim: ${system.camera_embedder.out_channels}
# tokenizer gives a tokenized representation for the 3D scene
# triplane tokens in this case
tokenizer_cls: CharacterGen.Stage_3D.lrm.models.tokenizers.triplane.TriplaneLearnablePositionalEmbedding
tokenizer:
plane_size: 32
num_channels: 512
# backbone network is a transformer that takes scene tokens (potentially with conditional image tokens)
# and outputs scene tokens of the same size
backbone_cls: CharacterGen.Stage_3D.lrm.models.transformers.transformer_1d.Transformer1D
backbone:
in_channels: ${system.tokenizer.num_channels}
num_attention_heads: 16
attention_head_dim: 64
num_layers: 12
cross_attention_dim: 768 # hard-code, =DINO feature dim
# camera modulation to the transformer layers
# if not needed, set norm_type=layer_norm and do not specify cond_dim_ada_norm_continuous
norm_type: "layer_norm"
enable_memory_efficient_attention: true
gradient_checkpointing: true
# post processor takes scene tokens and outputs the final scene parameters that will be used for rendering
# in this case, triplanes are upsampled and the features are condensed
post_processor_cls: CharacterGen.Stage_3D.lrm.models.networks.TriplaneUpsampleNetwork
post_processor:
in_channels: 512
out_channels: 80
renderer_cls: CharacterGen.Stage_3D.lrm.models.renderers.triplane_dmtet.TriplaneDMTetRenderer
renderer:
radius: 0.6 # slightly larger than 0.5
feature_reduction: concat
sdf_bias: -2.
tet_dir: "tets"
isosurface_resolution: 256
enable_isosurface_grid_deformation: false
sdf_activation: negative
decoder_cls: CharacterGen.Stage_3D.lrm.models.networks.MultiHeadMLP
decoder:
in_channels: 240 # 3 * 80
n_neurons: 64
n_hidden_layers_share: 8
heads:
- name: sdf
out_channels: 1
n_hidden_layers: 1
output_activation: null
- name: features
out_channels: 3
n_hidden_layers: 1
output_activation: null # activate in material
activation: silu
chunk_mode: deferred
chunk_size: 131072
exporter:
fmt: "obj"
#visual: "vertex"
visual: "uv"
save_uv: True
save_texture: True
uv_unwrap_method: "open3d"
output_path: "3D_Stage/outputs"
material_cls: CharacterGen.Stage_3D.lrm.models.materials.no_material.NoMaterial
background_cls: CharacterGen.Stage_3D.lrm.models.background.solid_color_background.SolidColorBackground
background:
color: [0.5, 0.5, 0.5]
+143
View File
@@ -0,0 +1,143 @@
name: michelangelo-image-to-shape-diffusion/clip-mvrgb-modln-l256-e64-ne8-nd16-nl6-170k
description: ''
tag: michelangelo-aligned-autoencoder+n4096+noise0.0+pfeat3+zeroemb0.0+normembFalse+lr5e-05+qkvbiasFalse+nfreq8+ln_postTrue
seed: 0
use_timestamp: true
timestamp: ''
exp_root_dir: outputs
exp_dir: outputs/michelangelo-image-to-shape-diffusion/clip-mvrgb-modln-l256-e64-ne8-nd16-nl6-170k
trial_name: michelangelo-aligned-autoencoder+n4096+noise0.0+pfeat3+zeroemb0.0+normembFalse+lr5e-05+qkvbiasFalse+nfreq8+ln_postTrue
trial_dir: outputs/michelangelo-image-to-shape-diffusion/clip-mvrgb-modln-l256-e64-ne8-nd16-nl6-170k/michelangelo-aligned-autoencoder+n4096+noise0.0+pfeat3+zeroemb0.0+normembFalse+lr5e-05+qkvbiasFalse+nfreq8+ln_postTrue
n_gpus: 8
resume: ./ckpts/3DNativeGeneration/michelangelo-image-to-shape-diffusion/clip-mvrgb-modln-l256-e64-ne8-nd16-nl6-170k.ckpt
data_type: objaverse-datamodule
data:
root_dir: data/objaverse_clean/cap3d_high_quality_170k_images
data_type: occupancy
n_samples: 4096
noise_sigma: 0.0
load_supervision: false
supervision_type: occupancy
n_supervision: 10000
load_image: true
image_data_path: data/objaverse_clean/raw_data/images/cap3d_high_quality_170k
image_type: mvrgb
idx:
- 0
- 4
- 8
- 12
- 16
n_views: 4
load_caption: false
rotate_points: false
batch_size: 32
num_workers: 16
system_type: shape-diffusion-system
system:
val_samples_json: val_data/mv_images/val_samples_rgb_mvimage.json
z_scale_factor: 1.0
guidance_scale: 7.5
num_inference_steps: 50
eta: 0.0
shape_model_type: michelangelo-aligned-autoencoder
shape_model:
num_latents: 256
embed_dim: 64
point_feats: 3
out_dim: 1
num_freqs: 8
include_pi: false
heads: 12
width: 768
num_encoder_layers: 8
num_decoder_layers: 16
use_ln_post: true
init_scale: 0.25
qkv_bias: false
use_flash: true
use_checkpoint: true
condition_model_type: clip-embedder
condition_model:
pretrained_model_name_or_path: openai/clip-vit-large-patch14
encode_camera: true
camera_embeds_dim: 32
n_views: 4
empty_embeds_ratio: 0.1
normalize_embeds: false
zero_uncond_embeds: true
denoiser_model_type: simple-denoiser
denoiser_model:
input_channels: 64
output_channels: 64
n_ctx: 256
width: 768
layers: 6
heads: 12
context_dim: 1024
init_scale: 1.0
skip_ln: true
use_checkpoint: true
noise_scheduler_type: diffusers.schedulers.DDPMScheduler
noise_scheduler:
num_train_timesteps: 1000
beta_start: 0.00085
beta_end: 0.012
beta_schedule: scaled_linear
variance_type: fixed_small
clip_sample: false
denoise_scheduler_type: diffusers.schedulers.DDIMScheduler
denoise_scheduler:
num_train_timesteps: 1000
beta_start: 0.00085
beta_end: 0.012
beta_schedule: scaled_linear
clip_sample: false
set_alpha_to_one: false
steps_offset: 1
loggers:
wandb:
enable: false
project: JiangXin
name: text-to-shape-diffusion+michelangelo-image-to-shape-diffusion/clip-mvrgb-modln-l256-e64-ne8-nd16-nl6-170k+michelangelo-aligned-autoencoder+n4096+noise0.0+pfeat3+zeroemb0.0+normembFalse+lr5e-05+qkvbiasFalse+nfreq8+ln_postTrue
loss:
loss_type: mse
lambda_diffusion: 1.0
optimizer:
name: AdamW
args:
lr: 5.0e-05
betas:
- 0.9
- 0.99
eps: 1.0e-06
scheduler:
name: SequentialLR
interval: step
schedulers:
- name: LinearLR
interval: step
args:
start_factor: 1.0e-06
end_factor: 1.0
total_iters: 5000
- name: CosineAnnealingLR
interval: step
args:
T_max: 5000
eta_min: 0.0
milestones:
- 5000
trainer:
num_nodes: 2
max_epochs: 100000
log_every_n_steps: 5
num_sanity_val_steps: 1
check_val_every_n_epoch: 3
enable_progress_bar: true
precision: 16-mixed
strategy: ddp_find_unused_parameters_true
checkpoint:
save_last: true
save_top_k: -1
every_n_train_steps: 5000
+8
View File
@@ -0,0 +1,8 @@
num_views: 6
dataset:
prompt_embeds_path: Gen_3D_Modules/Era3D/mvdiffusion/data/fixed_prompt_embeds_6view
num_views: ${num_views}
bg_color: 'white'
img_wh: [512, 512]
num_validation_samples: 1000
@@ -0,0 +1,15 @@
model_config:
target: InstantMesh.models.lrm_mesh.InstantMesh
params:
encoder_feat_dim: 768
encoder_freeze: false
encoder_model_name: facebook/dino-vitb16
transformer_dim: 1024
transformer_layers: 12
transformer_heads: 16
triplane_low_res: 32
triplane_high_res: 64
triplane_dim: 40
rendering_samples_per_ray: 96
grid_res: 128
grid_scale: 2.1
@@ -0,0 +1,15 @@
model_config:
target: InstantMesh.models.lrm_mesh.InstantMesh
params:
encoder_feat_dim: 768
encoder_freeze: false
encoder_model_name: facebook/dino-vitb16
transformer_dim: 1024
transformer_layers: 16
transformer_heads: 16
triplane_low_res: 32
triplane_high_res: 64
triplane_dim: 80
rendering_samples_per_ray: 128
grid_res: 128
grid_scale: 2.1
@@ -0,0 +1,13 @@
model_config:
target: InstantMesh.models.lrm.InstantNeRF
params:
encoder_feat_dim: 768
encoder_freeze: false
encoder_model_name: facebook/dino-vitb16
transformer_dim: 1024
transformer_layers: 12
transformer_heads: 16
triplane_low_res: 32
triplane_high_res: 64
triplane_dim: 40
rendering_samples_per_ray: 96
@@ -0,0 +1,13 @@
model_config:
target: InstantMesh.models.lrm.InstantNeRF
params:
encoder_feat_dim: 768
encoder_freeze: false
encoder_model_name: facebook/dino-vitb16
transformer_dim: 1024
transformer_layers: 16
transformer_heads: 16
triplane_low_res: 32
triplane_high_res: 64
triplane_dim: 80
rendering_samples_per_ray: 128
+96
View File
@@ -0,0 +1,96 @@
cond_image_size: 512
isosurface_resolution: 160
radius: 0.87
camera_embedder_cls: StableFast3D.sf3d.models.camera.LinearCameraEmbedder
camera_embedder:
in_channels: 25
out_channels: 768
conditions:
- c2w_cond
- intrinsic_normed_cond
image_tokenizer_cls: StableFast3D.sf3d.models.tokenizers.image.DINOV2SingleImageTokenizer
image_tokenizer:
pretrained_model_name_or_path: "facebook/dinov2-large"
width: 512
height: 512
modulation_cond_dim: 768
tokenizer_cls: StableFast3D.sf3d.models.tokenizers.triplane.TriplaneLearnablePositionalEmbedding
tokenizer:
plane_size: 96
num_channels: 1024
backbone_cls: StableFast3D.sf3d.models.transformers.backbone.TwoStreamInterleaveTransformer
backbone:
num_attention_heads: 16
attention_head_dim: 64
raw_triplane_channels: 1024
triplane_channels: 1024
raw_image_channels: 1024 # DINO features
num_latents: 1792
num_blocks: 4
num_basic_blocks: 3
post_processor_cls: StableFast3D.sf3d.models.network.PixelShuffleUpsampleNetwork
post_processor:
in_channels: 1024
out_channels: 40
scale_factor: 4
conv_layers: 4
decoder_cls: StableFast3D.sf3d.models.network.MaterialMLP
decoder:
in_channels: 120
n_neurons: 64
activation: silu
heads:
- name: density
out_channels: 1
out_bias: -1.0
n_hidden_layers: 2
output_activation: trunc_exp
- name: features
out_channels: 3
n_hidden_layers: 3
output_activation: sigmoid
- name: perturb_normal
out_channels: 3
n_hidden_layers: 3
output_activation: normalize_channel_last
- name: vertex_offset
out_channels: 3
n_hidden_layers: 2
image_estimator_cls: StableFast3D.sf3d.models.image_estimator.clip_based_estimator.ClipBasedHeadEstimator
image_estimator:
distribution: beta
distribution_eval: mode
heads:
- name: roughness
out_channels: 1
n_hidden_layers: 3
output_activation: linear
add_to_decoder_features: true
output_bias: 1.0
shape: [-1, 1, 1]
- name: metallic
out_channels: 1
n_hidden_layers: 3
output_activation: linear
add_to_decoder_features: true
output_bias: 1.0
shape: [-1, 1, 1]
global_estimator_cls: StableFast3D.sf3d.models.global_estimator.multi_head_estimator.MultiHeadEstimator
global_estimator:
triplane_features: 1024
heads:
- name: sg_amplitudes
out_channels: 24
n_hidden_layers: 3
output_activation: softplus
output_bias: 1.0
shape: [-1, 24, 1]
+150
View File
@@ -0,0 +1,150 @@
data:
background_color: [1.0, 1.0, 1.0]
cond_width: 252 # multiply of 14
cond_height: 252
relative_pose: true
num_workers: 16
eval_batch_size: 1
eval_height: 512
eval_width: 512
system:
camera_embedder_cls: TriplaneGaussian.models.networks.MLP
camera_embedder:
dim_in: 25 # c2w + [fx, fy, cx, cy]
dim_out: 768
n_neurons: 768
n_hidden_layers: 1
activation: silu
image_feature:
out_dim: 773
image_tokenizer_cls: TriplaneGaussian.models.tokenizers.image.DINOV2SingleImageTokenizer
image_tokenizer:
pretrained_model_name_or_path: "facebook/dinov2-base"
width: ${data.cond_width}
height: ${data.cond_height}
modulation: true
modulation_zero_init: true
modulation_single_layer: true
modulation_cond_dim: ${system.camera_embedder.dim_out} # c2w + intrinsic
freeze_backbone_params: false
enable_memory_efficient_attention: ${system.backbone.enable_memory_efficient_attention}
enable_gradient_checkpointing: ${system.backbone.gradient_checkpointing}
tokenizer_cls: TriplaneGaussian.models.tokenizers.triplane.TriplaneLearnablePositionalEmbedding
tokenizer:
plane_size: 32
num_channels: 512
backbone_cls: TriplaneGaussian.models.transformers.Transformer1D
backbone:
in_channels: ${system.tokenizer.num_channels}
num_attention_heads: 8
attention_head_dim: 64
num_layers: 10
cross_attention_dim: 768 # hard-code, =DINO feature dim
norm_type: "layer_norm"
enable_memory_efficient_attention: false
gradient_checkpointing: false
post_processor_cls: TriplaneGaussian.models.networks.TriplaneUpsampleNetwork
post_processor:
in_channels: ${system.tokenizer.num_channels}
out_channels: 80
pointcloud_generator_cls: TriplaneGaussian.models.pointclouds.simplepoint.SimplePointGenerator
pointcloud_generator:
camera_embedder_cls: TriplaneGaussian.models.networks.MLP
camera_embedder:
dim_in: 25 # c2w + [fx, fy, cx, cy]
dim_out: 768
n_neurons: 768
n_hidden_layers: 1
activation: silu
image_tokenizer_cls: TriplaneGaussian.models.tokenizers.image.DINOV2SingleImageTokenizer
image_tokenizer:
pretrained_model_name_or_path: "facebook/dinov2-base"
width: ${data.cond_width}
height: ${data.cond_height}
modulation: true
modulation_zero_init: true
modulation_single_layer: true
modulation_cond_dim: ${system.camera_embedder.dim_out} # c2w + intrinsic
freeze_backbone_params: true
enable_memory_efficient_attention: ${system.backbone.enable_memory_efficient_attention}
enable_gradient_checkpointing: false
tokenizer_cls: TriplaneGaussian.models.tokenizers.point.PointLearnablePositionalEmbedding
tokenizer:
num_pcl: 2048
num_channels: 512
backbone_cls: TriplaneGaussian.models.transformers.Transformer1D
backbone:
in_channels: ${system.pointcloud_generator.tokenizer.num_channels}
num_attention_heads: 8
attention_head_dim: 64
num_layers: 10
cross_attention_dim: 768 # hard-code, =DINO feature dim
norm_type: "layer_norm"
enable_memory_efficient_attention: ${system.backbone.enable_memory_efficient_attention}
gradient_checkpointing: ${system.backbone.gradient_checkpointing}
post_processor_cls: TriplaneGaussian.models.networks.PointOutLayer
post_processor:
in_channels: 512
out_channels: 3
pointcloud_upsampling_cls: TriplaneGaussian.models.snowflake.model_spdpp.SnowflakeModelSPDPP
pointcloud_upsampling:
input_channels: 768
dim_feat: 128
num_p0: 2048
radius: 1
bounding: true
use_fps: true
up_factors: [2,4]
token_type: "image_token"
pointcloud_encoder_cls: TriplaneGaussian.models.pointclouds.pointnet.LocalPoolPointnet
pointcloud_encoder:
input_channels: 776 # 3 + 3 + 768 + 1 + 1 [xyz, local features]
c_dim: ${system.tokenizer.num_channels}
hidden_dim: 128
plane_size: ${system.tokenizer.plane_size}
n_blocks: 5
radius: ${system.renderer.radius}
renderer_cls: TriplaneGaussian.models.renderer.GS3DRenderer
renderer:
sh_degree: 3
radius: 0.6
mlp_network_config:
n_neurons: ${system.renderer.gs_out.in_channels}
n_hidden_layers: 2
activation: silu
gs_out:
in_channels: 128
xyz_offset: true
restrict_offset: true
use_rgb: false
feature_channels:
xyz: 3
scaling: 3
rotation: 4
opacity: 1
shs: ${shsdim:${system.renderer.sh_degree}}
clip_scaling: 0.2
+38
View File
@@ -0,0 +1,38 @@
cond_image_size: 512
image_tokenizer_cls: TripoSR.models.tokenizers.image.DINOSingleImageTokenizer
image_tokenizer:
pretrained_model_name_or_path: "facebook/dino-vitb16"
tokenizer_cls: TripoSR.models.tokenizers.triplane.Triplane1DTokenizer
tokenizer:
plane_size: 32
num_channels: 1024
backbone_cls: TripoSR.models.transformer.transformer_1d.Transformer1D
backbone:
in_channels: ${tokenizer.num_channels}
num_attention_heads: 16
attention_head_dim: 64
num_layers: 16
cross_attention_dim: 768
post_processor_cls: TripoSR.models.network_utils.TriplaneUpsampleNetwork
post_processor:
in_channels: 1024
out_channels: 40
decoder_cls: TripoSR.models.network_utils.NeRFMLP
decoder:
in_channels: 120 # 3 * 40
n_neurons: 64
n_hidden_layers: 9
activation: silu
renderer_cls: TripoSR.models.nerf_renderer.TriplaneNeRFRenderer
renderer:
radius: 0.87 # slightly larger than 0.5 * sqrt(3)
feature_reduction: concat
density_activation: exp
density_bias: -1.0
num_samples_per_ray: 128
@@ -0,0 +1,43 @@
init_config:
# enable controls
enable_cross_attn_lora: False
enable_cross_attn_ip: False
enable_self_attn_lora: False
enable_self_attn_ref: False
enable_multiview_attn: True
# for cross attention
init_cross_attn_lora: False
init_cross_attn_ip: False
cross_attn_lora_rank: 256 # 0 for not enabled
cross_attn_lora_only_kv: False
ipadapter_pretrained_name: "h94/IP-Adapter"
ipadapter_subfolder_name: "models"
ipadapter_weight_name: "ip-adapter_sd15.safetensors"
ipadapter_effect_on: "all" # all, first
# for self attention
init_self_attn_lora: False
self_attn_lora_rank: 256
self_attn_lora_only_kv: False
# for self attention ref
init_self_attn_ref: False
self_attn_ref_position: "attn1"
self_attn_ref_other_model_name: "lambdalabs/sd-image-variations-diffusers"
self_attn_ref_pixel_wise_crosspond: False
self_attn_ref_effect_on: "all"
# for multiview attention
init_multiview_attn: True
multiview_attn_position: "attn1"
use_mv_joint_attn: True
num_modalities: 1
# for unet
init_unet_path: ""
cat_condition: True # cat condition to input
# for cls embedding
init_num_cls_label: 8 # for initialize
cls_labels: [0, 1, 2, 3] # for current task
@@ -0,0 +1,44 @@
pretrained_model_name_or_path: "lambdalabs/sd-image-variations-diffusers"
init_config:
# enable controls
enable_cross_attn_lora: False
enable_cross_attn_ip: False
enable_self_attn_lora: False
enable_self_attn_ref: True
enable_multiview_attn: False
# for cross attention
init_cross_attn_lora: False
init_cross_attn_ip: False
cross_attn_lora_rank: 512 # 0 for not enabled
cross_attn_lora_only_kv: False
ipadapter_pretrained_name: "h94/IP-Adapter"
ipadapter_subfolder_name: "models"
ipadapter_weight_name: "ip-adapter_sd15.safetensors"
ipadapter_effect_on: "all" # all, first
# for self attention
init_self_attn_lora: False
self_attn_lora_rank: 512
self_attn_lora_only_kv: False
# for self attention ref
init_self_attn_ref: True
self_attn_ref_position: "attn1"
self_attn_ref_other_model_name: "lambdalabs/sd-image-variations-diffusers"
self_attn_ref_pixel_wise_crosspond: True
self_attn_ref_effect_on: "all"
# for multiview attention
init_multiview_attn: False
multiview_attn_position: "attn1"
num_modalities: 1
# for unet
init_unet_path: "${pretrained_model_name_or_path}"
init_num_cls_label: 0 # for initialize
cls_labels: [] # for current task
+42
View File
@@ -0,0 +1,42 @@
#pretrained_model_name_or_path: 'flamehaze1115/wonder3d-v1.0' # or './ckpts'
revision: null
validation_dataset:
root_dir: "./example_images" # the folder path stores testing images
num_views: 6
bg_color: 'white'
img_wh: [256, 256]
num_validation_samples: 1000
crop_size: 192
filepaths: ['owl.png'] # the test image names. leave it empty, test all images in the folder
save_dir: 'outputs/'
pred_type: 'joint'
seed: 42
validation_batch_size: 1
dataloader_num_workers: 64
local_rank: -1
pipe_kwargs:
camera_embedding_type: 'e_de_da_sincos'
num_views: 6
validation_guidance_scales: [1.0]
pipe_validation_kwargs:
eta: 1.0
validation_grid_nrow: 6
unet_from_pretrained_kwargs:
camera_embedding_type: 'e_de_da_sincos'
projection_class_embeddings_input_dim: 10
num_views: 6
sample_size: 32
cd_attention_mid: true
zero_init_conv_in: false
zero_init_camera_projection: false
num_views: 6
camera_embedding_type: 'e_de_da_sincos'
enable_xformers_memory_efficient_attention: true
+14
View File
@@ -0,0 +1,14 @@
web{
clients_ip = [
127.0.0.1,
0.0.0.0,
// docker have default ip for host in container of subnet 172.17.0.0/16
// althrought there would be 172.18.0.0
172.17.0.0,
172.17.0.1,
]
}
huggingface{
token = "" # Your user access token to enable automatic download restricted model card
}
-137
View File
@@ -1,137 +0,0 @@
# ComfyUI-Rodin
**Comfyui-rodin** is a 3D generation extension based on [Rodin](https://hyper3d.ai/)-API. It provides many of the functionality nodes currently available in RodinAPI, such as Image-to-3D, Text-to-3D, Multiple Images-to-3D, etc. In addition, the extension provides a 3D preview node for ComfyUI.
## Generate Rodin Models via ComfyUI Custom Node​
This ​​ComfyUI custom node​​ integrates with the Hyper3D API to generate 3D assets using Rodin. Get more information about 'How to use Rodin API' and 'How to get Rodin API KEY' from [Rodin API document](https://developer.hyper3d.ai/)
## Installation
1. **Can be installed directly from [ComfyUI-Rodin](https://github.com/DeemosTech/ComfyUI-Rodin.git)**
Clone the repository:
`git clone https://github.com/DeemosTech/ComfyUI-Rodin.git`
Move the cloned repository to your ComfyUI `custom_nodes` directory.
2. **Can be installed from ComfyUI-Manager**
## Dependencies
This extension requires the following Python packages:
- `aiohttp` - For asynchronous HTTP requests to the Rodin API
- `asyncio` - For asynchronous operations
You can install the dependencies using the provided `requirements.txt` file:
```bash
pip install -r requirements.txt
```
## Update
1. Navigate to the cloned repo e.g. `custom_nodes/ComfyUI-Rodin`
2. `git pull`
3. Reinstall dependencies if needed: `pip install -r requirements.txt`
## Features
- **Rodin Nodes Overview**
- ![image](asset/RodinAllNode.png)
This extension provides several utility nodes for Rodin generation and Controlnet use.
- **The Simplest way to use**
- ![image](asset/RodinSimplestUse.png)
Get your API key from [Rodin api-dashboard](https://hyper3d.ai/api-dashboard) and fill it into the <strong>"Rodin - API KEY"</strong> node.
All the Rodin generation nodes need to be linked with <strong>"Rodin - API KEY"</strong>.
- **Multi-view Rodin Generate**
- ![image](asset/RodinMul.png)
- **How to use Boundbox ControlNet**
- ![image](asset/RodinBBox.png)
- **How to set Model height (in cm)**
- ![image](asset/RodinHeight.png)
## Node Types
### Gen 1/1.5 Nodes
- **Rodin - Regular Generate** - Standard 3D generation
- **Rodin - Detail Generate** - High-detail 3D generation
- **Rodin - Smooth Generate** - Smooth 3D generation
- **Rodin - Sketch Generate** - Sketch-based 3D generation
### Gen 2 Nodes
- **Rodin - Gen2 Generate** - Gen-2 model generation
### Gen 2.5 Nodes
- **Rodin - Gen 2.5 Fast Image-to-3D** - Fast image-based 3D generation
- **Rodin - Gen 2.5 Fast Text-to-3D** - Fast text-based 3D generation
- **Rodin - Gen 2.5 Regular Image-to-3D** - Regular image-based 3D generation
- **Rodin - Gen 2.5 Regular Text-to-3D** - Regular text-based 3D generation
- **Rodin - Gen 2.5 ExtremeHigh Image-to-3D** - High-quality image-based 3D generation
- **Rodin - Gen 2.5 ExtremeHigh Text-to-3D** - High-quality text-based 3D generation
### Other Nodes
- **Rodin - API KEY** - Input your Rodin API key
- **Rodin - BBox Controlnet** - Control 3D model dimensions
<details>
<summary> Obsolete node​s </summary>
- **Rodin - Image to 3D**
- Single image to 3D Mesh with Textures(PBR/Shaded)
- A successful run will download the 3D model to `ComfyUI/output` directory.
- ![image](asset/RodinImage23D.png)
The **image** and **api_key** must be supplied, and other options can be adjusted. Refer to the [RodinAPI documentation](https://developer.hyper3d.ai/api-specification/overview) for parameter information.
- **Rodin - Text to 3D**
- Prompt text to 3D Mesh with Textures(PBR/Shaded)
- A successful run will download the 3D model to `ComfyUI/output` directory.
- ![image](asset/RodinText23D.png)
The **Prompt** and **api_key** must be supplied, and other options can be adjusted. Refer to the [RodinAPI documentation](https://developer.hyper3d.ai/api-specification/overview) for parameter information.
- **Rodin - Multiple Images to 3D**
- Multiple Images to 3D Mesh with Textures(PBR/Shaded)
- A successful run will download the 3D model to `ComfyUI/output` directory.
- Multiple images can be different views of the same object or different objects. At least one image should be supplied.
- ![image](asset/RodinMul23D.png)
The **images(At least one)** and **api_key** must be supplied, and other options can be adjusted. Refer to the [RodinAPI documentation](https://developer.hyper3d.ai/api-specification/overview) for parameter information.
- **Rodin - Preview 3D Mesh**
- 3D Model preview node with support for multiple formats of PBR and Shaded rendering.
- The currently supported model types are: `obj`, `glb`, `fbx`, `stl`.
- The rendering mode can be switched.
- ![image](asset/RodinPreview.png)
</details>
## Troubleshooting
### Common Issues
1. **API Key Error** - Ensure you've entered a valid Rodin API key from the [Rodin api-dashboard](https://hyper3d.ai/api-dashboard)
2. **Generation Failed** - Check your internet connection and ensure you have sufficient API credits
3. **Model Download Issues** - Ensure you have write permissions to the ComfyUI output directory
### Error Messages
- `MISS_API_KEY` - API key is missing
- `MISS_IMAGES_OR_PROMPT` - No images or prompt provided
- `RODIN_ERROR` - Error from the Rodin API
- `NO_JOBS_FOUND` - No jobs found for the given subscription key
- `MISS_SUBSCRIPTION_KEY` - Subscription key is missing
- `MISS_JOB_UUID` - Job UUID is missing
- `NO_FILES_FOUND` - No files found for download
- `UNKNOWN_ERROR` - Unexpected error occurred
+39 -31
View File
@@ -1,33 +1,41 @@
from .hyper3d_nodes import *
import importlib.util
import glob
import os
import sys
import inspect
import folder_paths as comfy_paths
from pyhocon import ConfigFactory
from .webserver.server import server, set_web_conf
from .shared_utils.log_utils import setup_logger
from .pysssss import init, get_ext_dir
NODE_CLASS_MAPPINGS = {
"mLoadRodinAPIKEY": mLoadRodinAPIKEY,
"mRodin3D_Regular": mRodin3D_Regular,
"mRodin3D_Detail": mRodin3D_Detail,
"mRodin3D_Smooth": mRodin3D_Smooth,
"mRodin3D_Sketch": mRodin3D_Sketch,
"mRodin3D_Gen2": mRodin3D_Gen2,
"mRodin3D_bbox_controlnet": mRodin3D_bbox_controlnet,
"mRodin3D_Gen_2_5_Fast_Image": mRodin3D_Gen_2_5_Fast_Image,
"mRodin3D_Gen_2_5_Fast_Text": mRodin3D_Gen_2_5_Fast_Text,
"mRodin3D_Gen_2_5_Regular_Image": mRodin3D_Gen_2_5_Regular_Image,
"mRodin3D_Gen_2_5_Regular_Text": mRodin3D_Gen_2_5_Regular_Text,
"mRodin3D_Gen_2_5_ExtremeHigh_Image": mRodin3D_Gen_2_5_ExtremeHigh_Image,
"mRodin3D_Gen_2_5_ExtremeHigh_Text": mRodin3D_Gen_2_5_ExtremeHigh_Text,
}
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {
"mLoadRodinAPIKEY": "Rodin - API KEY",
"mRodin3D_Regular": "Rodin - Regular Generate",
"mRodin3D_Detail": "Rodin - Detail Generate",
"mRodin3D_Smooth": "Rodin - Smooth Generate",
"mRodin3D_Sketch": "Rodin - Sketch Generate",
"mRodin3D_Gen2": "Rodin - Gen2 Generate",
"mRodin3D_bbox_controlnet": "Rodin - BBox Controlnet",
"mRodin3D_Gen_2_5_Fast_Image": "Rodin - Gen 2.5 Fast Image-to-3D",
"mRodin3D_Gen_2_5_Fast_Text": "Rodin - Gen 2.5 Fast Text-to-3D",
"mRodin3D_Gen_2_5_Regular_Image": "Rodin - Gen 2.5 Regular Image-to-3D",
"mRodin3D_Gen_2_5_Regular_Text": "Rodin - Gen 2.5 Regular Text-to-3D",
"mRodin3D_Gen_2_5_ExtremeHigh_Image": "Rodin - Gen 2.5 ExtremeHigh Image-to-3D",
"mRodin3D_Gen_2_5_ExtremeHigh_Text": "Rodin - Gen 2.5 ExtremeHigh Text-to-3D",
}
ROOT_PATH = os.path.join(comfy_paths.get_folder_paths("custom_nodes")[0], "ComfyUI-Rodin")
sys.path.append(ROOT_PATH)
conf_path = os.path.join(ROOT_PATH, "Configs/system.conf")
f = open(conf_path)
conf_text = f.read()
f.close()
sys_conf = ConfigFactory.parse_string(conf_text)
set_web_conf(sys_conf['web'])
if init():
py = get_ext_dir("py")
files = glob.glob(os.path.join(py, "*.py"), recursive=False)
for file in files:
name = os.path.splitext(file)[0]
spec = importlib.util.spec_from_file_location(name, file)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
if hasattr(module, "NODE_CLASS_MAPPINGS") and getattr(module, "NODE_CLASS_MAPPINGS") is not None:
NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None:
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

-565
View File
@@ -1,565 +0,0 @@
from .modules.Rodin_Mainsite import process_full_generation, full_polygen_pipeline, QUALITY_MESH_DEFAULT, QUALITY_MESH_OPTIONS
RODIN_GEN_1_5_PARAS = {
"images": ("IMAGE", {"forceInput": True, "multiline": True}),
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"Material_Type": (["PBR", "Shaded", "All", "None"], {"default":"PBR"}),
"Polygon_count": (["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "200K-Quad", "200K-Triangle"], {"default": "18K-Quad"}),
}
RODIN_GEN_2_PARAS = {
"images": ("IMAGE", {"forceInput": True, "multiline": True}),
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"Material_Type": (["PBR", "Shaded", "All", "None"], {"default":"PBR"}),
"Polygon_count": (["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "200K-Quad", "2K-Triangle", "20K-Triangle", "150K-Triangle", "500K-Triangle", "1M-Triangle"], {"default": "500K-Triangle"}),
"TAPose": ("BOOLEAN", {"default": False}),
}
class mLoadRodinAPIKEY:
RETURN_TYPES = ("APIKEY",)
RETURN_NAMES = ("api_key",)
FUNCTION = "main_func"
CATEGORY = "Mesh/Rodin"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("STRING", {"default": "Get your API KEY from: https://hyper3d.ai/api-dashboard", "multiline": True})
},
}
async def main_func(self, api_key):
return (api_key,)
class mRodin3D_bbox_controlnet():
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("bbox",)
FUNCTION = "main_func"
CATEGORY = "Mesh/Rodin"
@classmethod
def INPUT_TYPES(s):
return {
"required":{
"Width": ("INT", {"default": 100, "min": 1, "max": 300, "step": 1, "display": "number"}),
"Height": ("INT", {"default": 100, "min": 1, "max": 300, "step": 1, "display": "number"}),
"Length": ("INT", {"default": 100, "min": 1, "max": 300, "step": 1, "display": "number"}),
},
}
async def main_func(self, Width, Height, Length):
bbox_control = str([Width, Height, Length])
# print(bbox_control)
return (bbox_control,)
class Rodin3D_simple():
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("model_path",)
FUNCTION = "main_func"
OUTPUT_NODE = True
CATEGORY = "Mesh/Rodin"
class mRodin3D_Regular(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
**RODIN_GEN_1_5_PARAS,
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, images, api_key, seed_, Material_Type, Polygon_count, bbox = None, height_cm = None):
num_images = images.shape[0]
m_images = []
for i in range(num_images):
m_images.append(images[i])
quality_override, mesh_mode = QUALITY_MESH_OPTIONS[Polygon_count]
model_path, _ = await process_full_generation(
api_key=api_key,
images=m_images,
prompt = None,
tier="Regular",
seed=seed_,
quality=None,
geometry_file_format="glb",
material=Material_Type,
texture_mode=None,
quality_override=quality_override,
mesh_mode=mesh_mode,
ta_pose=False,
hd_texture=False,
model_early_export=False,
is_micro=False,
geometry_instruct_mode='faithful',
bbox=bbox,
height_cm=height_cm
)
return (model_path,)
class mRodin3D_Detail(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
**RODIN_GEN_1_5_PARAS,
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, images, api_key, seed_, Material_Type, Polygon_count, bbox = None, height_cm = None):
num_images = images.shape[0]
m_images = []
for i in range(num_images):
m_images.append(images[i])
quality_override, mesh_mode = QUALITY_MESH_OPTIONS[Polygon_count]
model_path, _ = await process_full_generation(
api_key=api_key,
images=m_images,
prompt = None,
tier="Detail",
seed=seed_,
quality=None,
geometry_file_format="glb",
material=Material_Type,
texture_mode=None,
quality_override=quality_override,
mesh_mode=mesh_mode,
ta_pose=False,
hd_texture=False,
model_early_export=False,
is_micro=False,
geometry_instruct_mode='faithful',
bbox=bbox,
height_cm=height_cm
)
return (model_path,)
class mRodin3D_Smooth(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
**RODIN_GEN_1_5_PARAS,
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, images, api_key, seed_, Material_Type, Polygon_count, bbox = None, height_cm = None):
num_images = images.shape[0]
m_images = []
for i in range(num_images):
m_images.append(images[i])
quality_override, mesh_mode = QUALITY_MESH_OPTIONS[Polygon_count]
model_path, _ = await process_full_generation(
api_key=api_key,
images=m_images,
prompt = None,
tier="Smooth",
seed=seed_,
quality=None,
geometry_file_format="glb",
material=Material_Type,
texture_mode=None,
quality_override=quality_override,
mesh_mode=mesh_mode,
ta_pose=False,
hd_texture=False,
model_early_export=False,
is_micro=False,
geometry_instruct_mode='faithful',
bbox=bbox,
height_cm=height_cm
)
return (model_path,)
class mRodin3D_Sketch(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE", {"forceInput": True, "multiline": True}),
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"Material_Type": (["PBR", "Shaded"], {"default":"PBR"}),
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
},
}
async def main_func(self, images, api_key, seed_, Material_Type, bbox=None):
num_images = images.shape[0]
m_images = []
for i in range(num_images):
m_images.append(images[i])
model_path, _ = await process_full_generation(
api_key=api_key,
images=m_images,
prompt = None,
tier="Sketch",
seed=seed_,
quality=None,
geometry_file_format="glb",
material=Material_Type,
texture_mode=None,
quality_override=None,
mesh_mode=None,
ta_pose=False,
hd_texture=False,
model_early_export=False,
is_micro=False,
geometry_instruct_mode='faithful',
bbox=bbox,
height_cm=None,
)
return (model_path,)
class mRodin3D_Gen2(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
**RODIN_GEN_2_PARAS,
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, images, api_key, seed_, Material_Type, Polygon_count, TAPose, height_cm=None, bbox=None):
num_images = images.shape[0]
m_images = []
for i in range(num_images):
m_images.append(images[i])
quality_override, mesh_mode = QUALITY_MESH_OPTIONS[Polygon_count]
model_path, _ = await process_full_generation(
api_key=api_key,
images=m_images,
prompt = None,
tier="Gen-2",
seed=seed_,
quality=None,
geometry_file_format="glb",
material=Material_Type,
texture_mode=None,
quality_override=quality_override,
mesh_mode=mesh_mode,
ta_pose=TAPose,
hd_texture=False,
model_early_export=False,
is_micro=False,
geometry_instruct_mode='faithful',
bbox=bbox,
height_cm=height_cm,
)
return (model_path,)
class mRodin3D_Gen_2_5_Fast_Image(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
"images": ("IMAGE", {"forceInput": True, "multiline": True}),
"tier": (["Gen-2.5-Minimum", "Gen-2.5-Extreme-Low", "Gen-2.5-Low", "Gen-2.5-Medium", "Gen-2.5-High"], {"default":"Gen-2.5-Medium"}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"Material_Type": (["PBR", "Shaded", "All", "None"], {"default":"PBR"}),
"geometry_file_format": (["glb", "usdz"], {"default":"glb"}),
"mesh_faces": ("INT", {"default": 20000, "min": 1000, "max": 20000, "step": 1, "display": "number", }),
"texture_mode": (["minimum", "extreme-low", "low", "medium", "high", "Default"], {"default":"Default"}),
"TAPose": ("BOOLEAN", {"default": False}),
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, images, tier, api_key, seed_, Material_Type, TAPose, geometry_file_format, mesh_faces, texture_mode, height_cm=None, bbox=None):
num_images = images.shape[0]
m_images = []
for i in range(num_images):
m_images.append(images[i])
model_path, _ = await process_full_generation(
api_key=api_key,
images=m_images,
prompt = None,
tier=tier,
seed=seed_,
quality=None,
geometry_file_format=geometry_file_format,
material=Material_Type,
texture_mode=texture_mode,
quality_override=mesh_faces,
mesh_mode="Raw",
ta_pose=TAPose,
hd_texture=False,
model_early_export=False,
is_micro=False,
geometry_instruct_mode='faithful',
bbox=bbox,
height_cm=height_cm,
)
return (model_path,)
class mRodin3D_Gen_2_5_Fast_Text(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
"prompt": ("STRING", {"forceInput": False, "multiline": True}),
"tier": (["Gen-2.5-Minimum", "Gen-2.5-Extreme-Low", "Gen-2.5-Low", "Gen-2.5-Medium", "Gen-2.5-High"], {"default":"Gen-2.5-Medium"}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"Material_Type": (["PBR", "Shaded", "All", "None"], {"default":"PBR"}),
"geometry_file_format": (["glb", "usdz"], {"default":"glb"}),
"mesh_faces": ("INT", {"default": 20000, "min": 1000, "max": 20000, "step": 1, "display": "number", }),
"texture_mode": (["minimum", "extreme-low", "low", "medium", "high", "Default"], {"default":"Default"}),
"TAPose": ("BOOLEAN", {"default": False}),
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, prompt, tier, api_key, seed_, Material_Type, TAPose, geometry_file_format, mesh_faces, texture_mode, height_cm=None, bbox=None):
model_path, _ = await process_full_generation(
api_key=api_key,
images=None,
prompt = prompt,
tier=tier,
seed=seed_,
quality=None,
geometry_file_format=geometry_file_format,
material=Material_Type,
texture_mode=texture_mode,
quality_override=mesh_faces,
mesh_mode="Raw",
ta_pose=TAPose,
hd_texture=False,
model_early_export=False,
is_micro=False,
geometry_instruct_mode='faithful',
bbox=bbox,
height_cm=height_cm,
)
return (model_path,)
class mRodin3D_Gen_2_5_Regular_Image(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
"images": ("IMAGE", {"forceInput": True, "multiline": True}),
"tier": (["Gen-2.5-Low", "Gen-2.5-Medium", "Gen-2.5-High"], {"default":"Gen-2.5-Medium"}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"Material_Type": (["PBR", "Shaded", "All", "None"], {"default":"PBR"}),
"Polygon_count": (["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "2K-Triangle", "20K-Triangle", "150K-Triangle", "500K-Triangle", "1M-Triangle", "Default"], {"default": "Default"}),
"geometry_file_format": (["glb", "usdz"], {"default":"glb"}),
"texture_mode": (["minimum", "extreme-low", "low", "medium", "high", "Default"], {"default":"Default"}),
"TAPose": ("BOOLEAN", {"default": False}),
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, images, tier, api_key, seed_, Material_Type, TAPose, geometry_file_format, Polygon_count, texture_mode, height_cm=None, bbox=None):
num_images = images.shape[0]
m_images = []
for i in range(num_images):
m_images.append(images[i])
if Polygon_count != "Default":
quality_override, mesh_mode = QUALITY_MESH_OPTIONS[Polygon_count]
else:
quality_override, mesh_mode = QUALITY_MESH_DEFAULT[tier]
model_path, _ = await process_full_generation(
api_key=api_key,
images=m_images,
prompt = None,
tier=tier,
seed=seed_,
quality=None,
geometry_file_format=geometry_file_format,
material=Material_Type,
texture_mode=texture_mode,
quality_override=quality_override,
mesh_mode=mesh_mode,
ta_pose=TAPose,
hd_texture=False,
model_early_export=False,
is_micro=False,
geometry_instruct_mode='faithful',
bbox=bbox,
height_cm=height_cm,
)
return (model_path,)
class mRodin3D_Gen_2_5_Regular_Text(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
"prompt": ("STRING", {"forceInput": False, "multiline": True}),
"tier": (["Gen-2.5-Low", "Gen-2.5-Medium", "Gen-2.5-High"], {"default":"Gen-2.5-Medium"}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"Material_Type": (["PBR", "Shaded", "All", "None"], {"default":"PBR"}),
"Polygon_count": (["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "2K-Triangle", "20K-Triangle", "150K-Triangle", "500K-Triangle", "1M-Triangle", "Default"], {"default": "Default"}),
"geometry_file_format": (["glb", "usdz"], {"default":"glb"}),
"texture_mode": (["minimum", "extreme-low", "low", "medium", "high", "Default"], {"default":"Default"}),
"TAPose": ("BOOLEAN", {"default": False}),
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, prompt, tier, api_key, seed_, Material_Type, TAPose, geometry_file_format, Polygon_count, texture_mode, height_cm=None, bbox=None):
if Polygon_count != "Default":
quality_override, mesh_mode = QUALITY_MESH_OPTIONS[Polygon_count]
else:
quality_override, mesh_mode = QUALITY_MESH_DEFAULT[tier]
model_path, _ = await process_full_generation(
api_key=api_key,
images=None,
prompt = prompt,
tier=tier,
seed=seed_,
quality=None,
geometry_file_format=geometry_file_format,
material=Material_Type,
texture_mode=texture_mode,
quality_override=quality_override,
mesh_mode=mesh_mode,
ta_pose=TAPose,
hd_texture=False,
model_early_export=False,
is_micro=False,
geometry_instruct_mode='faithful',
bbox=bbox,
height_cm=height_cm,
)
return (model_path,)
class mRodin3D_Gen_2_5_ExtremeHigh_Image(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
"images": ("IMAGE", {"forceInput": True, "multiline": True}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"Material_Type": (["PBR", "Shaded", "All", "None"], {"default":"PBR"}),
"mesh_mode": (["Raw", "Quad"], {"default":"Raw"}),
"mesh_faces": ("INT", {"default": 1000000, "min": 20000, "max": 2000000, "step": 1, "display": "number", }),
"geometry_file_format": (["glb", "usdz", "fbx", "obj", "stl"], {"default":"glb"}),
"texture_mode": (["legacy", "minimum", "extreme-low", "low", "medium", "high"], {"default":"high"}),
"is_micro": ("BOOLEAN", {"default": False}),
"Creative": ("BOOLEAN", {"default": False}),
"TAPose": ("BOOLEAN", {"default": False}),
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, images, api_key, seed_, Material_Type, TAPose, mesh_mode, mesh_faces, geometry_file_format, texture_mode, is_micro, Creative, height_cm=None, bbox=None):
num_images = images.shape[0]
m_images = []
for i in range(num_images):
m_images.append(images[i])
model_path, _ = await process_full_generation(
api_key=api_key,
images=m_images,
prompt = None,
tier="Gen-2.5-Extreme-High",
seed=seed_,
quality=None,
geometry_file_format=geometry_file_format,
material=Material_Type,
texture_mode=texture_mode,
quality_override=mesh_faces,
mesh_mode=mesh_mode,
ta_pose=TAPose,
hd_texture=False,
model_early_export=False,
is_micro=is_micro,
geometry_instruct_mode= 'creative' if Creative else 'faithful',
bbox=bbox,
height_cm=height_cm,
)
return (model_path, )
class mRodin3D_Gen_2_5_ExtremeHigh_Text(Rodin3D_simple):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
"prompt": ("STRING", {"forceInput": False, "multiline": True}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"Material_Type": (["PBR", "Shaded", "All", "None"], {"default":"PBR"}),
"mesh_mode": (["Raw", "Quad"], {"default":"Raw"}),
"mesh_faces": ("INT", {"default": 1000000, "min": 20000, "max": 2000000, "step": 1, "display": "number", }),
"geometry_file_format": (["glb", "usdz", "fbx", "obj", "stl"], {"default":"glb"}),
"texture_mode": (["legacy", "minimum", "extreme-low", "low", "medium", "high"], {"default":"high"}),
"is_micro": ("BOOLEAN", {"default": False}),
"Creative": ("BOOLEAN", {"default": False}),
"TAPose": ("BOOLEAN", {"default": False}),
},
"optional":{
"bbox": ("STRING",{"forceInput":True,"multiline": True}),
"height_cm":("INT", {"forceInput":True}),
},
}
async def main_func(self, prompt, api_key, seed_, Material_Type, TAPose, mesh_mode, mesh_faces, geometry_file_format, texture_mode, is_micro, Creative, height_cm=None, bbox=None):
model_path, _ = await process_full_generation(
api_key=api_key,
images=None,
prompt = prompt,
tier="Gen-2.5-Extreme-High",
seed=seed_,
quality=None,
geometry_file_format=geometry_file_format,
material=Material_Type,
texture_mode=texture_mode,
quality_override=mesh_faces,
mesh_mode=mesh_mode,
ta_pose=TAPose,
hd_texture=False,
model_early_export=False,
is_micro=is_micro,
geometry_instruct_mode= 'creative' if Creative else 'faithful',
bbox=bbox,
height_cm=height_cm,
)
return (model_path, )
-586
View File
@@ -1,586 +0,0 @@
import io
from PIL import Image
import datetime
import asyncio
import aiohttp
import os
from typing import Optional, Tuple, List
import logging
import folder_paths as comfy_paths
ERROR_MEG = {
"MISS_API_KEY": "MISS_API_KEY",
"MISS_IMAGES_OR_PROMPT": "MISS_IMAGES_OR_PROMPT",
"UNKNOWN_ERROR": "UNKNOWN_ERROR",
"RODIN_ERROR": "RODIN_ERROR",
"NO_JOBS_FOUND": "NO_JOBS_FOUND",
"MISS_SUBSCRIPTION_KEY": "MISS_SUBSCRIPTION_KEY",
"MISS_JOB_UUID": "MISS_JOB_UUID",
"NO_FILES_FOUND": "NO_FILES_FOUND",
"RODIN_POLYGEN_ERROR": "RODIN_POLYGEN_ERROR",
"MISS_MODEL_ASSET_ID": "MISS_MODEL_ASSET_ID",
}
SUPPORTED_3D_EXTENSIONS = [
'.obj',
'.glb',
'.fbx',
'.stl',
'.usdz',
]
QUALITY_MESH_OPTIONS = {
"4K-Quad": (4000, "Quad"),
"8K-Quad": (8000, "Quad"),
"18K-Quad": (18000, "Quad"),
"50K-Quad": (50000, "Quad"),
"200K-Quad": (200000, "Quad"),
"2K-Triangle": (2000, "Raw"),
"20K-Triangle": (20000, "Raw"),
"150K-Triangle": (150000, "Raw"),
"200K-Triangle": (200000, "Raw"),
"500K-Triangle": (500000, "Raw"),
"1M-Triangle": (1000000, "Raw")
}
QUALITY_MESH_DEFAULT = {
"Gen-2.5-Minimum": (50000, "Raw"),
"Gen-2.5-Extreme-Low": (50000, "Raw"),
"Gen-2.5-Low": (50000, "Raw"),
"Gen-2.5-Medium": (500000, "Raw"),
"Gen-2.5-High": (500000, "Raw"),
"Gen-2.5-ExtremeHigh": (1000000, "Raw")
}
RODIN_API_BASE_URL = "https://api.hyper3d.com"
MAX_PARALLEL = 3
def tensor_to_filelike(tensor):
"""
Converts a PyTorch tensor to a file-like object.
Args:
- tensor (torch.Tensor): A tensor representing an image of shape (H, W, C)
where C is the number of channels (3 for RGB), H is height, and W is width.
Returns:
- io.BytesIO: A file-like object containing the image data.
"""
array = tensor.cpu().numpy()
array = (array * 255).astype('uint8')
image = Image.fromarray(array, 'RGB')
img_byte_arr = io.BytesIO()
image.save(img_byte_arr, format='PNG') # PNG is used for lossless compression
img_byte_arr.seek(0)
return img_byte_arr
async def submit_generate_job(
api_key: str,
images: Optional[List[str]],
prompt: Optional[str],
seed: Optional[int],
quality: Optional[str],
quality_override: Optional[int],
mesh_mode: Optional[str] = 'Raw',
geometry_file_format: Optional[str] = 'glb',
material: Optional[str] = 'PBR',
texture_mode: Optional[str] = None,
tier: Optional[str] = None,
ta_pose: Optional[bool] = False,
hd_texture: Optional[bool] = False,
model_early_export: Optional[bool] = False,
is_micro: Optional[bool] = False,
geometry_instruct_mode: Optional[str] = 'faithful',
bbox: Optional[str] = None,
height_cm: Optional[int] = None,
) -> Tuple[str, Optional[str], Optional[str]]:
"""Submit a job job"""
if not api_key:
return ERROR_MEG["MISS_API_KEY"], None, None
# Prepare the request
url = f"{RODIN_API_BASE_URL}/api/v2/rodin"
headers = {
"Authorization": f"Bearer {api_key}"
}
if images is None and prompt is None:
return ERROR_MEG["MISS_IMAGES_OR_PROMPT"], None, None
# Prepare form_data
form_data = aiohttp.FormData()
# Handle images
if images is not None:
if isinstance(images, list):
for img in images:
if isinstance(img, str):
with open(img, "rb") as f:
file_content = f.read()
form_data.add_field("images", file_content)
else:
form_data.add_field("images", tensor_to_filelike(img))
else:
# Handle single image case
if isinstance(images, str):
with open(images, "rb") as f:
file_content = f.read()
form_data.add_field("images", file_content)
else:
form_data.add_field("images", tensor_to_filelike(images))
# Handle prompt
if prompt is not None and prompt != "":
form_data.add_field("prompt", prompt)
# Handle seed
if seed is not None and seed != 0 and seed != "":
form_data.add_field("seed", str(seed))
# Handle quality
if quality is not None and quality_override is None:
form_data.add_field("quality", quality)
# Handle quality_override
if quality_override is not None:
form_data.add_field("quality_override", str(quality_override))
# Handle mesh_mode
if mesh_mode is not None:
form_data.add_field("mesh_mode", mesh_mode)
# Handle geometry_file_format
if geometry_file_format is not None:
form_data.add_field("geometry_file_format", geometry_file_format)
# Handle material
if material is not None:
form_data.add_field("material", material)
# Handle texture_mode
if texture_mode is not None and texture_mode != "Default":
form_data.add_field("texture_mode", texture_mode)
# Handle tier
if tier is not None:
form_data.add_field("tier", tier)
# Handle ta_pose
if ta_pose is not None:
form_data.add_field("ta_pose", str(ta_pose).lower())
# Handle hd_texture
if hd_texture is not None:
form_data.add_field("hd_texture", str(hd_texture).lower())
# Handle model_early_export
if model_early_export is not None:
form_data.add_field("model_early_export", str(model_early_export).lower())
# Handle is_micro
if is_micro is not None:
form_data.add_field("is_micro", str(is_micro).lower())
# Handle geometry_instruct_mode
if geometry_instruct_mode is not None:
form_data.add_field("geometry_instruct_mode", geometry_instruct_mode)
# Handle bbox
if bbox is not None:
# Format as [width, height, length] for API
form_data.add_field("bbox_condition", str(bbox))
if height_cm is not None:
form_data.add_field("height", str(height_cm))
for field in form_data._fields:
print(field)
# Post requests
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, data=form_data) as resp:
response = await resp.json()
print(response)
if resp.status in [200, 201]:
job_uuid = response.get("uuid", None)
subscription_key = response.get("jobs", {}).get("subscription_key", None)
if job_uuid is None or subscription_key is None:
return ERROR_MEG["RODIN_ERROR"], None, None
else:
return "Success! Job submitted successfully.", job_uuid, subscription_key
else:
return ERROR_MEG["RODIN_ERROR"], None, None
async def poll_job_status(
api_key: str,
job_uuid: str,
subscription_key: str,
) -> Tuple[str, Optional[str], Optional[str]]:
"""Poll the job status"""
if not api_key:
return ERROR_MEG["MISS_API_KEY"], None, None
if not subscription_key:
return ERROR_MEG["MISS_SUBSCRIPTION_KEY"], None, None
if not job_uuid:
return ERROR_MEG["MISS_JOB_UUID"], None, None
# Prepare the request
url = f"{RODIN_API_BASE_URL}/api/v2/status"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
data = {"subscription_key": subscription_key}
# Post requests
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=data) as resp:
response = await resp.json()
if resp.status in [200, 201]:
jobs = response.get("jobs", [])
if jobs is None or len(jobs) == 0:
return ERROR_MEG["NO_JOBS_FOUND"], None, response
# Check status of all jobs
all_done = True
any_failed = False
statuses = []
for job in jobs:
job_status = job.get("status", "unknown")
statuses.append(job_status)
if job_status == "Failed":
any_failed = True
elif job_status != "Done":
all_done = False
if all_done:
print(f"Task {job_uuid} statuses: {statuses}")
print(f"Task {job_uuid} Done!")
return f"Success! Task {job_uuid} All jobs have completed successfully.", "done", response
elif any_failed:
return f"Failed! Task {job_uuid} Jobs have failed. Statuses: {statuses}", "failed", response
else:
print(f"Task {job_uuid} statuses: {statuses}")
return f"Running... Task {job_uuid} Some jobs are still running. Statuses: {statuses}", "running", response
else:
return ERROR_MEG["UNKNOWN_ERROR"], None, response
async def download_results(
api_key: str,
job_uuid: str,
geometry_format: str = 'glb',
) -> Tuple[str, Optional[str], Optional[str]]:
"""Download the job results"""
if not api_key:
return ERROR_MEG["MISS_API_KEY"], None, None
if not job_uuid:
return ERROR_MEG["MISS_JOB_UUID"], None, None
# Prepare request
url = f"{RODIN_API_BASE_URL}/api/v2/download"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
data = {"task_uuid": job_uuid}
# Post requests
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=data) as resp:
response = await resp.json()
if resp.status in [200, 201]:
file_list = response.get("list", [])
if file_list is None or len(file_list) == 0:
return ERROR_MEG["NO_FILES_FOUND"], None, None
else:
return await _download_all_file(api_key=api_key, file_list=file_list, geometry_format=geometry_format, task_uuid=job_uuid)
else:
print(f"❌ Error downloading files: {response}")
return ERROR_MEG["RODIN_ERROR"], None, None
async def _download_all_file(
api_key: str,
file_list: list[str],
geometry_format: str,
task_uuid: str,
) -> Tuple[str, Optional[str], Optional[str]]:
"""Download the file"""
if not api_key:
return ERROR_MEG["MISS_API_KEY"], None, None
if not file_list:
return ERROR_MEG["NO_FILES_FOUND"], None, None
try:
save_path = os.path.join(comfy_paths.get_output_directory(), datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S"))
os.makedirs(save_path, exist_ok=True)
download_count = 0
preview_model_path = None
for file_info in file_list:
file_url = file_info.get("url", None)
file_name = file_info.get("name", f"file_download_{download_count}").split('/')[-1]
file_path = os.path.join(save_path, file_name)
format_supports_preview = f'.{geometry_format.lower()}' in SUPPORTED_3D_EXTENSIONS
if format_supports_preview and preview_model_path is None:
preview_model_path = file_path
if not file_url:
continue
print(f"[ download_files ] Downloading file: {file_path}")
async with aiohttp.ClientSession() as session:
async with session.get(file_url) as resp:
with open(file_path, "wb") as f:
f.write(await resp.read())
print(f"Downloaded {file_name}")
download_count += 1
return f"Success! Downloaded {download_count} files successfully!", preview_model_path, task_uuid
except Exception as e:
logging.error(f"❌ Error downloading files: {str(e)}")
print(e)
return ERROR_MEG["UNKNOWN_ERROR"], None, None
async def process_full_generation(
api_key: str,
images: Optional[List[str]],
prompt: Optional[str],
tier: str,
seed: Optional[int],
quality: Optional[str],
geometry_file_format: Optional[str],
material: Optional[str],
texture_mode: Optional[str],
quality_override: Optional[int],
mesh_mode: Optional[str],
ta_pose: Optional[bool],
hd_texture: Optional[bool],
model_early_export: Optional[bool],
is_micro: Optional[bool],
geometry_instruct_mode: Optional[str],
bbox: Optional[str],
height_cm: Optional[int] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""Full pipeline: submit job, poll status, and download results"""
# Step 1: Submit job
status_msg, job_uuid, subscription_key = await submit_generate_job(
api_key=api_key,
images=images,
prompt=prompt,
seed=seed,
quality=quality,
quality_override=quality_override,
mesh_mode=mesh_mode,
geometry_file_format=geometry_file_format,
material=material,
texture_mode=texture_mode,
tier=tier,
ta_pose=ta_pose,
hd_texture=hd_texture,
model_early_export=model_early_export,
is_micro=is_micro,
geometry_instruct_mode=geometry_instruct_mode,
bbox=bbox,
height_cm=height_cm,
)
if job_uuid is None or subscription_key is None:
logging.error(f"Failed to submit job: {status_msg}")
print(f"Error: {status_msg}")
return None, None
print(f"Job submitted successfully! Job UUID: {job_uuid}")
# Step 2: Poll for completion
max_attempts = 360 # 30 minutes with 5-second intervals (1800 seconds)
attempt = 0
while attempt < max_attempts:
status_msg, ready_flag, full_response = await poll_job_status(api_key=api_key, job_uuid=job_uuid, subscription_key=subscription_key)
print(f"Status: {status_msg}")
if ready_flag == "done": # Job completed
break
elif ready_flag == "failed":
logging.error(f"Job failed: {status_msg}")
print(f"Error: {status_msg}")
return None, None
await asyncio.sleep(5)
attempt += 1
if attempt >= max_attempts:
logging.error("Job polling timed out. Please check the job status.")
print("Error: Job polling timed out after 30 minutes. Please check the job status on the Rodin dashboard.")
return None, None
download_status, model_file, task_uuid = await download_results(api_key=api_key, job_uuid=job_uuid, geometry_format=geometry_file_format)
print(f"Download status: {download_status}")
if not model_file:
logging.error("Failed to download model file")
print("Error: Failed to download model file. Please check your internet connection and try again.")
return None, None
print(f"Model downloaded successfully: {model_file}")
return model_file, task_uuid
async def submit_polygen_task(
api_key: str,
asset_id: str,
model: str,
geometry_file_format: Optional[str],
mesh_mode: Optional[str],
quality: Optional[str],
) -> Tuple[str, Optional[str], Optional[str]]:
"""Submit a polygen task to the Rodin API"""
if not api_key:
return ERROR_MEG["MISS_API_KEY"], None, None
# Prepare the request
url = f"{RODIN_API_BASE_URL}/api/v2/polygen"
headers = {
"Authorization": f"Bearer {api_key}",
}
# Prepare data
if not model and not asset_id:
return ERROR_MEG["MISS_MODEL_ASSET_ID"], None, None
if model and asset_id:
return ERROR_MEG["PLEASE_ONLY_MODEL_OR_ASSET_ID"], None, None
# Prepare form_data
form_data = aiohttp.FormData()
if model:
if isinstance(model, str):
file_ext = os.path.splitext(model)[1].lower()
content_type_map = {
'.obj': 'model/obj',
'.glb': 'model/gltf-binary',
'.stl': 'model/stl',
'.fbx': 'model/fbx',
'.usdz': 'model/vnd.usdz+zip',
'.usda': 'model/usda',
'.usdc': 'model/usdc',
}
content_type = content_type_map.get(file_ext, 'application/octet-stream')
with open(model, "rb") as f:
file_content = f.read()
form_data.add_field("model", file_content, filename=os.path.basename(model), content_type=content_type)
if asset_id:
form_data.add_field("asset_id", asset_id)
if geometry_file_format:
form_data.add_field("geometry_file_format", geometry_file_format)
if mesh_mode:
form_data.add_field("meshmesh_mode", mesh_mode)
if quality:
form_data.add_field("quality", quality)
# Send the request
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, data=form_data) as resp:
response = await resp.json()
print(response)
if resp.status in [200, 201]:
job_uuid = response.get("uuid", None)
subscription_key = response.get("jobs", {}).get("subscription_key", None)
if job_uuid is None or subscription_key is None:
return ERROR_MEG["RODIN_POLYGEN_ERROR"], None, None
else:
return "Success! Polygen task submitted successfully.", job_uuid, subscription_key
else:
return ERROR_MEG["RODIN_POLYGEN_ERROR"], None, None
async def full_polygen_pipeline(
api_key: str,
asset_id: str,
model: str,
geometry_file_format: Optional[str],
mesh_mode: Optional[str],
quality: Optional[str],
) -> Tuple[Optional[str], Optional[str]]:
"""Full pipeline: submit polygen task, poll status, and download results"""
# Step 1: Submit polygen task
status_msg, job_uuid, subscription_key = await submit_polygen_task(
api_key=api_key,
asset_id=asset_id,
model=model,
geometry_file_format=geometry_file_format,
mesh_mode=mesh_mode,
quality=quality,
)
if job_uuid is None or subscription_key is None:
logging.error(f"Failed to submit polygen task: {status_msg}")
print(f"Error: {status_msg}")
return None, None
print(f"Polygen task submitted successfully! UUID: {job_uuid}")
# Step 2: Poll for completion
max_attempts = 360 # 30 minutes with 5-second intervals (1800 seconds)
attempt = 0
while attempt < max_attempts:
status_msg, ready_flag, full_response = await poll_job_status(api_key=api_key, job_uuid=job_uuid, subscription_key=subscription_key)
print(f"Status: {status_msg}")
if ready_flag == "done": # Polygen task completed
break
elif ready_flag == "failed":
logging.error(f"Polygen task failed: {status_msg}")
print(f"Error: {status_msg}")
return None, None
await asyncio.sleep(5)
attempt += 1
if attempt >= max_attempts:
logging.error("Polygen task polling timed out. Please check the task status.")
print("Error: Polygen task polling timed out after 30 minutes. Please check the task status on the Rodin dashboard.")
return None, None
download_status, model_file, _ = await download_results(api_key=api_key, job_uuid=job_uuid, geometry_format=geometry_file_format)
print(f"Download status: {download_status}")
if not model_file:
logging.error("Failed to download model file")
print("Error: Failed to download model file. Please check your internet connection and try again.")
return None, None
print(f"Model downloaded successfully: {model_file}")
return model_file, job_uuid
Binary file not shown.
Binary file not shown.
+579
View File
@@ -0,0 +1,579 @@
import requests
import torch
import os
import trimesh.exchange
import trimesh.exchange.gltf
import folder_paths as comfy_paths
import logging
import time
import urllib.request
import datetime
import shutil
import glob
import json
import random
import time
import sys
from tqdm import tqdm
from server import PromptServer
from aiohttp import web
from PIL import Image, ImageOps, ImageSequence, ImageFile
from PIL.PngImagePlugin import PngInfo
import node_helpers
import numpy as np
import io
import comfy.utils
import trimesh
ROOT_PATH = os.path.join(comfy_paths.get_folder_paths("custom_nodes")[0], "ComfyUI-Rodin")
CONFIG_ROOT_PATH = os.path.join(ROOT_PATH, "Configs")
# Constants
BASE_URL = "https://hyperhuman.deemos.com/api/v2"
API_HEADERS = {}
API_KEY_PARAMETER = {
"api_key": ("APIKEY", {"forceInput": True, "multiline": True}),
}
COMMON_PARAMETERS = {
"tier": (["Regular", "Sketch"], {"default": "Regular"}),
"seed_": ("INT", {"default": 0, "min": 0, "max": 65535, "step": 1, "display": "number", }),
"geometry_file_format":
(["glb", "usdz", "fbx", "obj", "stl"],
{"default": "glb", "label_off": "glb",
"pysssss.binding": [{
"source": "tier",
"callback": [{
"type": "if",
"condition": [{"left": "$source.value", "op": "eq", "right": '"Sketch"'}],
"true": [{
"type": "set", "target": "$this.options.values", "value": ["glb"]
}, {
"type": "set", "target": "$this.value", "value": '"glb"'
}, {
"type": "set", "target": "$this.disabled", "value": True
},],
"false": [{
"type": "set", "target": "$this.disabled", "value": False
}, {
"type": "set", "target": "$this.options.values", "value": ["glb", "usdz", "fbx", "obj", "stl"]
}],
}]
}]
}),
"material":
(["PBR", "Shaded"],
{"default": "PBR",
"pysssss.binding": [{
"source": "tier",
"callback": [{
"type": "if",
"condition": [{"left": "$source.value", "op": "eq", "right": '"Sketch"'}],
"true": [{
"type": "set", "target": "$this.options.values", "value": ["PBR"]
}, {
"type": "set", "target": "$this.value", "value": '"PBR"'
},
{
"type": "set", "target": "$this.disabled", "value": True
},],
"false": [{
"type": "set", "target": "$this.disabled", "value": False
}, {
"type": "set", "target": "$this.options.values", "value": ["PBR", "Shaded"]
}],
}]
}]
}),
"quality":
(["high", "medium", "low", "extra-low"],
{"default": "medium",
"pysssss.binding": [{
"source": "tier",
"callback": [{
"type": "if",
"condition": [{"left": "$source.value", "op": "eq", "right": '"Sketch"'}],
"true": [{
"type": "set", "target": "$this.options.values", "value": ["medium"]
}, {
"type": "set", "target": "$this.value", "value": '"medium"'
}, {
"type": "set", "target": "$this.disabled", "value": True
},],
"false": [{
"type": "set", "target": "$this.disabled", "value": False
}, {
"type": "set", "target": "$this.options.values", "value": ["high", "medium", "low", "extra-low"]
}],
}]
}]
}),
"use_hyper":
("BOOLEAN",
{"default": False,
"pysssss.binding": [{
"source": "tier",
"callback": [{
"type": "if",
"condition": [{"left": "$source.value", "op": "eq", "right": '"Sketch"'
}],
"true": [{
"type": "set", "target": "$this.value", "value": False
}, {
"type": "set", "target": "$this.disabled", "value": True
},],
"false": [{
"type": "set", "target": "$this.disabled", "value": False
}, {
"type": "set", "target": "$this.options.values", "value": "$result"
}],
}]
}]
}),
}
SUPPORTED_3D_EXTENSIONS = (
'.obj',
'.glb',
'.fbx',
'.stl',
'.usdz',
)
def post_request(url, api_key, data, files=None, max_retries=5, delay=2):
headers = {"Authorization": f"Bearer {api_key}"}
full_url = f"{BASE_URL}/{url}"
for attempt in range(max_retries):
try:
response = requests.post(full_url, headers={**API_HEADERS, **headers}, data=data, files=files)
response.raise_for_status()
return response.json()
except requests.ConnectionError as e:
logging.info(f"Connection error: {e}. Retrying {attempt + 1}/{max_retries}...")
except requests.Timeout as e:
logging.info(f"Timeout error: {e}. Retrying {attempt + 1}/{max_retries}...")
except requests.HTTPError as e:
logging.info(f"HTTP error: {e}. Response: {response.text if response else 'No response'}")
break
except Exception as e:
logging.info(f"An unexpected error occurred: {e}. Retrying {attempt + 1}/{max_retries}...")
time.sleep(delay)
logging.info("[ Rodin3D.process_request ] Max retries reached. Request failed.")
return None
def check_status(api_key, subscription_key):
data = {"subscription_key": subscription_key}
return post_request("status", api_key, data)["jobs"]
def load_image(image_path):
img = node_helpers.pillow(Image.open, image_path)
output_images = []
w, h = None, None
excluded_formats = ['MPO']
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
image = i.convert("RGB")
if len(output_images) == 0:
w = image.size[0]
h = image.size[1]
if image.size[0] != w or image.size[1] != h:
continue
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((64,64), dtype=torch.float32, device="cpu")
output_images.append(image)
if len(output_images) > 1 and img.format not in excluded_formats:
output_image = torch.cat(output_images, dim=0)
else:
output_image = output_images[0]
return output_image
def handle_image(img):
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
image = i.convert("RGB")
w = image.size[0]
h = image.size[1]
if image.size[0] != w or image.size[1] != h:
continue
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((64,64), dtype=torch.float32, device="cpu")
return image
def download_files(api_key, uuid):
data = {"task_uuid": uuid}
files_info = post_request("download", api_key, data)
save_path = os.path.join(comfy_paths.get_output_directory(), datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S"))
os.makedirs(save_path, exist_ok=True)
shaded = diffuse = normal = pbr = load_image('custom_nodes/ComfyUI-Rodin/asset/error.png')
for file_info in files_info["list"]:
filename = file_info["name"].split('/')[-1]
file_path = os.path.join(save_path, filename)
if file_path.endswith(SUPPORTED_3D_EXTENSIONS):
model_file_path = file_path
print(f"[ download_files ] Downloading file: {file_path}")
with requests.get(file_info["url"], stream=True) as r:
if file_path.endswith('.glb'):
kwargs = trimesh.exchange.gltf.load_glb(r.raw);
shaded = handle_image(kwargs['geometry']['model']['visual'].material.baseColorTexture)
diffuse = handle_image(kwargs['geometry']['model']['visual'].material.baseColorTexture)
normal = handle_image(kwargs['geometry']['model']['visual'].material.normalTexture)
pbr = handle_image(kwargs['geometry']['model']['visual'].material.metallicRoughnessTexture)
with open(file_path, "wb") as f:
shutil.copyfileobj(r.raw, f)
if filename == "shaded.png":
shaded = load_image(file_path)
elif filename == "texture_diffuse.png":
diffuse = load_image(file_path)
elif filename == "texture_normal.png":
normal = load_image(file_path)
elif filename == "texture_pbr.png":
pbr = load_image(file_path)
#logging.info(model_file_path)
return shaded, diffuse, normal, pbr, model_file_path,
def tensor_to_filelike(tensor):
"""
Converts a PyTorch tensor to a file-like object.
Args:
- tensor (torch.Tensor): A tensor representing an image of shape (H, W, C)
where C is the number of channels (3 for RGB), H is height, and W is width.
Returns:
- io.BytesIO: A file-like object containing the image data.
"""
array = tensor.cpu().numpy()
array = (array * 255).astype('uint8')
image = Image.fromarray(array, 'RGB')
img_byte_arr = io.BytesIO()
image.save(img_byte_arr, format='PNG') # PNG is used for lossless compression
img_byte_arr.seek(0)
return img_byte_arr
def LogInfomation(data, name):
logging.info(f"[ Rodin3D.process_request ]\n{name} =")
if isinstance(data, (dict)):
logging.info(json.dumps(data, indent=4))
elif isinstance(data, (list)) and name == "files":
logging.info(repr(data))
elif isinstance(data, (list)) and name == "status":
table_header = f"{'UUID':<40} {'Status':<12}"
separator = '-' * 52
logging.info(table_header)
logging.info(separator)
for item in data:
logging.info(f"{item['uuid']:<40} {item['status']:<12}")
class Rodin3D:
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "IMAGE", "STRING", )
RETURN_NAMES = ("shaded", "diffuse", "normal", "pbr", "model_path")
FUNCTION = "main_func"
OUTPUT_NODE = True
CATEGORY = "Mesh/Rodin"
def process_request(self, api_key, images, prompt, condition_mode, seed, geometry_file_format, material, quality, use_hyper, tier) -> None:
# Prepare request data and files
files = [
(
"images",
open(image, "rb") if isinstance(image, str) else tensor_to_filelike(image[0])
)
for image in images if image is not None]
data = {
"prompt": prompt,
"condition_mode": condition_mode,
"seed": seed,
"geometry_file_format": geometry_file_format,
"material": material,
"quality": quality,
"use_hyper": use_hyper,
"tier": tier,
}
#logging.info(f"[ Rodin3D.process_request ]\n data = {data}, files = {files}")
LogInfomation(data, "data")
LogInfomation(files, "files")
response = post_request("rodin", api_key, data, files=files)
# Submit and handle the response
if "uuid" in response:
shaded, diffuse, normal, pbr, model_path = self.submit_poll_download(api_key, data, response['uuid'], response['jobs']['subscription_key'])
return shaded, diffuse, normal, pbr, model_path,
else:
logging.info(f"[ Rodin3D.process_request ] Error submitting the job:\n{response}")
def submit_poll_download(self, api_key, data, uuid, subscription_key):
"""Submits the job, polls for its completion, and downloads the result when ready."""
polling_interval = 2 # Interval in seconds to wait between checks
total_seconds_estimated = 20 if data["tier"] == "Sketch" else 60
pbar = comfy.utils.ProgressBar(total_seconds_estimated)
while True:
status = check_status(api_key, subscription_key)
#logging.info(f"[ Rodin3D.process_request ] status = {status}")
LogInfomation(status, "status")
if all([job["status"] == "Done" for job in status]):
logging.info(f"[ Rodin3D.process_request ] Generation complete. Downloading files...")
save_model_path = download_files(api_key, uuid)
print(f"[save model path] : {save_model_path}")
return save_model_path
time.sleep(polling_interval)
pbar.update(2)
class RodinImage3D(Rodin3D):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
**API_KEY_PARAMETER,
"image": ("IMAGE", {"forceInput": True, "multiline": True}),
},
"optional": {
"prompt": ("STRING",{"forceInput":True,"multiline": True}),
**COMMON_PARAMETERS
},
}
def main_func(self, api_key, image, seed_, geometry_file_format, material, quality, use_hyper, tier, prompt=None):
images = [image]
condition_mode = "concat"
shaded, diffuse, normal, pbr, model_path = self.process_request(api_key, images, prompt, condition_mode, seed_, geometry_file_format, material, quality, use_hyper, tier)
#logging.info(m_model_path)
#logging.info(type(m_model_path))
return (shaded, diffuse, normal, pbr, model_path)
class RodinMultipleImage3D(RodinImage3D):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
**API_KEY_PARAMETER,
},
"optional": {
"image_1": ("IMAGE", {"forceInput": True, "multiline": True}),
"image_2": ("IMAGE", {"forceInput": True, "multiline": True}),
"image_3": ("IMAGE", {"forceInput": True, "multiline": True}),
"image_4": ("IMAGE", {"forceInput": True, "multiline": True}),
"image_5": ("IMAGE", {"forceInput": True, "multiline": True}),
"prompt": ("STRING",{"forceInput":True,"multiline": True}),
"condition_mode": (["concat", "fuse"], {"default": "concat"}),
**COMMON_PARAMETERS
},
}
def main_func(self, api_key, seed_, geometry_file_format, material, quality, use_hyper, tier, condition_mode, image_1=None, image_2=None, image_3=None, image_4=None, image_5=None, prompt=None):
images = [image_1, image_2, image_3, image_4, image_5]
# Filter out None values
images = [img for img in images if img is not None]
model_path = self.process_request(api_key, images, prompt, condition_mode, seed_, geometry_file_format, material, quality, use_hyper, tier)
return (model_path,)
class RodinText3D(RodinImage3D):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
**API_KEY_PARAMETER,
"prompt": ("STRING", {"forceInput": True, "multiline": True}),
},
"optional": {
**COMMON_PARAMETERS
},
}
def main_func(self, api_key, prompt, seed_, geometry_file_format, material, quality, use_hyper, tier):
images = []
condition_mode = None
model_path = self.process_request(api_key, images, prompt, condition_mode, seed_, geometry_file_format, material, quality, use_hyper, tier)
return (model_path,)
class PromptForRodin:
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",)
FUNCTION = "main_func"
CATEGORY = "Mesh/Rodin"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"prompt": ("STRING", {"default": "A textual prompt to guide the 3d generation.", "multiline": True})
}
}
def main_func(self, prompt):
return (prompt,)
class LoadRodinAPIKEY:
RETURN_TYPES = ("APIKEY",)
RETURN_NAMES = ("api_key",)
FUNCTION = "main_func"
CATEGORY = "Mesh/Rodin"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"api_key": ("STRING", {"default": "Get your API KEY from: https://hyperhuman.deemos.com/api-dashboard", "multiline": True})
},
}
def main_func(self, api_key):
return (api_key,)
class Preview_3DMesh:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model_path":("STRING", {"default": '', "multiline": False,"forceInput": True, }),
# NOTE: function which draging model file and getting the file path has done
# "test": ("MODELUPLOAD", {"widget": "model_path"}),
},
# "optional": {
# "shaded":("IMAGE", {"multiline": True}),
# "diffuse":("IMAGE", { "multiline": True}),
# "normal":("IMAGE", {"multiline": True}),
# "pbr":("IMAGE", {"multiline": True}),
# },
}
OUTPUT_NODE = True
RETURN_TYPES = ()
FUNCTION = "preview_mesh"
CATEGORY = "Comfy3D/Visualize"
def save_image(self, images, path):
for (batch_number, image) in enumerate(images):
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
metadata = None
img.save(path, pnginfo=metadata)
return path
def preview_mesh(self, model_path, shaded = None, diffuse = None, normal = None, pbr = None):
model_folder, filename = os.path.split(model_path)
if not os.path.isabs(model_path):
model_folder = os.path.join(comfy_paths.output_directory, model_folder)
model_path = os.path.join(comfy_paths.output_directory, model_path)
if not filename.lower().endswith(SUPPORTED_3D_EXTENSIONS):
logging.error(f"[{self.__class__.__name__}] File name {filename} does not end with supported 3D file extensions: {SUPPORTED_3D_EXTENSIONS}")
model_path = ""
# shaded_path = diffuse_path = normal_path = pbr_path = ''
# if shaded is not None:
# self.save_image(shaded, model_folder + '/shaded.png');
# shaded_path = model_folder + '/texture_shaded.png'
# if diffuse is not None:
# self.save_image(diffuse, model_folder + '/diffuse.png');
# diffuse_path = model_folder + '/texture_diffuse.png'
# if normal is not None:
# self.save_image(normal, model_folder + '/normal.png');
# normal_path = model_folder + '/texture_normal.png'
# if pbr is not None:
# self.save_image(pbr, model_folder + '/pbr.png');
# pbr_path = model_folder + '/texture_pbr.png'
previews = [
{
"model_path": model_path,
# "shaded_path": shaded_path,
# "diffuse_path": diffuse_path,
# "normal_path": normal_path,
# "pbr_path": pbr_path,
}
]
return {"ui": {"previews": previews}, "result": ()}
# A dictionary that contains all nodes you want to export with their names
# NOTE: names should be globally unique
NODE_CLASS_MAPPINGS = {
"RodinImage3D": RodinImage3D,
"RodinMultipleImage3D": RodinMultipleImage3D,
"RodinText3D": RodinText3D,
"PromptForRodin": PromptForRodin,
"LoadRodinAPIKEY": LoadRodinAPIKEY,
"Preview_3DMesh":Preview_3DMesh,
}
# A dictionary that contains the friendly/humanly readable titles for the nodes
NODE_DISPLAY_NAME_MAPPINGS = {
"RodinImage3D": "Rodin - Image to 3D",
"RodinMultipleImage3D": "Rodin - Multiple Images to 3D",
"RodinText3D": "Rodin - Text to 3D",
"PromptForRodin": "Rodin - Prompt for Rodin",
"LoadRodinAPIKEY": "Rodin - API KEY",
"Preview_3DMesh":"Rodin - Preview 3D Mesh",
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "CustomScripts",
"logging": false
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "CustomScripts",
"logging": false,
"workflows": {
"directory": "C:\\ComfyUI-Workflows"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "CustomScripts",
"logging": false
}
+300
View File
@@ -0,0 +1,300 @@
import asyncio
import os
import json
import shutil
import inspect
import aiohttp
from server import PromptServer
from tqdm import tqdm
config = None
def is_logging_enabled():
config = get_extension_config()
if "logging" not in config:
return False
return config["logging"]
def log(message, type=None, always=False, name=None):
if not always and not is_logging_enabled():
return
if type is not None:
message = f"[{type}] {message}"
if name is None:
name = get_extension_config()["name"]
print(f"(pysssss:{name}) {message}")
def get_ext_dir(subpath=None, mkdir=False):
dir = os.path.dirname(__file__)
if subpath is not None:
dir = os.path.join(dir, subpath)
dir = os.path.abspath(dir)
if mkdir and not os.path.exists(dir):
os.makedirs(dir)
return dir
def get_comfy_dir(subpath=None, mkdir=False):
dir = os.path.dirname(inspect.getfile(PromptServer))
if subpath is not None:
dir = os.path.join(dir, subpath)
dir = os.path.abspath(dir)
if mkdir and not os.path.exists(dir):
os.makedirs(dir)
return dir
def get_web_ext_dir():
config = get_extension_config()
name = config["name"]
dir = get_comfy_dir("web/extensions/pysssss")
if not os.path.exists(dir):
os.makedirs(dir)
dir = os.path.join(dir, name)
return dir
def get_extension_config(reload=False):
global config
if reload == False and config is not None:
return config
config_path = get_ext_dir("pysssss.json")
default_config_path = get_ext_dir("pysssss.default.json")
if not os.path.exists(config_path):
if os.path.exists(default_config_path):
shutil.copy(default_config_path, config_path)
if not os.path.exists(config_path):
log(f"Failed to create config at {config_path}", type="ERROR", always=True, name="???")
print(f"Extension path: {get_ext_dir()}")
return {"name": "Unknown", "version": -1}
else:
log("Missing pysssss.default.json, this extension may not work correctly. Please reinstall the extension.",
type="ERROR", always=True, name="???")
print(f"Extension path: {get_ext_dir()}")
return {"name": "Unknown", "version": -1}
with open(config_path, "r") as f:
config = json.loads(f.read())
return config
def link_js(src, dst):
src = os.path.abspath(src)
dst = os.path.abspath(dst)
if os.name == "nt":
try:
import _winapi
_winapi.CreateJunction(src, dst)
return True
except:
pass
try:
os.symlink(src, dst)
return True
except:
import logging
logging.exception('')
return False
def is_junction(path):
if os.name != "nt":
return False
try:
return bool(os.readlink(path))
except OSError:
return False
def install_js():
src_dir = get_ext_dir("web/js")
if not os.path.exists(src_dir):
log("No JS")
return
should_install = should_install_js()
if should_install:
log("it looks like you're running an old version of ComfyUI that requires manual setup of web files, it is recommended you update your installation.", "warning", True)
dst_dir = get_web_ext_dir()
linked = os.path.islink(dst_dir) or is_junction(dst_dir)
if linked or os.path.exists(dst_dir):
if linked:
if should_install:
log("JS already linked")
else:
os.unlink(dst_dir)
log("JS unlinked, PromptServer will serve extension")
elif not should_install:
shutil.rmtree(dst_dir)
log("JS deleted, PromptServer will serve extension")
return
if not should_install:
log("JS skipped, PromptServer will serve extension")
return
if link_js(src_dir, dst_dir):
log("JS linked")
return
log("Copying JS files")
shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True)
def should_install_js():
return not hasattr(PromptServer.instance, "supports") or "custom_nodes_from_web" not in PromptServer.instance.supports
def init(check_imports=None):
log("Init")
if check_imports is not None:
import importlib.util
for imp in check_imports:
spec = importlib.util.find_spec(imp)
if spec is None:
log(f"{imp} is required, please check requirements are installed.",
type="ERROR", always=True)
return False
install_js()
return True
def get_async_loop():
loop = None
try:
loop = asyncio.get_event_loop()
except:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def get_http_session():
loop = get_async_loop()
return aiohttp.ClientSession(loop=loop)
async def download(url, stream, update_callback=None, session=None):
close_session = False
if session is None:
close_session = True
session = get_http_session()
try:
async with session.get(url) as response:
size = int(response.headers.get('content-length', 0)) or None
with tqdm(
unit='B', unit_scale=True, miniters=1, desc=url.split('/')[-1], total=size,
) as progressbar:
perc = 0
async for chunk in response.content.iter_chunked(2048):
stream.write(chunk)
progressbar.update(len(chunk))
if update_callback is not None and progressbar.total is not None and progressbar.total != 0:
last = perc
perc = round(progressbar.n / progressbar.total, 2)
if perc != last:
last = perc
await update_callback(perc)
finally:
if close_session and session is not None:
await session.close()
async def download_to_file(url, destination, update_callback=None, is_ext_subpath=True, session=None):
if is_ext_subpath:
destination = get_ext_dir(destination)
with open(destination, mode='wb') as f:
download(url, f, update_callback, session)
def wait_for_async(async_fn, loop=None):
res = []
async def run_async():
r = await async_fn()
res.append(r)
if loop is None:
try:
loop = asyncio.get_event_loop()
except:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(run_async())
return res[0]
def update_node_status(client_id, node, text, progress=None):
if client_id is None:
client_id = PromptServer.instance.client_id
if client_id is None:
return
PromptServer.instance.send_sync("pysssss/update_status", {
"node": node,
"progress": progress,
"text": text
}, client_id)
async def update_node_status_async(client_id, node, text, progress=None):
if client_id is None:
client_id = PromptServer.instance.client_id
if client_id is None:
return
await PromptServer.instance.send("pysssss/update_status", {
"node": node,
"progress": progress,
"text": text
}, client_id)
def get_config_value(key, default=None, throw=False):
split = key.split(".")
obj = get_extension_config()
for s in split:
if s in obj:
obj = obj[s]
else:
if throw:
raise KeyError("Configuration key missing: " + key)
else:
return default
return obj
def is_inside_dir(root_dir, check_path):
root_dir = os.path.abspath(root_dir)
if not os.path.isabs(check_path):
check_path = os.path.abspath(os.path.join(root_dir, check_path))
return os.path.commonpath([check_path, root_dir]) == root_dir
def get_child_dir(root_dir, child_path, throw_if_outside=True):
child_path = os.path.abspath(os.path.join(root_dir, child_path))
if is_inside_dir(root_dir, child_path):
return child_path
if throw_if_outside:
raise NotADirectoryError(
"Saving outside the target folder is not allowed.")
return None
-2
View File
@@ -1,2 +0,0 @@
aiohttp
asyncio
View File
Binary file not shown.
Binary file not shown.
+258
View File
@@ -0,0 +1,258 @@
from abc import ABC, abstractmethod
import numpy as np
import math
from scipy.spatial.transform import Rotation as R
import torch
from kiui.cam import orbit_camera
def dot(x, y):
if isinstance(x, np.ndarray):
return np.sum(x * y, -1, keepdims=True)
else:
return torch.sum(x * y, -1, keepdim=True)
def length(x, eps=1e-20):
if isinstance(x, np.ndarray):
return np.sqrt(np.maximum(np.sum(x * x, axis=-1, keepdims=True), eps))
else:
return torch.sqrt(torch.clamp(dot(x, x), min=eps))
def safe_normalize(x, eps=1e-20):
return x / length(x, eps)
def look_at(campos, target, opengl=True):
# campos: [N, 3], camera/eye position
# target: [N, 3], object to look at
# return: [N, 3, 3], rotation matrix
if not opengl:
# camera forward aligns with -z
forward_vector = safe_normalize(target - campos)
up_vector = np.array([0, 1, 0], dtype=np.float32)
right_vector = safe_normalize(np.cross(forward_vector, up_vector))
up_vector = safe_normalize(np.cross(right_vector, forward_vector))
else:
# camera forward aligns with +z
forward_vector = safe_normalize(campos - target)
up_vector = np.array([0, 1, 0], dtype=np.float32)
right_vector = safe_normalize(np.cross(up_vector, forward_vector))
up_vector = safe_normalize(np.cross(forward_vector, right_vector))
R = np.stack([right_vector, up_vector, forward_vector], axis=1)
return R
def get_look_at_camera_pose(target, target_to_cam_offset, look_distance=0.1, opengl=True):
"""
Calculate the pose (cam2world) matrix from target position the camera suppose to look at and offset vector from target to camera
Args:
target (NDArray[float32], shape: 3): the target position the camera suppose to look at
target_to_cam_dir (NDArray[float32], shape: 3): offset direction from target to camera
look_distance (float, optional): length of offset vector from target to camera.
Returns:
NDArray[float32]: shape: (4, 4), pose (cam2world) matrix
"""
norm=np.linalg.norm(target_to_cam_offset)
if norm==0:
norm=np.finfo(np.float32).eps
target_to_cam_offset = look_distance * target_to_cam_offset / norm
campos = target_to_cam_offset + target # [3]
T = np.eye(4, dtype=np.float32)
T[:3, :3] = look_at(campos, target, opengl)
T[:3, 3] = campos
return T
class OrbitCamera:
def __init__(self, W, H, r=2, fovy=60, near=0.01, far=100):
self.W = W
self.H = H
self.radius = r # camera distance from center
self.fovy = np.deg2rad(fovy) # deg 2 rad
self.near = near
self.far = far
self.center = np.array([0, 0, 0], dtype=np.float32) # look at this point
self.rot = R.from_matrix(np.eye(3))
self.up = np.array([0, 1, 0], dtype=np.float32) # need to be normalized!
@property
def fovx(self):
return 2 * np.arctan(np.tan(self.fovy / 2) * self.W / self.H)
@property
def campos(self):
return self.pose[:3, 3]
# pose (c2w)
@property
def pose(self):
# first move camera to radius
res = np.eye(4, dtype=np.float32)
res[2, 3] = self.radius # opengl convention...
# rotate
rot = np.eye(4, dtype=np.float32)
rot[:3, :3] = self.rot.as_matrix()
res = rot @ res
# translate
res[:3, 3] -= self.center
return res
# view (w2c)
@property
def view(self):
return np.linalg.inv(self.pose)
# projection (perspective)
@property
def perspective(self):
y = np.tan(self.fovy / 2)
aspect = self.W / self.H
return np.array(
[
[1 / (y * aspect), 0, 0, 0],
[0, -1 / y, 0, 0],
[
0,
0,
-(self.far + self.near) / (self.far - self.near),
-(2 * self.far * self.near) / (self.far - self.near),
],
[0, 0, -1, 0],
],
dtype=np.float32,
)
# intrinsics
@property
def intrinsics(self):
focal = self.H / (2 * np.tan(self.fovy / 2))
return np.array([focal, focal, self.W // 2, self.H // 2], dtype=np.float32)
@property
def mvp(self):
return self.perspective @ np.linalg.inv(self.pose) # [4, 4]
def orbit(self, dx, dy):
# rotate along camera up/side axis!
side = self.rot.as_matrix()[:3, 0]
rotvec_x = self.up * np.radians(-0.05 * dx)
rotvec_y = side * np.radians(-0.05 * dy)
self.rot = R.from_rotvec(rotvec_x) * R.from_rotvec(rotvec_y) * self.rot
def scale(self, delta):
self.radius *= 1.1 ** (-delta)
def pan(self, dx, dy, dz=0):
# pan in camera coordinate system (careful on the sensitivity!)
self.center += 0.0005 * self.rot.as_matrix()[:3, :3] @ np.array([-dx, -dy, dz])
def calculate_fovX(H, W, fovy):
return 2 * np.arctan(np.tan(fovy / 2) * W / H)
def get_projection_matrix(znear, zfar, fovX, fovY, z_sign=1.0):
tanHalfFovY = math.tan((fovY / 2))
tanHalfFovX = math.tan((fovX / 2))
P = torch.zeros(4, 4)
P[0, 0] = 1 / tanHalfFovX
P[1, 1] = 1 / tanHalfFovY
P[3, 2] = z_sign
P[2, 2] = z_sign * zfar / (zfar - znear)
P[2, 3] = -(zfar * znear) / (zfar - znear)
return P
class MiniCam:
def __init__(self, c2w, width, height, fovy, fovx, znear, zfar, projection_matrix=None):
# c2w (pose) should be in NeRF convention.
self.image_width = width
self.image_height = height
self.FoVy = fovy
self.FoVx = fovx
self.znear = znear
self.zfar = zfar
w2c = np.linalg.inv(c2w)
# rectify...
w2c[1:3, :3] *= -1
w2c[:3, 3] *= -1
self.world_view_transform = torch.tensor(w2c).transpose(0, 1).cuda()
self.projection_matrix = (
get_projection_matrix(
znear=self.znear, zfar=self.zfar, fovX=self.FoVx, fovY=self.FoVy
)
.transpose(0, 1)
.cuda()
) if projection_matrix is None else projection_matrix
self.full_proj_transform = self.world_view_transform @ self.projection_matrix
self.camera_center = -torch.tensor(c2w[:3, 3]).cuda()
class BaseCameraController(ABC):
def __init__(self, renderer, cam_size_W, cam_size_H, reference_orbit_camera_fovy, invert_bg_prob=1.0, static_bg=None, device='cuda'):
self.device = torch.device(device)
self.renderer = renderer
self.cam = OrbitCamera(cam_size_W, cam_size_H, fovy=reference_orbit_camera_fovy)
self.invert_bg_prob = invert_bg_prob
self.black_bg = torch.tensor([0, 0, 0], dtype=torch.float32, device=self.device)
self.white_bg = torch.tensor([1, 1, 1], dtype=torch.float32, device=self.device)
self.static_bg = None if static_bg is None else torch.tensor(static_bg, dtype=torch.float32, device=self.device)
self.post_init()
super().__init__()
def post_init(self):
# Calls after default initialize at the end of __init__()
pass
@abstractmethod
def get_render_result(self, render_pose, bg_color, **kwargs):
pass
def render_at_pose(self, cam_pose, **kwargs):
radius, elevation, azimuth, center_X, center_Y, center_Z = cam_pose
orbit_target = np.array([center_X, center_Y, center_Z], dtype=np.float32)
render_pose = orbit_camera(elevation, azimuth, radius, target=orbit_target)
if self.static_bg is None:
bg_color = self.white_bg if np.random.rand() > self.invert_bg_prob else self.black_bg
else:
bg_color = self.static_bg
return self.get_render_result(render_pose, bg_color, **kwargs)
def render_all_pose(self, all_cam_poses, **kwargs):
all_rendered_images, all_rendered_masks = [], []
extra_outputs = {}
for cam_pose in all_cam_poses:
out = self.render_at_pose(cam_pose, **kwargs)
image = out["image"] # [3, H, W] in [0, 1]
mask = out["alpha"] # [1, H, W] in [0, 1]
all_rendered_images.append(image)
all_rendered_masks.append(mask)
for k in out:
if k not in extra_outputs:
extra_outputs[k] = []
extra_outputs[k].append(out[k])
for k in extra_outputs:
extra_outputs[k] = torch.stack(extra_outputs[k], dim=0)
# [Number of Poses, 3, H, W], [Number of Poses, 1, H, W] both in [0, 1]
return torch.stack(all_rendered_images, dim=0), torch.stack(all_rendered_masks, dim=0), extra_outputs
+82
View File
@@ -0,0 +1,82 @@
import os
from os import listdir
from os.path import isfile, join, exists, dirname
import sys
from datetime import datetime
from shared_utils.log_utils import cstr
def get_parent_dirpath_n_level_up(abs_path, n=1):
for i in range(n):
abs_path = dirname(abs_path)
return abs_path
def get_persistent_directory(folder_name):
if sys.platform == "win32":
folder = join(os.path.expanduser("~"), "AppData", "Local", folder_name)
else:
folder = join(os.path.expanduser("~"), "." + folder_name)
os.makedirs(folder, exist_ok=True)
return folder
def parse_save_filename(save_path, output_directory, supported_extensions, class_name):
folder_path, filename = os.path.split(save_path)
filename, file_extension = os.path.splitext(filename)
if file_extension.lower() in supported_extensions:
if not os.path.isabs(save_path):
folder_path = join(output_directory, folder_path)
os.makedirs(folder_path, exist_ok=True)
# replace time date format to current time
now = datetime.now() # current date and time
all_date_format = ["%Y", "%m", "%d", "%H", "%M", "%S", "%f"]
for date_format in all_date_format:
if date_format in filename:
filename = filename.replace(date_format, now.strftime(date_format))
save_path = join(folder_path, filename) + file_extension
cstr(f"[{class_name}] Saving model to {save_path}").msg.print()
return save_path
else:
cstr(f"[{class_name}] File name {filename} does not end with supported file extensions: {supported_extensions}").error.print()
return None
def get_list_filenames(directory, extension_filter=None, recursive=False):
"""
Recursively finds files with specified extensions in a directory and returns relative paths.
Args:
directory (str): The directory path to search.
extension_filter (list): List of file extensions (e.g., ['.txt', '.csv']).
Returns:
list: List of relative file paths matching the specified extensions.
"""
if exists(directory):
if recursive:
result = []
for root, _, files in os.walk(directory):
for item in files:
if extension_filter is None or os.path.splitext(item)[1].lower() in extension_filter:
relative_path = os.path.relpath(os.path.join(root, item), directory)
result.append(relative_path)
return result
else:
return [f for f in listdir(directory) if isfile(join(directory, f)) and (extension_filter is None or f.lower().endswith(extension_filter))]
else:
return []
# Download pre-trained model if it not exist locally
def resume_or_download_model_from_hf(checkpoints_dir_abs, repo_id, model_name, class_name="", repo_type="model"):
ckpt_path = os.path.join(checkpoints_dir_abs, model_name)
if not os.path.isfile(ckpt_path):
cstr(f"[{class_name}] can't find checkpoint {ckpt_path}, will download it from repo {repo_id} instead").warning.print()
from huggingface_hub import hf_hub_download
hf_hub_download(repo_id=repo_id, local_dir=checkpoints_dir_abs, filename=model_name, repo_type=repo_type)
return ckpt_path
+198
View File
@@ -0,0 +1,198 @@
import torch
import torch.nn.functional as F
import torchvision.transforms.functional as TF
import numpy as np
from PIL import Image
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
def prepare_torch_img(img, size_H, size_W, device="cuda", keep_shape=False):
# [N, H, W, C] -> [N, C, H, W]
img_new = img.permute(0, 3, 1, 2).to(device)
img_new = F.interpolate(img_new, (size_H, size_W), mode="bilinear", align_corners=False).contiguous()
if keep_shape:
img_new = img_new.permute(0, 2, 3, 1)
return img_new
def torch_imgs_to_pils(images, masks=None, alpha_min=0.1):
"""
images (torch): [N, H, W, C] or [H, W, C]
masks (torch): [N, H, W] or [H, W]
"""
if len(images.shape) == 3:
images = images.unsqueeze(0)
if masks is not None:
if len(masks.shape) == 2:
masks = masks.unsqueeze(0)
inv_mask_index = masks < alpha_min
images[inv_mask_index] = 0.
masks = masks.unsqueeze(3)
images = torch.cat((images, masks), dim=3)
mode="RGBA"
else:
mode="RGB"
pil_image_list = [Image.fromarray((images[i].detach().cpu().numpy() * 255).astype(np.uint8), mode=mode) for i in range(images.shape[0])]
return pil_image_list
def troch_image_dilate(img):
"""
Remove thin seams on generated texture
img (torch): [H, W, C]
"""
import cv2
img = np.asarray(img.cpu().numpy(), dtype=np.float32)
img = img * 255
img = img.clip(0, 255)
mask = np.sum(img.astype(np.float32), axis=-1, keepdims=True)
mask = (mask <= 3.0).astype(np.float32)
kernel = np.ones((3, 3), 'uint8')
dilate_img = cv2.dilate(img, kernel, iterations=1)
img = img * (1 - mask) + dilate_img * mask
img = (img.clip(0, 255) / 255).astype(np.float32)
return torch.from_numpy(img)
def pils_to_torch_imgs(pils: Union[Image.Image, List[Image.Image]], device="cuda", force_rgb=True):
if isinstance(pils, Image.Image):
pils = [pils]
images = []
for pil in pils:
if pil.mode == "RGBA" and force_rgb:
pil = pil.convert('RGB')
images.append(TF.to_tensor(pil).permute(1, 2, 0))
images = torch.stack(images, dim=0).to(device)
return images
def pils_rgba_to_rgb(pils: Union[Image.Image, List[Image.Image]], bkgd="WHITE"):
if isinstance(pils, Image.Image):
pils = [pils]
rgbs = []
for pil in pils:
if pil.mode == 'RGBA':
new_image = Image.new("RGBA", pil.size, bkgd)
new_image.paste(pil, (0, 0), pil)
rgbs.append(new_image.convert('RGB'))
else:
rgbs.append(pil)
return rgbs
def pil_split_image(image, rows=None, cols=None):
"""
inverse function of make_image_grid
"""
# image is in square
if rows is None and cols is None:
# image.size [W, H]
rows = 1
cols = image.size[0] // image.size[1]
assert cols * image.size[1] == image.size[0]
subimg_size = image.size[1]
elif rows is None:
subimg_size = image.size[0] // cols
rows = image.size[1] // subimg_size
assert rows * subimg_size == image.size[1]
elif cols is None:
subimg_size = image.size[1] // rows
cols = image.size[0] // subimg_size
assert cols * subimg_size == image.size[0]
else:
subimg_size = image.size[1] // rows
assert cols * subimg_size == image.size[0]
subimgs = []
for i in range(rows):
for j in range(cols):
subimg = image.crop((j*subimg_size, i*subimg_size, (j+1)*subimg_size, (i+1)*subimg_size))
subimgs.append(subimg)
return subimgs
def pil_make_image_grid(images, rows=None, cols=None):
if rows is None and cols is None:
rows = 1
cols = len(images)
if rows is None:
rows = len(images) // cols
if len(images) % cols != 0:
rows += 1
if cols is None:
cols = len(images) // rows
if len(images) % rows != 0:
cols += 1
total_imgs = rows * cols
if total_imgs > len(images):
images += [Image.new(images[0].mode, images[0].size) for _ in range(total_imgs - len(images))]
w, h = images[0].size
grid = Image.new(images[0].mode, size=(cols * w, rows * h))
for i, img in enumerate(images):
grid.paste(img, box=(i % cols * w, i // cols * h))
return grid
def pils_erode_masks(mask_list):
out_mask_list = []
for idx, mask in enumerate(mask_list):
arr = np.array(mask)
alpha = (arr[:, :, 3] > 127).astype(np.uint8)
# erode 1px
import cv2
alpha = cv2.erode(alpha, np.ones((3, 3), np.uint8), iterations=1)
alpha = (alpha * 255).astype(np.uint8)
out_mask_list.append(Image.fromarray(alpha[:, :, None]))
return out_mask_list
def pils_resize_foreground(
pils: Union[Image.Image, List[Image.Image]],
ratio: float,
) -> List[Image.Image]:
if isinstance(pils, Image.Image):
pils = [pils]
new_pils = []
for image in pils:
image = np.array(image)
assert image.shape[-1] == 4
alpha = np.where(image[..., 3] > 0)
y1, y2, x1, x2 = (
alpha[0].min(),
alpha[0].max(),
alpha[1].min(),
alpha[1].max(),
)
# crop the foreground
fg = image[y1:y2, x1:x2]
# pad to square
size = max(fg.shape[0], fg.shape[1])
ph0, pw0 = (size - fg.shape[0]) // 2, (size - fg.shape[1]) // 2
ph1, pw1 = size - fg.shape[0] - ph0, size - fg.shape[1] - pw0
new_image = np.pad(
fg,
((ph0, ph1), (pw0, pw1), (0, 0)),
mode="constant",
constant_values=((0, 0), (0, 0), (0, 0)),
)
# compute padding according to the ratio
new_size = int(new_image.shape[0] / ratio)
# pad to size, double side
ph0, pw0 = (new_size - size) // 2, (new_size - size) // 2
ph1, pw1 = new_size - size - ph0, new_size - size - pw0
new_image = np.pad(
new_image,
((ph0, ph1), (pw0, pw1), (0, 0)),
mode="constant",
constant_values=((0, 0), (0, 0), (0, 0)),
)
new_image = Image.fromarray(new_image, mode="RGBA")
new_pils.append(new_image)
return new_pils
+105
View File
@@ -0,0 +1,105 @@
import logging
import sys
class cstr(str):
# Modified from: WAS Node Suite
class color:
END = '\33[0m'
BOLD = '\33[1m'
ITALIC = '\33[3m'
UNDERLINE = '\33[4m'
BLINK = '\33[5m'
BLINK2 = '\33[6m'
SELECTED = '\33[7m'
BLACK = '\33[30m'
RED = '\33[31m'
GREEN = '\33[32m'
YELLOW = '\33[33m'
BLUE = '\33[34m'
VIOLET = '\33[35m'
BEIGE = '\33[36m'
WHITE = '\33[37m'
BLACKBG = '\33[40m'
REDBG = '\33[41m'
GREENBG = '\33[42m'
YELLOWBG = '\33[43m'
BLUEBG = '\33[44m'
VIOLETBG = '\33[45m'
BEIGEBG = '\33[46m'
WHITEBG = '\33[47m'
GREY = '\33[90m'
LIGHTRED = '\33[91m'
LIGHTGREEN = '\33[92m'
LIGHTYELLOW = '\33[93m'
LIGHTBLUE = '\33[94m'
LIGHTVIOLET = '\33[95m'
LIGHTBEIGE = '\33[96m'
LIGHTWHITE = '\33[97m'
GREYBG = '\33[100m'
LIGHTREDBG = '\33[101m'
LIGHTGREENBG = '\33[102m'
LIGHTYELLOWBG = '\33[103m'
LIGHTBLUEBG = '\33[104m'
LIGHTVIOLETBG = '\33[105m'
LIGHTBEIGEBG = '\33[106m'
LIGHTWHITEBG = '\33[107m'
@staticmethod
def add_code(name, code):
if not hasattr(cstr.color, name.upper()):
setattr(cstr.color, name.upper(), code)
else:
raise ValueError(f"'cstr' object already contains a code with the name '{name}'.")
def __new__(cls, text):
return super().__new__(cls, text)
def __getattr__(self, attr):
if attr.lower().startswith("_cstr"):
code = getattr(self.color, attr.upper().lstrip("_cstr"))
modified_text = self.replace(f"__{attr[1:]}__", f"{code}")
return cstr(modified_text)
elif attr.upper() in dir(self.color):
code = getattr(self.color, attr.upper())
modified_text = f"{code}{self}{self.color.END}"
return cstr(modified_text)
elif attr.lower() in dir(cstr):
return getattr(cstr, attr.lower())
else:
raise AttributeError(f"'cstr' object has no attribute '{attr}'")
def print(self, **kwargs):
print(self, **kwargs)
#! MESSAGE TEMPLATES
cstr.color.add_code("msg", f"{cstr.color.BLUE}[Comfy3D] {cstr.color.END}")
cstr.color.add_code("warning", f"{cstr.color.LIGHTYELLOW}[Comfy3D] [WARNING] {cstr.color.END}")
cstr.color.add_code("error", f"{cstr.color.RED}[Comfy3D] [ERROR] {cstr.color.END}")
class WarningFilter(logging.Filter):
def filter(self, record):
if record.levelno == logging.WARNING:
record.msg = f"Warn!: {record.msg}"
return True
def create_handler(stream, levels, formatter):
handler = logging.StreamHandler(stream)
handler.setLevel(min(levels))
handler.addFilter(lambda record: record.levelno in levels)
handler.addFilter(WarningFilter()) # Apply the custom filter
handler.setFormatter(formatter)
return handler
def setup_logger(logger_name, level, stdout_levels, stderr_levels, formatter):
logger = logging.getLogger(logger_name)
for handler in logger.handlers[:]:
logger.removeHandler(handler)
logger.setLevel(level)
stdout_handler = create_handler(sys.stdout, stdout_levels, formatter)
stderr_handler = create_handler(sys.stderr, stderr_levels, formatter)
logger.addHandler(stdout_handler)
logger.addHandler(stderr_handler)
+118
View File
@@ -0,0 +1,118 @@
# Copyright 2021 The PlenOctree Authors.
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import torch
C0 = 0.28209479177387814
C1 = 0.4886025119029199
C2 = [
1.0925484305920792,
-1.0925484305920792,
0.31539156525252005,
-1.0925484305920792,
0.5462742152960396
]
C3 = [
-0.5900435899266435,
2.890611442640554,
-0.4570457994644658,
0.3731763325901154,
-0.4570457994644658,
1.445305721320277,
-0.5900435899266435
]
C4 = [
2.5033429417967046,
-1.7701307697799304,
0.9461746957575601,
-0.6690465435572892,
0.10578554691520431,
-0.6690465435572892,
0.47308734787878004,
-1.7701307697799304,
0.6258357354491761,
]
def eval_sh(deg, sh, dirs):
"""
Evaluate spherical harmonics at unit directions
using hardcoded SH polynomials.
Works with torch/np/jnp.
... Can be 0 or more batch dimensions.
Args:
deg: int SH deg. Currently, 0-3 supported
sh: jnp.ndarray SH coeffs [..., C, (deg + 1) ** 2]
dirs: jnp.ndarray unit directions [..., 3]
Returns:
[..., C]
"""
assert deg <= 4 and deg >= 0
coeff = (deg + 1) ** 2
assert sh.shape[-1] >= coeff
result = C0 * sh[..., 0]
if deg > 0:
x, y, z = dirs[..., 0:1], dirs[..., 1:2], dirs[..., 2:3]
result = (result -
C1 * y * sh[..., 1] +
C1 * z * sh[..., 2] -
C1 * x * sh[..., 3])
if deg > 1:
xx, yy, zz = x * x, y * y, z * z
xy, yz, xz = x * y, y * z, x * z
result = (result +
C2[0] * xy * sh[..., 4] +
C2[1] * yz * sh[..., 5] +
C2[2] * (2.0 * zz - xx - yy) * sh[..., 6] +
C2[3] * xz * sh[..., 7] +
C2[4] * (xx - yy) * sh[..., 8])
if deg > 2:
result = (result +
C3[0] * y * (3 * xx - yy) * sh[..., 9] +
C3[1] * xy * z * sh[..., 10] +
C3[2] * y * (4 * zz - xx - yy)* sh[..., 11] +
C3[3] * z * (2 * zz - 3 * xx - 3 * yy) * sh[..., 12] +
C3[4] * x * (4 * zz - xx - yy) * sh[..., 13] +
C3[5] * z * (xx - yy) * sh[..., 14] +
C3[6] * x * (xx - 3 * yy) * sh[..., 15])
if deg > 3:
result = (result + C4[0] * xy * (xx - yy) * sh[..., 16] +
C4[1] * yz * (3 * xx - yy) * sh[..., 17] +
C4[2] * xy * (7 * zz - 1) * sh[..., 18] +
C4[3] * yz * (7 * zz - 3) * sh[..., 19] +
C4[4] * (zz * (35 * zz - 30) + 3) * sh[..., 20] +
C4[5] * xz * (7 * zz - 3) * sh[..., 21] +
C4[6] * (xx - yy) * (7 * zz - 1) * sh[..., 22] +
C4[7] * xz * (xx - 3 * yy) * sh[..., 23] +
C4[8] * (xx * (xx - 3 * yy) - yy * (3 * xx - yy)) * sh[..., 24])
return result
def RGB2SH(rgb):
return (rgb - 0.5) / C0
def SH2RGB(sh):
return sh * C0 + 0.5
+5
View File
@@ -0,0 +1,5 @@
{
"input": "$input/**/*.txt",
"output": "$output/**/*.txt",
"temp": "$temp/**/*.txt"
}
BIN
View File
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link type="text/css" rel="stylesheet" href="/extensions/Comfy-Rodin/style/threeStyle.css">
<link type="text/css" rel="stylesheet" href="/extensions/Comfy-Rodin/style/libs/coloris.min.css">
<link type="text/css" rel="stylesheet" href="/extensions/Comfy-Rodin/style/sharedStyle.css">
</head>
<body>
<div id="progress-container">
<dialog open id="progress-dialog">
<p>
<label for="progress-indicator">Loading scene...</label>
</p>
<progress max="100" id="progress-indicator"></progress>
</dialog>
</div>
<div id="container">
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@latest/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@latest/examples/jsm/"
}
}
</script>
<script id="visualizer" type="module" filepath="" timestamp="" crossorigin src="/extensions/Comfy-Rodin/js/threeVisualizer.js"></script>
</div>
<script src="/extensions/Comfy-Rodin/js/libs/coloris.min.js"></script>
<script>
Coloris({
el: '.coloris',
theme: 'polaroid',
themeMode: 'dark',
format: 'rgb',
alpha: false,
swatches: [
'rgb(0, 0, 0)',
'rgb(128, 128, 128)',
'rgb(255, 255, 255)',
'rgb(132, 188, 218)',
'rgb(128, 227, 119)',
'rgb(213, 96, 98)'
]
});
</script>
<button type="button" id="download-button">Download</button>
<input type="text" id="color-picker" class="coloris" value="rgb(128, 128, 128)"></input>
</body>
</html>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
.pysssss-autocomplete {
color: var(--descrip-text);
background-color: var(--comfy-menu-bg);
position: absolute;
font-family: sans-serif;
box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4);
z-index: 9999;
overflow: auto;
}
.pysssss-autocomplete-item {
cursor: pointer;
padding: 3px 7px;
display: flex;
border-left: 3px solid transparent;
align-items: center;
}
.pysssss-autocomplete-item--selected {
border-left-color: dodgerblue;
}
.pysssss-autocomplete-highlight {
font-weight: bold;
text-decoration: underline;
text-decoration-color: dodgerblue;
}
.pysssss-autocomplete-pill {
margin-left: auto;
font-size: 10px;
color: #fff;
padding: 2px 4px 2px 14px;
position: relative;
}
.pysssss-autocomplete-pill::after {
content: "";
display: block;
background: rgba(255, 255, 255, 0.25);
width: calc(100% - 10px);
height: 100%;
position: absolute;
left: 10px;
top: 0;
border-radius: 5px;
}
.pysssss-autocomplete-pill + .pysssss-autocomplete-pill {
margin-left: 0;
}
.pysssss-autocomplete-item-info {
margin-left: auto;
transition: filter 0.2s;
will-change: filter;
text-decoration: none;
padding-left: 10px;
}
.pysssss-autocomplete-item-info:hover {
filter: invert(1);
}
+681
View File
@@ -0,0 +1,681 @@
import { $el } from "../../../../scripts/ui.js";
import { addStylesheet } from "./utils.js";
addStylesheet(import.meta.url);
/*
https://github.com/component/textarea-caret-position
The MIT License (MIT)
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const getCaretCoordinates = (function () {
// We'll copy the properties below into the mirror div.
// Note that some browsers, such as Firefox, do not concatenate properties
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
// so we have to list every single property explicitly.
var properties = [
"direction", // RTL support
"boxSizing",
"width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
"height",
"overflowX",
"overflowY", // copy the scrollbar for IE
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"borderStyle",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
"fontStyle",
"fontVariant",
"fontWeight",
"fontStretch",
"fontSize",
"fontSizeAdjust",
"lineHeight",
"fontFamily",
"textAlign",
"textTransform",
"textIndent",
"textDecoration", // might not make a difference, but better be safe
"letterSpacing",
"wordSpacing",
"tabSize",
"MozTabSize",
];
var isBrowser = typeof window !== "undefined";
var isFirefox = isBrowser && window.mozInnerScreenX != null;
return function getCaretCoordinates(element, position, options) {
if (!isBrowser) {
throw new Error("textarea-caret-position#getCaretCoordinates should only be called in a browser");
}
var debug = (options && options.debug) || false;
if (debug) {
var el = document.querySelector("#input-textarea-caret-position-mirror-div");
if (el) el.parentNode.removeChild(el);
}
// The mirror div will replicate the textarea's style
var div = document.createElement("div");
div.id = "input-textarea-caret-position-mirror-div";
document.body.appendChild(div);
var style = div.style;
var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
var isInput = element.nodeName === "INPUT";
// Default textarea styles
style.whiteSpace = "pre-wrap";
if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
// Position off-screen
style.position = "absolute"; // required to return coordinates properly
if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
// Transfer the element's properties to the div
properties.forEach(function (prop) {
if (isInput && prop === "lineHeight") {
// Special case for <input>s because text is rendered centered and line height may be != height
if (computed.boxSizing === "border-box") {
var height = parseInt(computed.height);
var outerHeight =
parseInt(computed.paddingTop) +
parseInt(computed.paddingBottom) +
parseInt(computed.borderTopWidth) +
parseInt(computed.borderBottomWidth);
var targetHeight = outerHeight + parseInt(computed.lineHeight);
if (height > targetHeight) {
style.lineHeight = height - outerHeight + "px";
} else if (height === targetHeight) {
style.lineHeight = computed.lineHeight;
} else {
style.lineHeight = 0;
}
} else {
style.lineHeight = computed.height;
}
} else {
style[prop] = computed[prop];
}
});
if (isFirefox) {
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = "scroll";
} else {
style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
}
div.textContent = element.value.substring(0, position);
// The second special handling for input type="text" vs textarea:
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
var span = document.createElement("span");
// Wrapping must be replicated *exactly*, including when a long word gets
// onto the next line, with whitespace at the end of the line before (#7).
// The *only* reliable way to do that is to copy the *entire* rest of the
// textarea's content into the <span> created at the caret position.
// For inputs, just '.' would be enough, but no need to bother.
span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all
div.appendChild(span);
var coordinates = {
top: span.offsetTop + parseInt(computed["borderTopWidth"]),
left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
height: parseInt(computed["lineHeight"]),
};
if (debug) {
span.style.backgroundColor = "#aaa";
} else {
document.body.removeChild(div);
}
return coordinates;
};
})();
/*
Key functions from:
https://github.com/yuku/textcomplete
© Yuku Takahashi - This software is licensed under the MIT license.
The MIT License (MIT)
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const CHAR_CODE_ZERO = "0".charCodeAt(0);
const CHAR_CODE_NINE = "9".charCodeAt(0);
class TextAreaCaretHelper {
constructor(el, getScale) {
this.el = el;
this.getScale = getScale;
}
#calculateElementOffset() {
const rect = this.el.getBoundingClientRect();
const owner = this.el.ownerDocument;
if (owner == null) {
throw new Error("Given element does not belong to document");
}
const { defaultView, documentElement } = owner;
if (defaultView == null) {
throw new Error("Given element does not belong to window");
}
const offset = {
top: rect.top + defaultView.pageYOffset,
left: rect.left + defaultView.pageXOffset,
};
if (documentElement) {
offset.top -= documentElement.clientTop;
offset.left -= documentElement.clientLeft;
}
return offset;
}
#isDigit(charCode) {
return CHAR_CODE_ZERO <= charCode && charCode <= CHAR_CODE_NINE;
}
#getLineHeightPx() {
const computedStyle = getComputedStyle(this.el);
const lineHeight = computedStyle.lineHeight;
// If the char code starts with a digit, it is either a value in pixels,
// or unitless, as per:
// https://drafts.csswg.org/css2/visudet.html#propdef-line-height
// https://drafts.csswg.org/css2/cascade.html#computed-value
if (this.#isDigit(lineHeight.charCodeAt(0))) {
const floatLineHeight = parseFloat(lineHeight);
// In real browsers the value is *always* in pixels, even for unit-less
// line-heights. However, we still check as per the spec.
return this.#isDigit(lineHeight.charCodeAt(lineHeight.length - 1))
? floatLineHeight * parseFloat(computedStyle.fontSize)
: floatLineHeight;
}
// Otherwise, the value is "normal".
// If the line-height is "normal", calculate by font-size
return this.#calculateLineHeightPx(this.el.nodeName, computedStyle);
}
/**
* Returns calculated line-height of the given node in pixels.
*/
#calculateLineHeightPx(nodeName, computedStyle) {
const body = document.body;
if (!body) return 0;
const tempNode = document.createElement(nodeName);
tempNode.innerHTML = "&nbsp;";
Object.assign(tempNode.style, {
fontSize: computedStyle.fontSize,
fontFamily: computedStyle.fontFamily,
padding: "0",
position: "absolute",
});
body.appendChild(tempNode);
// Make sure textarea has only 1 row
if (tempNode instanceof HTMLTextAreaElement) {
tempNode.rows = 1;
}
// Assume the height of the element is the line-height
const height = tempNode.offsetHeight;
body.removeChild(tempNode);
return height;
}
getCursorOffset() {
const scale = this.getScale();
const elOffset = this.#calculateElementOffset();
const elScroll = this.#getElScroll();
const cursorPosition = this.#getCursorPosition();
const lineHeight = this.#getLineHeightPx();
const top = elOffset.top - (elScroll.top * scale) + (cursorPosition.top + lineHeight) * scale;
const left = elOffset.left - elScroll.left + cursorPosition.left;
const clientTop = this.el.getBoundingClientRect().top;
if (this.el.dir !== "rtl") {
return { top, left, lineHeight, clientTop };
} else {
const right = document.documentElement ? document.documentElement.clientWidth - left : 0;
return { top, right, lineHeight, clientTop };
}
}
#getElScroll() {
return { top: this.el.scrollTop, left: this.el.scrollLeft };
}
#getCursorPosition() {
return getCaretCoordinates(this.el, this.el.selectionEnd);
}
getBeforeCursor() {
return this.el.selectionStart !== this.el.selectionEnd ? null : this.el.value.substring(0, this.el.selectionEnd);
}
getAfterCursor() {
return this.el.value.substring(this.el.selectionEnd);
}
insertAtCursor(value, offset, finalOffset) {
if (this.el.selectionStart != null) {
const startPos = this.el.selectionStart;
const endPos = this.el.selectionEnd;
// Move selection to beginning of offset
this.el.selectionStart = this.el.selectionStart + offset;
// Using execCommand to support undo, but since it's officially
// 'deprecated' we need a backup solution, but it won't support undo :(
let pasted = true;
try {
if (!document.execCommand("insertText", false, value)) {
pasted = false;
}
} catch (e) {
console.error("Error caught during execCommand:", e);
pasted = false;
}
if (!pasted) {
console.error(
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
textarea.setRangeText(modifiedText, this.el.selectionStart, this.el.selectionEnd, 'end');
}
this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + (finalOffset ?? 0);
} else {
// Using execCommand to support undo, but since it's officially
// 'deprecated' we need a backup solution, but it won't support undo :(
let pasted = true;
try {
if (!document.execCommand("insertText", false, value)) {
pasted = false;
}
} catch (e) {
console.error("Error caught during execCommand:", e);
pasted = false;
}
if (!pasted) {
console.error(
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
this.el.value += value;
}
}
}
}
/*********************/
/**
* @typedef {{
* text: string,
* priority?: number,
* info?: Function,
* hint?: string,
* showValue?: boolean,
* caretOffset?: number
* }} AutoCompleteEntry
*/
export class TextAreaAutoComplete {
static globalSeparator = "";
static enabled = true;
static insertOnTab = true;
static insertOnEnter = true;
static replacer = undefined;
static lorasEnabled = false;
static suggestionCount = 20;
/** @type {Record<string, Record<string, AutoCompleteEntry>>} */
static groups = {};
/** @type {Set<string>} */
static globalGroups = new Set();
/** @type {Record<string, AutoCompleteEntry>} */
static globalWords = {};
/** @type {Record<string, AutoCompleteEntry>} */
static globalWordsExclLoras = {};
/** @type {HTMLTextAreaElement} */
el;
/** @type {Record<string, AutoCompleteEntry>} */
overrideWords;
overrideSeparator = "";
get words() {
return this.overrideWords ?? TextAreaAutoComplete.globalWords;
}
get separator() {
return this.overrideSeparator ?? TextAreaAutoComplete.globalSeparator;
}
/**
* @param {HTMLTextAreaElement} el
*/
constructor(el, words = null, separator = null) {
this.el = el;
this.helper = new TextAreaCaretHelper(el, () => app.canvas.ds.scale);
this.dropdown = $el("div.pysssss-autocomplete");
this.overrideWords = words;
this.overrideSeparator = separator;
this.#setup();
}
#setup() {
this.el.addEventListener("keydown", this.#keyDown.bind(this));
this.el.addEventListener("keypress", this.#keyPress.bind(this));
this.el.addEventListener("keyup", this.#keyUp.bind(this));
this.el.addEventListener("click", this.#hide.bind(this));
this.el.addEventListener("blur", () => setTimeout(() => this.#hide(), 150));
}
/**
* @param {KeyboardEvent} e
*/
#keyDown(e) {
if (!TextAreaAutoComplete.enabled) return;
if (this.dropdown.parentElement) {
// We are visible
switch (e.key) {
case "ArrowUp":
e.preventDefault();
if (this.selected.index) {
this.#setSelected(this.currentWords[this.selected.index - 1].wordInfo);
} else {
this.#setSelected(this.currentWords[this.currentWords.length - 1].wordInfo);
}
break;
case "ArrowDown":
e.preventDefault();
if (this.selected.index === this.currentWords.length - 1) {
this.#setSelected(this.currentWords[0].wordInfo);
} else {
this.#setSelected(this.currentWords[this.selected.index + 1].wordInfo);
}
break;
case "Tab":
if (TextAreaAutoComplete.insertOnTab) {
this.#insertItem();
e.preventDefault();
}
break;
}
}
}
/**
* @param {KeyboardEvent} e
*/
#keyPress(e) {
if (!TextAreaAutoComplete.enabled) return;
if (this.dropdown.parentElement) {
// We are visible
switch (e.key) {
case "Enter":
if (!e.ctrlKey) {
if (TextAreaAutoComplete.insertOnEnter) {
this.#insertItem();
e.preventDefault();
}
}
break;
}
}
if (!e.defaultPrevented) {
this.#update();
}
}
#keyUp(e) {
if (!TextAreaAutoComplete.enabled) return;
if (this.dropdown.parentElement) {
// We are visible
switch (e.key) {
case "Escape":
e.preventDefault();
this.#hide();
break;
}
} else if (e.key.length > 1 && e.key != "Delete" && e.key != "Backspace") {
return;
}
if (!e.defaultPrevented) {
this.#update();
}
}
#setSelected(item) {
if (this.selected) {
this.selected.el.classList.remove("pysssss-autocomplete-item--selected");
}
this.selected = item;
this.selected.el.classList.add("pysssss-autocomplete-item--selected");
}
#insertItem() {
if (!this.selected) return;
this.selected.el.click();
}
#getFilteredWords(term) {
term = term.toLocaleLowerCase();
const priorityMatches = [];
const prefixMatches = [];
const includesMatches = [];
for (const word of Object.keys(this.words)) {
const lowerWord = word.toLocaleLowerCase();
if (lowerWord === term) {
// Dont include exact matches
continue;
}
const pos = lowerWord.indexOf(term);
if (pos === -1) {
// No match
continue;
}
const wordInfo = this.words[word];
if (wordInfo.priority) {
priorityMatches.push({ pos, wordInfo });
} else if (pos) {
includesMatches.push({ pos, wordInfo });
} else {
prefixMatches.push({ pos, wordInfo });
}
}
priorityMatches.sort(
(a, b) =>
b.wordInfo.priority - a.wordInfo.priority ||
a.wordInfo.text.length - b.wordInfo.text.length ||
a.wordInfo.text.localeCompare(b.wordInfo.text)
);
const top = priorityMatches.length * 0.2;
return priorityMatches.slice(0, top).concat(prefixMatches, priorityMatches.slice(top), includesMatches).slice(0, TextAreaAutoComplete.suggestionCount);
}
#update() {
let before = this.helper.getBeforeCursor();
if (before?.length) {
const m = before.match(/([^\s|,|;|"]+)$/);
if (m) {
before = m[0];
} else {
before = null;
}
}
if (!before) {
this.#hide();
return;
}
this.currentWords = this.#getFilteredWords(before);
if (!this.currentWords.length) {
this.#hide();
return;
}
this.dropdown.style.display = "";
let hasSelected = false;
const items = this.currentWords.map(({ wordInfo, pos }, i) => {
const parts = [
$el("span", {
textContent: wordInfo.text.substr(0, pos),
}),
$el("span.pysssss-autocomplete-highlight", {
textContent: wordInfo.text.substr(pos, before.length),
}),
$el("span", {
textContent: wordInfo.text.substr(pos + before.length),
}),
];
if (wordInfo.hint) {
parts.push(
$el("span.pysssss-autocomplete-pill", {
textContent: wordInfo.hint,
})
);
}
if (wordInfo.priority) {
parts.push(
$el("span.pysssss-autocomplete-pill", {
textContent: wordInfo.priority,
})
);
}
if (wordInfo.value && wordInfo.text !== wordInfo.value && wordInfo.showValue !== false) {
parts.push(
$el("span.pysssss-autocomplete-pill", {
textContent: wordInfo.value,
})
);
}
if (wordInfo.info) {
parts.push(
$el("a.pysssss-autocomplete-item-info", {
textContent: "ℹ️",
title: "View info...",
onclick: (e) => {
e.stopPropagation();
wordInfo.info();
e.preventDefault();
},
})
);
}
const item = $el(
"div.pysssss-autocomplete-item",
{
onclick: () => {
this.el.focus();
let value = wordInfo.value ?? wordInfo.text;
const use_replacer = wordInfo.use_replacer ?? true;
if (TextAreaAutoComplete.replacer && use_replacer) {
value = TextAreaAutoComplete.replacer(value);
}
this.helper.insertAtCursor(value + this.separator, -before.length, wordInfo.caretOffset);
setTimeout(() => {
this.#update();
}, 150);
},
onmousemove: () => {
this.#setSelected(wordInfo);
},
},
parts
);
if (wordInfo === this.selected) {
hasSelected = true;
}
wordInfo.index = i;
wordInfo.el = item;
return item;
});
this.#setSelected(hasSelected ? this.selected : this.currentWords[0].wordInfo);
this.dropdown.replaceChildren(...items);
if (!this.dropdown.parentElement) {
document.body.append(this.dropdown);
}
const position = this.helper.getCursorOffset();
this.dropdown.style.left = (position.left ?? 0) + "px";
this.dropdown.style.top = (position.top ?? 0) + "px";
this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px";
}
#hide() {
this.selected = null;
this.dropdown.remove();
}
static updateWords(id, words, addGlobal = true) {
const isUpdate = id in TextAreaAutoComplete.groups;
TextAreaAutoComplete.groups[id] = words;
if (addGlobal) {
TextAreaAutoComplete.globalGroups.add(id);
}
if (isUpdate) {
// Remerge all words
TextAreaAutoComplete.globalWords = Object.assign(
{},
...Object.keys(TextAreaAutoComplete.groups)
.filter((k) => TextAreaAutoComplete.globalGroups.has(k))
.map((k) => TextAreaAutoComplete.groups[k])
);
} else if (addGlobal) {
// Just insert the new words
Object.assign(TextAreaAutoComplete.globalWords, words);
}
}
}
+244
View File
@@ -0,0 +1,244 @@
// @ts-check
// @ts-ignore
import { ComfyWidgets } from "../../../../scripts/widgets.js";
// @ts-ignore
import { api } from "../../../../scripts/api.js";
// @ts-ignore
import { app } from "../../../../scripts/app.js";
const PathHelper = {
get(obj, path) {
if (typeof path !== "string") {
// Hardcoded value
return path;
}
if (path[0] === '"' && path[path.length - 1] === '"') {
// Hardcoded string
return JSON.parse(path);
}
// Evaluate the path
path = path.split(".").filter(Boolean);
for (const p of path) {
const k = isNaN(+p) ? p : +p;
obj = obj[k];
}
return obj;
},
set(obj, path, value) {
// https://stackoverflow.com/a/54733755
if (Object(obj) !== obj) return obj; // When obj is not an object
// If not yet an array, get the keys from the string-path
if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
path.slice(0, -1).reduce(
(
a,
c,
i // Iterate all of them except the last one
) =>
Object(a[c]) === a[c] // Does the key exist and is its value an object?
? // Yes: then follow that path
a[c]
: // No: create the key. Is the next key a potential array-index?
(a[c] =
Math.abs(path[i + 1]) >> 0 === +path[i + 1]
? [] // Yes: assign a new array object
: {}), // No: assign a new plain object
obj
)[path[path.length - 1]] = value; // Finally assign the value to the last key
return obj; // Return the top-level object to allow chaining
},
};
/***
@typedef { {
left: string;
op: "eq" | "ne",
right: string
} } IfCondition
@typedef { {
type: "if",
condition: Array<IfCondition>,
true?: Array<BindingCallback>,
false?: Array<BindingCallback>
} } IfCallback
@typedef { {
type: "fetch",
url: string,
then: Array<BindingCallback>
} } FetchCallback
@typedef { {
type: "set",
target: string,
value: string
} } SetCallback
@typedef { {
type: "validate-combo",
} } ValidateComboCallback
@typedef { IfCallback | FetchCallback | SetCallback | ValidateComboCallback } BindingCallback
@typedef { {
source: string,
callback: Array<BindingCallback>
} } Binding
***/
/**
* @param {IfCondition} condition
*/
function evaluateCondition(condition, state) {
const left = PathHelper.get(state, condition.left);
const right = PathHelper.get(state, condition.right);
let r;
if (condition.op === "eq") {
r = left === right;
} else {
r = left !== right;
}
return r;
}
/**
* @type { Record<BindingCallback["type"], (cb: any, state: Record<string, any>) => Promise<void>> }
*/
const callbacks = {
/**
* @param {IfCallback} cb
*/
async if(cb, state) {
// For now only support ANDs
let success = true;
for (const condition of cb.condition) {
const r = evaluateCondition(condition, state);
if (!r) {
success = false;
break;
}
}
for (const m of cb[success + ""] ?? []) {
await invokeCallback(m, state);
}
},
/**
* @param {FetchCallback} cb
*/
async fetch(cb, state) {
const url = cb.url.replace(/\{([^\}]+)\}/g, (m, v) => {
return PathHelper.get(state, v);
});
const res = await (await api.fetchApi(url)).json();
state["$result"] = res;
for (const m of cb.then) {
await invokeCallback(m, state);
}
},
/**
* @param {SetCallback} cb
*/
async set(cb, state) {
const value = PathHelper.get(state, cb.value);
PathHelper.set(state, cb.target, value);
},
async "validate-combo"(cb, state) {
const w = state["$this"];
const valid = w.options.values.includes(w.value);
if (!valid) {
w.value = w.options.values[0];
}
},
};
async function invokeCallback(callback, state) {
if (callback.type in callbacks) {
// @ts-ignore
await callbacks[callback.type](callback, state);
} else {
console.warn(
"%c[🐍 pysssss]",
"color: limegreen",
`[binding ${state.$node.comfyClass}.${state.$this.name}]`,
"unsupported binding callback type:",
callback.type
);
}
}
app.registerExtension({
name: "pysssss.Binding",
beforeRegisterNodeDef(node, nodeData) {
const hasBinding = (v) => {
if (!v) return false;
return Object.values(v).find((c) => c[1]?.["pysssss.binding"]);
};
const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional };
if (hasBinding(inputs)) {
const onAdded = node.prototype.onAdded;
node.prototype.onAdded = function () {
const r = onAdded?.apply(this, arguments);
for (const widget of this.widgets || []) {
const bindings = inputs[widget.name][1]?.["pysssss.binding"];
if (!bindings) continue;
for (const binding of bindings) {
/**
* @type {import("../../../../../web/types/litegraph.d.ts").IWidget}
*/
const source = this.widgets.find((w) => w.name === binding.source);
if (!source) {
console.warn(
"%c[🐍 pysssss]",
"color: limegreen",
`[binding ${node.comfyClass}.${widget.name}]`,
"unable to find source binding widget:",
binding.source,
binding
);
continue;
}
let lastValue;
async function valueChanged() {
const state = {
$this: widget,
$source: source,
$node: node,
};
for (const callback of binding.callback) {
await invokeCallback(callback, state);
}
app.graph.setDirtyCanvas(true, false);
}
const cb = source.callback;
source.callback = function () {
const v = cb?.apply(this, arguments) ?? source.value;
if (v !== lastValue) {
lastValue = v;
valueChanged();
}
return v;
};
lastValue = source.value;
valueChanged();
}
}
return r;
};
}
},
});
+98
View File
@@ -0,0 +1,98 @@
.pysssss-lightbox {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
transition: opacity 0.2s;
}
.pysssss-lightbox-prev,
.pysssss-lightbox-next {
height: 60px;
display: flex;
align-items: center;
}
.pysssss-lightbox-prev:after,
.pysssss-lightbox-next:after {
border-style: solid;
border-width: 0.25em 0.25em 0 0;
display: inline-block;
height: 0.45em;
left: 0.15em;
position: relative;
top: 0.15em;
transform: rotate(-135deg) scale(0.75);
vertical-align: top;
width: 0.45em;
padding: 10px;
font-size: 20px;
margin: 0 10px 0 20px;
transition: color 0.2s;
flex-shrink: 0;
content: "";
}
.pysssss-lightbox-next:after {
transform: rotate(45deg) scale(0.75);
margin: 0 20px 0 0px;
}
.pysssss-lightbox-main {
flex: auto;
text-align: center;
}
.pysssss-lightbox-link {
display: inline-block;
position: relative;
}
.pysssss-lightbox .lds-ring {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.pysssss-lightbox-img {
max-height: 90vh;
max-width: calc(100vw - 130px);
height: auto;
object-fit: contain;
border: 3px solid white;
border-radius: 4px;
transition: opacity 0.2s;
user-select: none;
}
.pysssss-lightbox-img:hover {
border-color: dodgerblue;
}
.pysssss-lightbox-close {
font-size: 80px;
line-height: 1ch;
height: 1ch;
width: 1ch;
position: absolute;
right: 10px;
top: 10px;
padding: 5px;
}
.pysssss-lightbox-close:after {
content: "\00d7";
}
.pysssss-lightbox-close:hover,
.pysssss-lightbox-prev:hover,
.pysssss-lightbox-next:hover {
color: dodgerblue;
cursor: pointer;
}
+149
View File
@@ -0,0 +1,149 @@
import { $el } from "../../../../scripts/ui.js";
import { addStylesheet, getUrl, loadImage } from "./utils.js";
import { createSpinner } from "./spinner.js";
addStylesheet(getUrl("lightbox.css", import.meta.url));
const $$el = (tag, name, ...args) => {
if (name) name = "-" + name;
return $el(tag + ".pysssss-lightbox" + name, ...args);
};
const ani = async (a, t, b) => {
a();
await new Promise((r) => setTimeout(r, t));
b();
};
export class Lightbox {
constructor() {
this.el = $$el("div", "", {
parent: document.body,
onclick: (e) => {
e.stopImmediatePropagation();
this.close();
},
style: {
display: "none",
opacity: 0,
},
});
this.closeBtn = $$el("div", "close", {
parent: this.el,
});
this.prev = $$el("div", "prev", {
parent: this.el,
onclick: (e) => {
this.update(-1);
e.stopImmediatePropagation();
},
});
this.main = $$el("div", "main", {
parent: this.el,
});
this.next = $$el("div", "next", {
parent: this.el,
onclick: (e) => {
this.update(1);
e.stopImmediatePropagation();
},
});
this.link = $$el("a", "link", {
parent: this.main,
target: "_blank",
});
this.spinner = createSpinner();
this.link.appendChild(this.spinner);
this.img = $$el("img", "img", {
style: {
opacity: 0,
},
parent: this.link,
onclick: (e) => {
e.stopImmediatePropagation();
},
onwheel: (e) => {
if (!(e instanceof WheelEvent) || e.ctrlKey) {
return;
}
const direction = Math.sign(e.deltaY);
this.update(direction);
},
});
}
close() {
ani(
() => (this.el.style.opacity = 0),
200,
() => (this.el.style.display = "none")
);
}
async show(images, index) {
this.images = images;
this.index = index || 0;
await this.update(0);
}
async update(shift) {
if (shift < 0 && this.index <= 0) {
return;
}
if (shift > 0 && this.index >= this.images.length - 1) {
return;
}
this.index += shift;
this.prev.style.visibility = this.index ? "unset" : "hidden";
this.next.style.visibility = this.index === this.images.length - 1 ? "hidden" : "unset";
const img = this.images[this.index];
this.el.style.display = "flex";
this.el.clientWidth; // Force a reflow
this.el.style.opacity = 1;
this.img.style.opacity = 0;
this.spinner.style.display = "inline-block";
try {
await loadImage(img);
} catch (err) {
console.error('failed to load image', img, err);
}
this.spinner.style.display = "none";
this.link.href = img;
this.img.src = img;
this.img.style.opacity = 1;
}
async updateWithNewImage(img, feedDirection) {
// No-op if lightbox is not open
if (this.el.style.display === "none" || this.el.style.opacity === "0") return;
// Ensure currently shown image does not change
const [method, shift] = feedDirection === "newest first" ? ["unshift", 1] : ["push", 0];
this.images[method](img);
await this.update(shift);
}
}
export const lightbox = new Lightbox();
addEventListener('keydown', (event) => {
if (lightbox.el.style.display === 'none') {
return;
}
const { key } = event;
switch (key) {
case 'ArrowLeft':
case 'a':
lightbox.update(-1);
break;
case 'ArrowRight':
case 'd':
lightbox.update(1);
break;
case 'Escape':
lightbox.close();
break;
}
});
+119
View File
@@ -0,0 +1,119 @@
.pysssss-model-info {
color: white;
font-family: sans-serif;
max-width: 90vw;
}
.pysssss-model-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.pysssss-model-info h2 {
text-align: center;
margin: 0 0 10px 0;
}
.pysssss-model-info p {
margin: 5px 0;
}
.pysssss-model-info a {
color: dodgerblue;
}
.pysssss-model-info a:hover {
text-decoration: underline;
}
.pysssss-model-tags-list {
display: flex;
flex-wrap: wrap;
list-style: none;
gap: 10px;
max-height: 200px;
overflow: auto;
margin: 10px 0;
padding: 0;
}
.pysssss-model-tag {
background-color: rgb(128, 213, 247);
color: #000;
display: flex;
align-items: center;
gap: 5px;
border-radius: 5px;
padding: 2px 5px;
cursor: pointer;
}
.pysssss-model-tag--selected span::before {
content: "✅";
position: absolute;
background-color: dodgerblue;
left: 0;
top: 0;
right: 0;
bottom: 0;
text-align: center;
}
.pysssss-model-tag:hover {
outline: 2px solid dodgerblue;
}
.pysssss-model-tag p {
margin: 0;
}
.pysssss-model-tag span {
text-align: center;
border-radius: 5px;
background-color: dodgerblue;
color: #fff;
padding: 2px;
position: relative;
min-width: 20px;
overflow: hidden;
}
.pysssss-model-metadata .comfy-modal-content {
max-width: 100%;
}
.pysssss-model-metadata label {
margin-right: 1ch;
color: #ccc;
}
.pysssss-model-metadata span {
color: dodgerblue;
}
.pysssss-preview {
max-width: 50%;
margin-left: 10px;
position: relative;
}
.pysssss-preview img {
max-height: 300px;
}
.pysssss-preview button {
position: absolute;
font-size: 12px;
bottom: 10px;
right: 10px;
}
.pysssss-preview button+button {
bottom: 34px;
}
.pysssss-preview button.pysssss-preview-nav {
bottom: unset;
right: 30px;
top: 10px;
font-size: 14px;
line-height: 14px;
}
.pysssss-preview button.pysssss-preview-nav+.pysssss-preview-nav {
right: 10px;
}
.pysssss-model-notes {
background-color: rgba(0, 0, 0, 0.25);
padding: 5px;
margin-top: 5px;
}
.pysssss-model-notes:empty {
display: none;
}
+358
View File
@@ -0,0 +1,358 @@
import { $el, ComfyDialog } from "../../../../scripts/ui.js";
import { api } from "../../../../scripts/api.js";
import { addStylesheet } from "./utils.js";
addStylesheet(import.meta.url);
class MetadataDialog extends ComfyDialog {
constructor() {
super();
this.element.classList.add("pysssss-model-metadata");
}
show(metadata) {
super.show(
$el(
"div",
Object.keys(metadata).map((k) =>
$el("div", [
$el("label", { textContent: k }),
$el("span", { textContent: typeof metadata[k] === "object" ? JSON.stringify(metadata[k]) : metadata[k] }),
])
)
)
);
}
}
export class ModelInfoDialog extends ComfyDialog {
constructor(name, node) {
super();
this.name = name;
this.node = node;
this.element.classList.add("pysssss-model-info");
}
get customNotes() {
return this.metadata["pysssss.notes"];
}
set customNotes(v) {
this.metadata["pysssss.notes"] = v;
}
get hash() {
return this.metadata["pysssss.sha256"];
}
async show(type, value) {
this.type = type;
const req = api.fetchApi("/pysssss/metadata/" + encodeURIComponent(`${type}/${value}`));
this.info = $el("div", { style: { flex: "auto" } });
this.img = $el("img", { style: { display: "none" } });
this.imgWrapper = $el("div.pysssss-preview", [this.img]);
this.main = $el("main", { style: { display: "flex" } }, [this.info, this.imgWrapper]);
this.content = $el("div.pysssss-model-content", [$el("h2", { textContent: this.name }), this.main]);
const loading = $el("div", { textContent: "ℹ️ Loading...", parent: this.content });
super.show(this.content);
this.metadata = await (await req).json();
this.viewMetadata.style.cursor = this.viewMetadata.style.opacity = "";
this.viewMetadata.removeAttribute("disabled");
loading.remove();
this.addInfo();
}
createButtons() {
const btns = super.createButtons();
this.viewMetadata = $el("button", {
type: "button",
textContent: "View raw metadata",
disabled: "disabled",
style: {
opacity: 0.5,
cursor: "not-allowed",
},
onclick: (e) => {
if (this.metadata) {
new MetadataDialog().show(this.metadata);
}
},
});
btns.unshift(this.viewMetadata);
return btns;
}
getNoteInfo() {
function parseNote() {
if (!this.customNotes) return [];
let notes = [];
// Extract links from notes
const r = new RegExp("(\\bhttps?:\\/\\/[^\\s]+)", "g");
let end = 0;
let m;
do {
m = r.exec(this.customNotes);
let pos;
let fin = 0;
if (m) {
pos = m.index;
fin = m.index + m[0].length;
} else {
pos = this.customNotes.length;
}
let pre = this.customNotes.substring(end, pos);
if (pre) {
pre = pre.replaceAll("\n", "<br>");
notes.push(
$el("span", {
innerHTML: pre,
})
);
}
if (m) {
notes.push(
$el("a", {
href: m[0],
textContent: m[0],
target: "_blank",
})
);
}
end = fin;
} while (m);
return notes;
}
let textarea;
let notesContainer;
const editText = "✏️ Edit";
const edit = $el("a", {
textContent: editText,
href: "#",
style: {
float: "right",
color: "greenyellow",
textDecoration: "none",
},
onclick: async (e) => {
e.preventDefault();
if (textarea) {
this.customNotes = textarea.value;
const resp = await api.fetchApi("/pysssss/metadata/notes/" + encodeURIComponent(`${this.type}/${this.name}`), {
method: "POST",
body: this.customNotes,
});
if (resp.status !== 200) {
console.error(resp);
alert(`Error saving notes (${req.status}) ${req.statusText}`);
return;
}
e.target.textContent = editText;
textarea.remove();
textarea = null;
notesContainer.replaceChildren(...parseNote.call(this));
this.node?.["pysssss.updateExamples"]?.();
} else {
e.target.textContent = "💾 Save";
textarea = $el("textarea", {
style: {
width: "100%",
minWidth: "200px",
minHeight: "50px",
},
textContent: this.customNotes,
});
e.target.after(textarea);
notesContainer.replaceChildren();
textarea.style.height = Math.min(textarea.scrollHeight, 300) + "px";
}
},
});
notesContainer = $el("div.pysssss-model-notes", parseNote.call(this));
return $el(
"div",
{
style: { display: "contents" },
},
[edit, notesContainer]
);
}
addInfo() {
const usageHint = this.metadata["modelspec.usage_hint"];
if (usageHint) {
this.addInfoEntry("Usage Hint", usageHint);
}
this.addInfoEntry("Notes", this.getNoteInfo());
}
addInfoEntry(name, value) {
return $el(
"p",
{
parent: this.info,
},
[
typeof name === "string" ? $el("label", { textContent: name + ": " }) : name,
typeof value === "string" ? $el("span", { textContent: value }) : value,
]
);
}
async getCivitaiDetails() {
const req = await fetch("https://civitai.com/api/v1/model-versions/by-hash/" + this.hash);
if (req.status === 200) {
return await req.json();
} else if (req.status === 404) {
throw new Error("Model not found");
} else {
throw new Error(`Error loading info (${req.status}) ${req.statusText}`);
}
}
addCivitaiInfo() {
const promise = this.getCivitaiDetails();
const content = $el("span", { textContent: "ℹ️ Loading..." });
this.addInfoEntry(
$el("label", [
$el("img", {
style: {
width: "18px",
position: "relative",
top: "3px",
margin: "0 5px 0 0",
},
src: "https://civitai.com/favicon.ico",
}),
$el("span", { textContent: "Civitai: " }),
]),
content
);
return promise
.then((info) => {
content.replaceChildren(
$el("a", {
href: "https://civitai.com/models/" + info.modelId,
textContent: "View " + info.model.name,
target: "_blank",
})
);
const allPreviews = info.images?.filter((i) => i.type === "image");
const previews = allPreviews?.filter((i) => i.nsfwLevel <= ModelInfoDialog.nsfwLevel);
if (previews?.length) {
let previewIndex = 0;
let preview;
const updatePreview = () => {
preview = previews[previewIndex];
this.img.src = preview.url;
};
updatePreview();
this.img.style.display = "";
this.img.title = `${previews.length} previews.`;
if (allPreviews.length !== previews.length) {
this.img.title += ` ${allPreviews.length - previews.length} images hidden due to NSFW level.`;
}
this.imgSave = $el("button", {
textContent: "Use as preview",
parent: this.imgWrapper,
onclick: async () => {
// Convert the preview to a blob
const blob = await (await fetch(this.img.src)).blob();
// Store it in temp
const name = "temp_preview." + new URL(this.img.src).pathname.split(".")[1];
const body = new FormData();
body.append("image", new File([blob], name));
body.append("overwrite", "true");
body.append("type", "temp");
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
if (resp.status !== 200) {
console.error(resp);
alert(`Error saving preview (${req.status}) ${req.statusText}`);
return;
}
// Use as preview
await api.fetchApi("/pysssss/save/" + encodeURIComponent(`${this.type}/${this.name}`), {
method: "POST",
body: JSON.stringify({
filename: name,
type: "temp",
}),
headers: {
"content-type": "application/json",
},
});
app.refreshComboInNodes();
},
});
$el("button", {
textContent: "Show metadata",
parent: this.imgWrapper,
onclick: async () => {
if (preview.meta && Object.keys(preview.meta).length) {
new MetadataDialog().show(preview.meta);
} else {
alert("No image metadata found");
}
},
});
const addNavButton = (icon, direction) => {
$el("button.pysssss-preview-nav", {
textContent: icon,
parent: this.imgWrapper,
onclick: async () => {
previewIndex += direction;
if (previewIndex < 0) {
previewIndex = previews.length - 1;
} else if (previewIndex >= previews.length) {
previewIndex = 0;
}
updatePreview();
},
});
};
if (previews.length > 1) {
addNavButton("‹", -1);
addNavButton("›", 1);
}
} else if (info.images?.length) {
$el("span", { style: { opacity: 0.6 }, textContent: "⚠️ All images hidden due to NSFW level setting.", parent: this.imgWrapper });
}
return info;
})
.catch((err) => {
content.textContent = "⚠️ " + err.message;
});
}
}
+35
View File
@@ -0,0 +1,35 @@
.pysssss-lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.pysssss-lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 5px solid #fff;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
}
.pysssss-lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.pysssss-lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.pysssss-lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { addStylesheet } from "./utils.js";
addStylesheet(import.meta.url);
export function createSpinner() {
const div = document.createElement("div");
div.innerHTML = `<div class="pysssss-lds-ring"><div></div><div></div><div></div><div></div></div>`;
return div.firstElementChild;
}
+30
View File
@@ -0,0 +1,30 @@
import { $el } from "../../../../scripts/ui.js";
export function addStylesheet(url) {
if (url.endsWith(".js")) {
url = url.substr(0, url.length - 2) + "css";
}
$el("link", {
parent: document.head,
rel: "stylesheet",
type: "text/css",
href: url.startsWith("http") ? url : getUrl(url),
});
}
export function getUrl(path, baseUrl) {
if (baseUrl) {
return new URL(path, baseUrl).toString();
} else {
return new URL("../" + path, import.meta.url).toString();
}
}
export async function loadImage(url) {
return new Promise((res, rej) => {
const img = new Image();
img.onload = res;
img.onerror = rej;
img.src = url;
});
}
+6
View File
File diff suppressed because one or more lines are too long
+177
View File
@@ -0,0 +1,177 @@
import * as THREE from 'three';
import { api } from '/scripts/api.js';
import {getRGBValue} from '/extensions/Comfy-Rodin/js/sharedFunctions.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
const visualizer = document.getElementById("visualizer");
const container = document.getElementById( 'container' );
const progressDialog = document.getElementById("progress-dialog");
const progressIndicator = document.getElementById("progress-indicator");
const colorPicker = document.getElementById("color-picker");
const downloadButton = document.getElementById("download-button");
const renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
container.appendChild( renderer.domElement );
const pmremGenerator = new THREE.PMREMGenerator( renderer );
// scene
const scene = new THREE.Scene();
scene.background = new THREE.Color( 0x000000 );
scene.environment = pmremGenerator.fromScene( new RoomEnvironment( renderer ), 0.04 ).texture;
const ambientLight = new THREE.AmbientLight( 0xffffff , 3.0 );
const camera = new THREE.PerspectiveCamera( 40, window.innerWidth / window.innerHeight, 1, 100 );
camera.position.set( 5, 2, 8 );
const pointLight = new THREE.PointLight( 0xffffff, 15 );
camera.add( pointLight );
const controls = new OrbitControls( camera, renderer.domElement );
controls.target.set( 0, 0.5, 0 );
controls.update();
controls.enablePan = true;
controls.enableDamping = true;
// Handle window reseize event
window.onresize = function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
};
const clock = new THREE.Clock();
var lastTimestamp = "";
var needUpdate = false;
let mixer;
let currentURL;
var url = location.protocol + '//' + location.host;
downloadButton.addEventListener('click', e => {
window.open(currentURL, '_blank');
});
function frameUpdate() {
var filepath = visualizer.getAttribute("filepath");
var timestamp = visualizer.getAttribute("timestamp");
if (timestamp == lastTimestamp){
if (needUpdate){
controls.update();
if (mixer !== undefined) {
const delta = clock.getDelta();
mixer.update(delta);
}
renderer.render( scene, camera );
}
requestAnimationFrame( frameUpdate );
} else {
needUpdate = false;
scene.clear();
progressDialog.open = true;
lastTimestamp = timestamp;
main(filepath);
}
var color = getRGBValue(colorPicker.value, true);
if (color[0] != scene.background.r || color[1] != scene.background.g || color[2] != scene.background.b){
scene.background.setStyle(colorPicker.value);
renderer.render( scene, camera ); // Force update background color in preview scene
}
}
const onProgress = function ( xhr ) {
if ( xhr.lengthComputable ) {
progressIndicator.value = xhr.loaded / xhr.total * 100;
}
};
const onError = function ( e ) {
console.error( e );
};
async function main(filepath="") {
// Check if file name is valid
if (/^.+\.[a-zA-Z]+$/.test(filepath)){
let params = {"filepath": filepath};
currentURL = api.apiURL('/viewfile?' + new URLSearchParams(params));
var filepathSplit = filepath.split('.');
var fileExt = filepathSplit.pop().toLowerCase();
var filepathNoExt = filepathSplit.join(".");
if (fileExt == "obj"){
const loader = new OBJLoader();
var mtlFolderpath = filepath.substring(0, Math.max(filepath.lastIndexOf("/"), filepath.lastIndexOf("\\"))) + "/";
var mtlFilepath = filepathNoExt.replace(/^.*[\\\/]/, '') + ".mtl";
const mtlLoader = new MTLLoader();
mtlLoader.setPath(api.apiURL('/viewfile?' + new URLSearchParams({"filepath": mtlFolderpath})));
mtlLoader.load( mtlFilepath, function ( mtl ) {
mtl.preload();
loader.setMaterials( mtl );
}, onProgress, onError );
loader.load( currentURL, function ( obj ) {
obj.scale.setScalar( 5 );
scene.add( obj );
obj.traverse(node => {
if (node.material && node.material.map == null) {
node.material.vertexColors = true;
}
});
}, onProgress, onError );
} else if (fileExt == "glb") {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath( 'https://unpkg.com/three@latest/examples/jsm/libs/draco/gltf/' );
const loader = new GLTFLoader();
loader.setDRACOLoader( dracoLoader );
loader.load( currentURL, function ( gltf ) {
const model = gltf.scene;
//model.position.set( 1, 1, 0 );
model.scale.set( 3, 3, 3 );
scene.add( model );
mixer = new THREE.AnimationMixer(model);
gltf.animations.forEach((clip) => {
mixer.clipAction(clip).play();
});
}, onProgress, onError );
} else if (fileExt == "ply") {
} else {
throw new Error(`File extension name has to be either .ply or .splat, got .${fileExt}`);
}
needUpdate = true;
}
scene.add( ambientLight );
scene.add( camera );
progressDialog.close();
frameUpdate();
}
main("D:\\ComfyUI\\ComfyUI_windows_portable_nvidia_cu121_or_cpu\\ComfyUI_windows_portable\\ComfyUI\\output\\2024-09-12_17-52-06\\model.glb");
//main();
+12
View File
@@ -0,0 +1,12 @@
body,
html {
margin: 0;
padding: 0;
overflow: hidden;
background-color: #000;
}
canvas {
width: 100vw;
height: 100vh;
}
File diff suppressed because one or more lines are too long
+74
View File
@@ -0,0 +1,74 @@
dialog {
width: 100%;
text-align: center;
max-width: 20em;
color: white;
background-color: #000;
border: none;
position: relative;
transform: translate(-50%, -50%);
}
#progress-container {
position: absolute;
top: 50%;
left: 50%;
}
progress {
width: 100%;
height: 1em;
border: none;
background-color: #fff;
color: #eee;
}
progress::-webkit-progress-bar {
background-color: #333;
}
progress::-webkit-progress-value {
background-color: #eee;
}
progress::-moz-progress-bar {
background-color: #eee;
}
#container {
position: absolute;
top: 0%;
left: 0%;
width: 100%;
height: 100%;
}
.clr-field {
margin-left: 5px;
margin-top: 5px;
}
.clr-field button {
width: 20px;
height: 20px;
left: 5px;
right: auto;
border-radius: 5px;
}
.clr-field input {
padding-left: 36px;
width: 120px;
height: 24px;
border: 2px solid #aaa;
border-radius: 5px;
}
#download-button {
margin-left: 5px;
margin-top: 10px;
position: absolute;
z-index: 10;
left: 180px;
}
+91
View File
@@ -0,0 +1,91 @@
body {
margin: 0;
background-color: #000;
color: #fff;
font-family: Monospace;
font-size: 13px;
line-height: 24px;
overscroll-behavior: none;
}
a {
color: #ff0;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
cursor: pointer;
text-transform: uppercase;
}
#info {
position: absolute;
top: 0px;
width: 100%;
padding: 10px;
box-sizing: border-box;
text-align: center;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
z-index: 1; /* TODO Solve this in HTML */
}
a, button, input, select {
pointer-events: auto;
}
.lil-gui {
z-index: 2 !important; /* TODO Solve this in HTML */
}
@media all and ( max-width: 640px ) {
.lil-gui.root {
right: auto;
top: auto;
max-height: 50%;
max-width: 80%;
bottom: 0;
left: 0;
}
}
#overlay {
position: absolute;
font-size: 16px;
z-index: 2;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background: rgba(0,0,0,0.7);
}
#overlay button {
background: transparent;
border: 0;
border: 1px solid rgb(255, 255, 255);
border-radius: 4px;
color: #ffffff;
padding: 12px 18px;
text-transform: uppercase;
cursor: pointer;
}
#notSupported {
width: 50%;
margin: auto;
background-color: #f00;
margin-top: 20px;
padding: 10px;
}
@@ -0,0 +1 @@
const e="/extensions/ComfyUI-Rodin/threeVisualizer/assets/emHdBindings-D9OMRJgz.data";export{e as default};
@@ -0,0 +1 @@
const s="/extensions/ComfyUI-Rodin/threeVisualizer/assets/emHdBindings-CZBDkVTp.js";export{s as default};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
const s="/extensions/ComfyUI-Rodin/threeVisualizer/assets/emHdBindings-BNKz2vMm.wasm";export{s as default};
@@ -0,0 +1 @@
const Z="data:text/javascript;base64,InVzZSBzdHJpY3QiO3ZhciBNb2R1bGU9e307dmFyIEVOVklST05NRU5UX0lTX05PREU9dHlwZW9mIHByb2Nlc3M9PSJvYmplY3QiJiZ0eXBlb2YgcHJvY2Vzcy52ZXJzaW9ucz09Im9iamVjdCImJnR5cGVvZiBwcm9jZXNzLnZlcnNpb25zLm5vZGU9PSJzdHJpbmciO2lmKEVOVklST05NRU5UX0lTX05PREUpe3ZhciBub2RlV29ya2VyVGhyZWFkcz1yZXF1aXJlKCJ3b3JrZXJfdGhyZWFkcyIpO3ZhciBwYXJlbnRQb3J0PW5vZGVXb3JrZXJUaHJlYWRzLnBhcmVudFBvcnQ7cGFyZW50UG9ydC5vbigibWVzc2FnZSIsZGF0YT0+b25tZXNzYWdlKHtkYXRhOmRhdGF9KSk7dmFyIGZzPXJlcXVpcmUoImZzIik7T2JqZWN0LmFzc2lnbihnbG9iYWwse3NlbGY6Z2xvYmFsLHJlcXVpcmU6cmVxdWlyZSxNb2R1bGU6TW9kdWxlLGxvY2F0aW9uOntocmVmOl9fZmlsZW5hbWV9LFdvcmtlcjpub2RlV29ya2VyVGhyZWFkcy5Xb3JrZXIsaW1wb3J0U2NyaXB0czpmPT4oMCxldmFsKShmcy5yZWFkRmlsZVN5bmMoZiwidXRmOCIpKyIvLyMgc291cmNlVVJMPSIrZikscG9zdE1lc3NhZ2U6bXNnPT5wYXJlbnRQb3J0LnBvc3RNZXNzYWdlKG1zZykscGVyZm9ybWFuY2U6Z2xvYmFsLnBlcmZvcm1hbmNlfHx7bm93OkRhdGUubm93fX0pfXZhciBpbml0aWFsaXplZEpTPWZhbHNlO2Z1bmN0aW9uIHRocmVhZFByaW50RXJyKCl7dmFyIHRleHQ9QXJyYXkucHJvdG90eXBlLnNsaWNlLmNhbGwoYXJndW1lbnRzKS5qb2luKCIgIik7aWYoRU5WSVJPTk1FTlRfSVNfTk9ERSl7ZnMud3JpdGVTeW5jKDIsdGV4dCsiXG4iKTtyZXR1cm59Y29uc29sZS5lcnJvcih0ZXh0KX1mdW5jdGlvbiB0aHJlYWRBbGVydCgpe3ZhciB0ZXh0PUFycmF5LnByb3RvdHlwZS5zbGljZS5jYWxsKGFyZ3VtZW50cykuam9pbigiICIpO3Bvc3RNZXNzYWdlKHtjbWQ6ImFsZXJ0Iix0ZXh0OnRleHQsdGhyZWFkSWQ6TW9kdWxlWyJfcHRocmVhZF9zZWxmIl0oKX0pfXZhciBlcnI9dGhyZWFkUHJpbnRFcnI7c2VsZi5hbGVydD10aHJlYWRBbGVydDtNb2R1bGVbImluc3RhbnRpYXRlV2FzbSJdPShpbmZvLHJlY2VpdmVJbnN0YW5jZSk9Pnt2YXIgbW9kdWxlPU1vZHVsZVsid2FzbU1vZHVsZSJdO01vZHVsZVsid2FzbU1vZHVsZSJdPW51bGw7dmFyIGluc3RhbmNlPW5ldyBXZWJBc3NlbWJseS5JbnN0YW5jZShtb2R1bGUsaW5mbyk7cmV0dXJuIHJlY2VpdmVJbnN0YW5jZShpbnN0YW5jZSl9O3NlbGYub251bmhhbmRsZWRyZWplY3Rpb249ZT0+e3Rocm93IGUucmVhc29ufHxlfTtmdW5jdGlvbiBoYW5kbGVNZXNzYWdlKGUpe3RyeXtpZihlLmRhdGEuY21kPT09ImxvYWQiKXtsZXQgbWVzc2FnZVF1ZXVlPVtdO3NlbGYub25tZXNzYWdlPWU9Pm1lc3NhZ2VRdWV1ZS5wdXNoKGUpO3NlbGYuc3RhcnRXb3JrZXI9aW5zdGFuY2U9PntNb2R1bGU9aW5zdGFuY2U7cG9zdE1lc3NhZ2UoeyJjbWQiOiJsb2FkZWQifSk7Zm9yKGxldCBtc2cgb2YgbWVzc2FnZVF1ZXVlKXtoYW5kbGVNZXNzYWdlKG1zZyl9c2VsZi5vbm1lc3NhZ2U9aGFuZGxlTWVzc2FnZX07TW9kdWxlWyJ3YXNtTW9kdWxlIl09ZS5kYXRhLndhc21Nb2R1bGU7Zm9yKGNvbnN0IGhhbmRsZXIgb2YgZS5kYXRhLmhhbmRsZXJzKXtNb2R1bGVbaGFuZGxlcl09KC4uLmFyZ3MpPT57cG9zdE1lc3NhZ2Uoe2NtZDoiY2FsbEhhbmRsZXIiLGhhbmRsZXI6aGFuZGxlcixhcmdzOmFyZ3N9KX19TW9kdWxlWyJ3YXNtTWVtb3J5Il09ZS5kYXRhLndhc21NZW1vcnk7TW9kdWxlWyJidWZmZXIiXT1Nb2R1bGVbIndhc21NZW1vcnkiXS5idWZmZXI7TW9kdWxlWyJFTlZJUk9OTUVOVF9JU19QVEhSRUFEIl09dHJ1ZTtpZih0eXBlb2YgZS5kYXRhLnVybE9yQmxvYj09InN0cmluZyIpe2ltcG9ydFNjcmlwdHMoZS5kYXRhLnVybE9yQmxvYil9ZWxzZXt2YXIgb2JqZWN0VXJsPVVSTC5jcmVhdGVPYmplY3RVUkwoZS5kYXRhLnVybE9yQmxvYik7aW1wb3J0U2NyaXB0cyhvYmplY3RVcmwpO1VSTC5yZXZva2VPYmplY3RVUkwob2JqZWN0VXJsKX1nZXRVc2RNb2R1bGUoTW9kdWxlKX1lbHNlIGlmKGUuZGF0YS5jbWQ9PT0icnVuIil7TW9kdWxlWyJfX2Vtc2NyaXB0ZW5fdGhyZWFkX2luaXQiXShlLmRhdGEucHRocmVhZF9wdHIsMCwwLDEpO01vZHVsZVsiX19lbXNjcmlwdGVuX3RocmVhZF9tYWlsYm94X2F3YWl0Il0oZS5kYXRhLnB0aHJlYWRfcHRyKTtNb2R1bGVbImVzdGFibGlzaFN0YWNrU3BhY2UiXSgpO01vZHVsZVsiUFRocmVhZCJdLnJlY2VpdmVPYmplY3RUcmFuc2ZlcihlLmRhdGEpO01vZHVsZVsiUFRocmVhZCJdLnRocmVhZEluaXRUTFMoKTtpZighaW5pdGlhbGl6ZWRKUyl7TW9kdWxlWyJfX2VtYmluZF9pbml0aWFsaXplX2JpbmRpbmdzIl0oKTtpbml0aWFsaXplZEpTPXRydWV9dHJ5e01vZHVsZVsiaW52b2tlRW50cnlQb2ludCJdKGUuZGF0YS5zdGFydF9yb3V0aW5lLGUuZGF0YS5hcmcpfWNhdGNoKGV4KXtpZihleCE9InVud2luZCIpe3Rocm93IGV4fX19ZWxzZSBpZihlLmRhdGEuY21kPT09ImNhbmNlbCIpe2lmKE1vZHVsZVsiX3B0aHJlYWRfc2VsZiJdKCkpe01vZHVsZVsiX19lbXNjcmlwdGVuX3RocmVhZF9leGl0Il0oLTEpfX1lbHNlIGlmKGUuZGF0YS50YXJnZXQ9PT0ic2V0aW1tZWRpYXRlIil7fWVsc2UgaWYoZS5kYXRhLmNtZD09PSJjaGVja01haWxib3giKXtpZihpbml0aWFsaXplZEpTKXtNb2R1bGVbImNoZWNrTWFpbGJveCJdKCl9fWVsc2UgaWYoZS5kYXRhLmNtZCl7ZXJyKGB3b3JrZXIuanMgcmVjZWl2ZWQgdW5rbm93biBjb21tYW5kICR7ZS5kYXRhLmNtZH1gKTtlcnIoZS5kYXRhKX19Y2F0Y2goZXgpe2lmKE1vZHVsZVsiX19lbXNjcmlwdGVuX3RocmVhZF9jcmFzaGVkIl0pe01vZHVsZVsiX19lbXNjcmlwdGVuX3RocmVhZF9jcmFzaGVkIl0oKX10aHJvdyBleH19c2VsZi5vbm1lc3NhZ2U9aGFuZGxlTWVzc2FnZTsK";export{Z as default};
@@ -0,0 +1,2 @@
(function(){"use strict";var a={},d=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string";if(d){var l=require("worker_threads"),c=l.parentPort;c.on("message",e=>onmessage({data:e}));var m=require("fs");Object.assign(global,{self:global,require,Module:a,location:{href:__filename},Worker:l.Worker,importScripts:e=>(0,eval)(m.readFileSync(e,"utf8")+"//# sourceURL="+e),postMessage:e=>c.postMessage(e),performance:global.performance||{now:Date.now}})}var o=!1;function u(){var e=Array.prototype.slice.call(arguments).join(" ");if(d){m.writeSync(2,e+`
`);return}console.error(e)}function _(){var e=Array.prototype.slice.call(arguments).join(" ");postMessage({cmd:"alert",text:e,threadId:a._pthread_self()})}var f=u;self.alert=_,a.instantiateWasm=(e,s)=>{var r=a.wasmModule;a.wasmModule=null;var t=new WebAssembly.Instance(r,e);return s(t)},self.onunhandledrejection=e=>{throw e.reason||e};function i(e){try{if(e.data.cmd==="load"){let r=[];self.onmessage=t=>r.push(t),self.startWorker=t=>{a=t,postMessage({cmd:"loaded"});for(let n of r)i(n);self.onmessage=i},a.wasmModule=e.data.wasmModule;for(const t of e.data.handlers)a[t]=(...n)=>{postMessage({cmd:"callHandler",handler:t,args:n})};if(a.wasmMemory=e.data.wasmMemory,a.buffer=a.wasmMemory.buffer,a.ENVIRONMENT_IS_PTHREAD=!0,typeof e.data.urlOrBlob=="string")importScripts(e.data.urlOrBlob);else{var s=URL.createObjectURL(e.data.urlOrBlob);importScripts(s),URL.revokeObjectURL(s)}getUsdModule(a)}else if(e.data.cmd==="run"){a.__emscripten_thread_init(e.data.pthread_ptr,0,0,1),a.__emscripten_thread_mailbox_await(e.data.pthread_ptr),a.establishStackSpace(),a.PThread.receiveObjectTransfer(e.data),a.PThread.threadInitTLS(),o||(a.__embind_initialize_bindings(),o=!0);try{a.invokeEntryPoint(e.data.start_routine,e.data.arg)}catch(r){if(r!="unwind")throw r}}else e.data.cmd==="cancel"?a._pthread_self()&&a.__emscripten_thread_exit(-1):e.data.target==="setimmediate"||(e.data.cmd==="checkMailbox"?o&&a.checkMailbox():e.data.cmd&&(f(`worker.js received unknown command ${e.data.cmd}`),f(e.data)))}catch(r){throw a.__emscripten_thread_crashed&&a.__emscripten_thread_crashed(),r}}self.onmessage=i})();
@@ -0,0 +1 @@
const e="/extensions/ComfyUI-Rodin/threeVisualizer/assets/emHdBindings.worker-DUEm-236.js";export{e as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1,2 @@
*.data binary
*.wasm binary
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
"use strict";var Module={};var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string";if(ENVIRONMENT_IS_NODE){var nodeWorkerThreads=require("worker_threads");var parentPort=nodeWorkerThreads.parentPort;parentPort.on("message",data=>onmessage({data:data}));var fs=require("fs");Object.assign(global,{self:global,require:require,Module:Module,location:{href:__filename},Worker:nodeWorkerThreads.Worker,importScripts:f=>(0,eval)(fs.readFileSync(f,"utf8")+"//# sourceURL="+f),postMessage:msg=>parentPort.postMessage(msg),performance:global.performance||{now:Date.now}})}var initializedJS=false;function threadPrintErr(){var text=Array.prototype.slice.call(arguments).join(" ");if(ENVIRONMENT_IS_NODE){fs.writeSync(2,text+"\n");return}console.error(text)}function threadAlert(){var text=Array.prototype.slice.call(arguments).join(" ");postMessage({cmd:"alert",text:text,threadId:Module["_pthread_self"]()})}var err=threadPrintErr;self.alert=threadAlert;Module["instantiateWasm"]=(info,receiveInstance)=>{var module=Module["wasmModule"];Module["wasmModule"]=null;var instance=new WebAssembly.Instance(module,info);return receiveInstance(instance)};self.onunhandledrejection=e=>{throw e.reason||e};function handleMessage(e){try{if(e.data.cmd==="load"){let messageQueue=[];self.onmessage=e=>messageQueue.push(e);self.startWorker=instance=>{Module=instance;postMessage({"cmd":"loaded"});for(let msg of messageQueue){handleMessage(msg)}self.onmessage=handleMessage};Module["wasmModule"]=e.data.wasmModule;for(const handler of e.data.handlers){Module[handler]=(...args)=>{postMessage({cmd:"callHandler",handler:handler,args:args})}}Module["wasmMemory"]=e.data.wasmMemory;Module["buffer"]=Module["wasmMemory"].buffer;Module["ENVIRONMENT_IS_PTHREAD"]=true;if(typeof e.data.urlOrBlob=="string"){importScripts(e.data.urlOrBlob)}else{var objectUrl=URL.createObjectURL(e.data.urlOrBlob);importScripts(objectUrl);URL.revokeObjectURL(objectUrl)}getUsdModule(Module)}else if(e.data.cmd==="run"){Module["__emscripten_thread_init"](e.data.pthread_ptr,0,0,1);Module["__emscripten_thread_mailbox_await"](e.data.pthread_ptr);Module["establishStackSpace"]();Module["PThread"].receiveObjectTransfer(e.data);Module["PThread"].threadInitTLS();if(!initializedJS){Module["__embind_initialize_bindings"]();initializedJS=true}try{Module["invokeEntryPoint"](e.data.start_routine,e.data.arg)}catch(ex){if(ex!="unwind"){throw ex}}}else if(e.data.cmd==="cancel"){if(Module["_pthread_self"]()){Module["__emscripten_thread_exit"](-1)}}else if(e.data.target==="setimmediate"){}else if(e.data.cmd==="checkMailbox"){if(initializedJS){Module["checkMailbox"]()}}else if(e.data.cmd){err(`worker.js received unknown command ${e.data.cmd}`);err(e.data)}}catch(ex){if(Module["__emscripten_thread_crashed"]){Module["__emscripten_thread_crashed"]()}throw ex}}self.onmessage=handleMessage;
+112
View File
@@ -0,0 +1,112 @@
import "./emHdBindings.js";
/**
* @type {Promise<import("..").USD> | null}
*/
let usd_module_promise = null;
/**
* @param {undefined | import("..").GetUsdModuleOptions} opts
*/
export async function getUsdModule(opts) {
if (usd_module_promise) {
return usd_module_promise;
}
/**
* @type {import("..").getUsdModule}
*/
const getUsdModuleFn = globalThis["NEEDLE:USD:GET"];
if (!getUsdModuleFn) {
throw new Error("\"NEEDLE:USD:GET\" not found in globalThis - please modify \"emHdBindings.js\" and add: globalThis[\"NEEDLE:USD:GET\"] = getUsdModule;");
}
// HACK for worker import: \"Cannot use import statement outside a module\""
// https://github.com/vitejs/vite/issues/6979
// @ts-ignore
const isProd = import.meta.env?.PROD ?? true;
/**
* We use a async import here because otherwise sveltekit vite complains about unknown file extensions (e.g. .wasm)
*/
const bindingsPromise = await Promise.all([
/** @ts-ignore */
import(`./emHdBindings.js?url`),
/** @ts-ignore */
import(`./emHdBindings.data?url`),
// https://v3.vitejs.dev/guide/features.html#web-workers
// https://github.com/vitejs/vite/issues/6979
/** @ts-ignore */
import(`./emHdBindings.worker.js?worker&url`),
/** @ts-ignore */
import(`./emHdBindings.worker.js?url`),
/** @ts-ignore */
import(`./emHdBindings.wasm?url`),
]);
const [bindings, data, workerProd, workerDev, wasm] = bindingsPromise;
const worker = isProd ? workerProd : workerDev;
const preloaded_data = await fetch(data.default).then(r => r.arrayBuffer());
return usd_module_promise = getUsdModuleFn({
mainScriptUrlOrBlob: bindings.default,// "./emHdBindings.js",
setStatus: (status) => {
console.debug("🧊 USD STATUS", status);
},
...opts,
locateFile: (file) => {
if (opts?.debug === true) console.warn("LOCATE FILE:", file)
const userResult = opts?.locateFile?.(file);
if (userResult) {
return userResult;
}
/** resolved filepath */
let res = null;
if (file.includes("emHdBindings.data")) {
res = data.default;
}
else if (file.includes("emHdBindings.wasm")) {
res = wasm.default;
}
else if (file.includes("emHdBindings.worker.js")) {
res = worker.default;
}
// if (url?.startsWith("data:text/javascript;base64")) {
// // we're client side and Buffer and atob are not available
// // so we need to convert the base64 to a blob
// const base64 = url.split(",")[1];
// const binary = atob(base64);
// const bytes = new Uint8Array(binary.length);
// for (let i = 0; i < binary.length; i++) {
// bytes[i] = binary.charCodeAt(i);
// }
// }
return res ?? file;
},
getPreloadedPackage(name, size) {
const userResult = opts?.getPreloadedPackage?.(name, size);
if (userResult) return userResult;
// For debugging if the data file isnt loaded or the size might be wrong
// Make sure to clear the vite cache. See https://linear.app/needle/issue/NE-4851#comment-2a9538e3
if (name.includes("emHdBindings.data")) {
if (preloaded_data.byteLength !== size) {
throw new Error(`emHdBindings.data size mismatch: expected ${size} but got ${preloaded_data.byteLength}\n${data.default}`);
}
}
return null;
},
});
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<script type="module" crossorigin src="/extensions/ComfyUI-Rodin/threeVisualizer/assets/index-CCfjSMW2.js"></script>
<link rel="stylesheet" crossorigin href="/extensions/ComfyUI-Rodin/threeVisualizer/assets/index-DfT-kUaP.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+302
View File
@@ -0,0 +1,302 @@
import { app } from "/scripts/app.js"
import { api } from '/scripts/api.js';
class Visualizer {
constructor(node, container, visualSrc) {
this.node = node
this.iframe = document.createElement('iframe')
Object.assign(this.iframe, {
scrolling: "no",
overflow: "hidden",
})
if (visualSrc === 'threeVisualizer') {
// this.iframe.src = "//localhost:5173/extensions/ComfyUI-Rodin/threeVisualizer"
this.iframe.src = "/extensions/ComfyUI-Rodin/" + visualSrc + "/index.html"
} else {
this.iframe.src = "/extensions/ComfyUI-Rodin/html/" + visualSrc + ".html"
}
container.appendChild(this.iframe)
}
updateVisual({
model_path, shaded_path, diffuse_path, normal_path, pbr_path
}) {
this.iframe.contentWindow.postMessage({
model_path, shaded_path, diffuse_path, normal_path, pbr_path
}, '*')
}
remove() {
this.container.remove()
}
}
function createVisualizer(node, inputName, typeName, inputData, app) {
node.name = inputName
const widget = {
type: typeName,
name: "preview3d",
callback: () => {},
draw : function(ctx, node, widgetWidth, widgetY, widgetHeight) {
const margin = 10
const top_offset = 5
const visible = app.canvas.ds.scale > 0.5 && this.type === typeName
const w = widgetWidth - margin * 4
const clientRectBound = ctx.canvas.getBoundingClientRect()
const transform = new DOMMatrix()
.scaleSelf(
clientRectBound.width / ctx.canvas.width,
clientRectBound.height / ctx.canvas.height
)
.multiplySelf(ctx.getTransform())
.translateSelf(margin, margin + widgetY)
Object.assign(this.visualizer.style, {
left: `${transform.a * margin + transform.e}px`,
top: `${transform.d + transform.f + top_offset}px`,
width: `${(w * transform.a)}px`,
height: `${(w * transform.d - widgetHeight - (margin * 10) * transform.d)}px`,
position: "absolute",
overflow: "hidden",
zIndex: app.graph._nodes.indexOf(node),
})
Object.assign(this.visualizer.children[0].style, {
transformOrigin: "50% 50%",
width: '100%',
height: '100%',
border: '0 none',
})
this.visualizer.hidden = !visible
},
}
const container = document.createElement('div')
container.id = `Comfy3D_${inputName}`
node.visualizer = new Visualizer(node, container, typeName)
widget.visualizer = container
widget.parent = node
document.body.appendChild(widget.visualizer)
node.addCustomWidget(widget)
node.updateParameters = (params) => {
node.visualizer.updateVisual(params)
}
// Events for drawing backgound
node.onDrawBackground = function (ctx) {
if (!this.flags.collapsed) {
node.visualizer.iframe.hidden = false
} else {
node.visualizer.iframe.hidden = true
}
}
// Make sure visualization iframe is always inside the node when resize the node
node.onResize = function () {
let [w, h] = this.size
if (w <= 600) w = 600
if (h <= 500) h = 500
if (w > 600) {
h = w - 100
}
this.size = [w, h]
}
// Events for remove nodes
node.onRemoved = () => {
for (let w in node.widgets) {
if (node.widgets[w].visualizer) {
node.widgets[w].visualizer.remove()
}
}
}
return {
widget: widget,
}
}
function registerVisualizer(nodeType, nodeData, nodeClassName, typeName){
if (nodeData.name == nodeClassName) {
console.log("[3D Visualizer] Registering node: " + nodeData.name)
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = async function() {
const r = onNodeCreated
? onNodeCreated.apply(this, arguments)
: undefined
let Preview3DNode = app.graph._nodes.filter(
(wi) => wi.type == nodeClassName
)
let nodeName = `Preview3DNode_${nodeClassName}`
console.log(`[Comfy3D] Create: ${nodeName}`)
const result = await createVisualizer.apply(this, [this, nodeName, typeName, {}, app])
this.setSize([600, 500])
return r
}
nodeType.prototype.onExecuted = async function(message) {
if (message?.previews) {
this.updateParameters(message.previews[0])
}
}
}
}
app.registerExtension({
name: "Mr.ForExample.Visualizer.GS",
async init (app) {
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
registerVisualizer(nodeType, nodeData, "Preview_3DMesh", "threeVisualizer")
},
getCustomWidgets() {
return {
MODELUPLOAD(node, inputName, inputData, app) {
const ModelPathWidget = node.widgets.find(
(w) => w.name === (inputData[1]?.widget ?? 'model_path')
)
let uploadWidget
var default_value = ModelPathWidget.value
Object.defineProperty(ModelPathWidget, 'value', {
set: function (value) {
this._real_value = value
},
get: function () {
if (!this._real_value) {
return default_value
}
let value = this._real_value
if (value.filename) {
let real_value = value
value = ''
if (real_value.subfolder) {
value = real_value.subfolder + '/'
}
value += real_value.filename
if (real_value.type && real_value.type !== 'input')
value += ` [${real_value.type}]`
}
return value
}
})
// @ts-expect-error
const cb = node.callback
ModelPathWidget.callback = function () {
if (cb) {
return cb.apply(this, arguments)
}
}
async function uploadFile(file, updateNode) {
try {
// Wrap file in formdata so it includes filename
const body = new FormData()
body.append('model', file)
const resp = await api.fetchApi('/upload/model', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json()
// Add the file to the dropdown list and update the widget value
let path = data.filepath
if (updateNode) {
ModelPathWidget.value = path
}
} else {
console.error((resp.status + ' - ' + resp.statusText));
}
} catch (error) {
console.error(error)
}
}
// const fileInput = document.createElement('input')
// Object.assign(fileInput, {
// type: 'file',
// accept: 'model/obj,model/stl,model/gltf-binary,model/gltf+json,model/vnd.usdz+zip',
// style: 'display: none',
// onchange: async () => {
// if (fileInput.files.length) {
// await uploadFile(fileInput.files[0], true)
// }
// }
// })
// document.body.append(fileInput)
// Create the button widget for selecting the files
uploadWidget = node.addWidget('button', inputName, 'model', () => {
fileInput.click()
})
uploadWidget.label = 'choose file to upload'
uploadWidget.serialize = false
// Add handler to check if an image is being dragged over our node
// @ts-expect-error
node.onDragOver = function (e) {
if (e.dataTransfer && e.dataTransfer.items) {
const model = [...e.dataTransfer.items].find((f) => f.kind === 'file')
return !!model
}
return false
}
// On drop upload files
// @ts-expect-error
node.onDragDrop = function (e) {
console.log('onDragDrop called')
let handled = false
for (const file of e.dataTransfer.files) {
if (file.name.endsWith('.glb')) {
uploadFile(file, !handled) // Dont await these, any order is fine, only update on first one
handled = true
}
}
return handled
}
// @ts-expect-error
node.pasteFile = function (file) {
if (file.type.startsWith('model/')) {
uploadFile(file, true)
return true
}
return false
}
return { widget: uploadWidget }
}
}
}
})
Binary file not shown.
+137
View File
@@ -0,0 +1,137 @@
import server
import folder_paths as comfy_paths
import os
import node_helpers
from ..shared_utils.log_utils import cstr
ROOT_PATH = os.path.join(comfy_paths.get_folder_paths("custom_nodes")[0], "ComfyUI-Rodin")
web = server.web
SUPPORTED_VIEW_EXTENSIONS = (
'.png',
'.jpg',
'.jpeg ',
'.mtl',
'.obj',
'.glb',
'.ply',
'.splat',
'.fbx',
'.stl',
'.usdz'
)
web_conf = None
def set_web_conf(new_web_conf):
global web_conf
web_conf = new_web_conf
def create_cors_middleware():
@web.middleware
async def cors_middleware(request: web.Request, handler):
if request.method == "OPTIONS":
# Pre-flight request. Reply successfully:
response = web.Response()
else:
response = await handler(request)
response.headers["Cross-Origin-Embedder-Policy"] = "require-corp"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
return response
return cors_middleware
@server.PromptServer.instance.routes.get("/viewfile")
async def view_file(request):
query = request.rel_url.query
# Security check to see if query client is local
if request.remote in web_conf['clients_ip'] and "filepath" in query:
filepath = query["filepath"]
cstr(f"[Server Query view_file] Get file {filepath}").msg.print()
if filepath.lower().endswith(SUPPORTED_VIEW_EXTENSIONS) and os.path.exists(filepath):
return web.FileResponse(filepath)
return web.Response(status=404)
def get_dir_by_type(dir_type):
if dir_type is None:
dir_type = "input"
if dir_type == "input":
type_dir = comfy_paths.get_input_directory()
elif dir_type == "temp":
type_dir = comfy_paths.get_temp_directory()
elif dir_type == "output":
type_dir = comfy_paths.get_output_directory()
return type_dir, dir_type
def compare_model_hash(filepath, image):
hasher = node_helpers.hasher()
# function to compare hashes of two images to see if it already exists, fix to #3465
if os.path.exists(filepath):
a = hasher()
b = hasher()
with open(filepath, "rb") as f:
a.update(f.read())
b.update(image.file.read())
image.file.seek(0)
f.close()
return a.hexdigest() == b.hexdigest()
return False
def model_upload(post):
model = post.get("model")
overwrite = post.get("overwrite")
model_is_duplicate = False
model_upload_type = post.get("type")
upload_dir, model_upload_type = get_dir_by_type(model_upload_type)
if model and model.file:
filename = model.filename
if not filename:
return web.Response(status=400)
subfolder = post.get("subfolder", "")
full_output_folder = os.path.join(upload_dir, os.path.normpath(subfolder))
filepath = os.path.abspath(os.path.join(full_output_folder, filename))
if os.path.commonpath((upload_dir, filepath)) != upload_dir:
return web.Response(status=400)
if not os.path.exists(full_output_folder):
os.makedirs(full_output_folder)
split = os.path.splitext(filename)
if overwrite is not None and (overwrite == "true" or overwrite == "1"):
pass
else:
i = 1
while os.path.exists(filepath):
if compare_model_hash(filepath, model): #compare hash to prevent saving of duplicates with same name, fix for #3465
model_is_duplicate = True
break
filename = f"{split[0]} ({i}){split[1]}"
filepath = os.path.join(full_output_folder, filename)
i += 1
if not model_is_duplicate:
with open(filepath, "wb") as f:
f.write(model.file.read())
return web.json_response({"name" : filename, "filepath": filepath})
else:
return web.Response(status=400)
@server.PromptServer.instance.routes.post("/upload/model")
async def upload_model(request):
post = await request.post()
return model_upload(post)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

File diff suppressed because it is too large Load Diff