Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4110e8300 |
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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**
|
||||
- 
|
||||
|
||||
This extension provides several utility nodes for Rodin generation and Controlnet use.
|
||||
|
||||
- **The Simplest way to use**
|
||||
- 
|
||||
|
||||
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**
|
||||
- 
|
||||
|
||||
- **How to use Boundbox ControlNet**
|
||||
- 
|
||||
|
||||
- **How to set Model height (in cm)**
|
||||
- 
|
||||
|
||||
## 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 nodes </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.
|
||||
- 
|
||||
|
||||
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.
|
||||
- 
|
||||
|
||||
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.
|
||||
- 
|
||||
|
||||
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.
|
||||
- 
|
||||
|
||||
</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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 328 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 55 KiB |
@@ -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, )
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "CustomScripts",
|
||||
"logging": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "CustomScripts",
|
||||
"logging": false,
|
||||
"workflows": {
|
||||
"directory": "C:\\ComfyUI-Workflows"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "CustomScripts",
|
||||
"logging": false
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
aiohttp
|
||||
asyncio
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"input": "$input/**/*.txt",
|
||||
"output": "$output/**/*.txt",
|
||||
"temp": "$temp/**/*.txt"
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1006 B |
@@ -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);
|
||||
}
|
||||
@@ -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 = " ";
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,12 @@
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -0,0 +1,2 @@
|
||||
*.data binary
|
||||
*.wasm binary
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
Before Width: | Height: | Size: 240 KiB |