diff --git a/comfy/supported_models.py b/comfy/supported_models.py index 8d2e02f68..1e4434fd5 100644 --- a/comfy/supported_models.py +++ b/comfy/supported_models.py @@ -1443,7 +1443,7 @@ class HiDreamO1(supported_models_base.BASE): } latent_format = latent_formats.HiDreamO1Pixel - memory_usage_factor = 0.6 + memory_usage_factor = 0.033 # fp16 not supported: LM MLP down_proj activations fp16 overflow, causing NaNs supported_inference_dtypes = [torch.bfloat16, torch.float32] diff --git a/comfy/utils.py b/comfy/utils.py index b75972027..66682690a 100644 --- a/comfy/utils.py +++ b/comfy/utils.py @@ -1164,12 +1164,18 @@ def tiled_scale_multidim(samples, function, tile=(64, 64), overlap=8, upscale_am o = out o_d = out_div + ps_view = ps + mask_view = mask for d in range(dims): - o = o.narrow(d + 2, upscaled[d], mask.shape[d + 2]) - o_d = o_d.narrow(d + 2, upscaled[d], mask.shape[d + 2]) + l = min(ps_view.shape[d + 2], o.shape[d + 2] - upscaled[d]) + o = o.narrow(d + 2, upscaled[d], l) + o_d = o_d.narrow(d + 2, upscaled[d], l) + if l < ps_view.shape[d + 2]: + ps_view = ps_view.narrow(d + 2, 0, l) + mask_view = mask_view.narrow(d + 2, 0, l) - o.add_(ps * mask) - o_d.add_(mask) + o.add_(ps_view * mask_view) + o_d.add_(mask_view) if pbar is not None: pbar.update(1) diff --git a/comfy_api/latest/_util/geometry_types.py b/comfy_api/latest/_util/geometry_types.py index b586fceb3..cdde60b10 100644 --- a/comfy_api/latest/_util/geometry_types.py +++ b/comfy_api/latest/_util/geometry_types.py @@ -12,9 +12,24 @@ class VOXEL: class MESH: - def __init__(self, vertices: torch.Tensor, faces: torch.Tensor): - self.vertices = vertices - self.faces = faces + def __init__(self, vertices: torch.Tensor, faces: torch.Tensor, + uvs: torch.Tensor | None = None, + vertex_colors: torch.Tensor | None = None, + texture: torch.Tensor | None = None, + vertex_counts: torch.Tensor | None = None, + face_counts: torch.Tensor | None = None): + + assert (vertex_counts is None) == (face_counts is None), \ + "vertex_counts and face_counts must be provided together (both or neither)" + self.vertices = vertices # vertices: (B, N, 3) + self.faces = faces # faces: (B, M, 3) + self.uvs = uvs # uvs: (B, N, 2) + self.vertex_colors = vertex_colors # vertex_colors: (B, N, 3 or 4) + self.texture = texture # texture: (B, H, W, 3) + # When vertices/faces are zero-padded to a common N/M across the batch (variable-size mesh batch), + # these hold the real per-item lengths (B,). None means rows are uniform and no slicing is needed. + self.vertex_counts = vertex_counts + self.face_counts = face_counts class File3D: diff --git a/comfy_api_nodes/apis/anthropic.py b/comfy_api_nodes/apis/anthropic.py new file mode 100644 index 000000000..6cac537ea --- /dev/null +++ b/comfy_api_nodes/apis/anthropic.py @@ -0,0 +1,75 @@ +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, Field + + +class AnthropicRole(str, Enum): + user = "user" + assistant = "assistant" + + +class AnthropicTextContent(BaseModel): + type: Literal["text"] = "text" + text: str = Field(...) + + +class AnthropicImageSourceBase64(BaseModel): + type: Literal["base64"] = "base64" + media_type: str = Field(..., description="MIME type of the image, e.g. image/png, image/jpeg") + data: str = Field(..., description="Base64-encoded image data") + + +class AnthropicImageSourceUrl(BaseModel): + type: Literal["url"] = "url" + url: str = Field(...) + + +class AnthropicImageContent(BaseModel): + type: Literal["image"] = "image" + source: AnthropicImageSourceBase64 | AnthropicImageSourceUrl = Field(...) + + +class AnthropicMessage(BaseModel): + role: AnthropicRole = Field(...) + content: list[AnthropicTextContent | AnthropicImageContent] = Field(...) + + +class AnthropicMessagesRequest(BaseModel): + model: str = Field(...) + messages: list[AnthropicMessage] = Field(...) + max_tokens: int = Field(..., ge=1) + system: str | None = Field(None, description="Top-level system prompt") + temperature: float | None = Field(None, ge=0.0, le=1.0) + top_p: float | None = Field(None, ge=0.0, le=1.0) + top_k: int | None = Field(None, ge=0) + stop_sequences: list[str] | None = Field(None) + + +class AnthropicResponseTextBlock(BaseModel): + type: Literal["text"] = "text" + text: str = Field(...) + + +class AnthropicCacheCreationUsage(BaseModel): + ephemeral_5m_input_tokens: int | None = Field(None) + ephemeral_1h_input_tokens: int | None = Field(None) + + +class AnthropicMessagesUsage(BaseModel): + input_tokens: int | None = Field(None) + output_tokens: int | None = Field(None) + cache_creation_input_tokens: int | None = Field(None) + cache_read_input_tokens: int | None = Field(None) + cache_creation: AnthropicCacheCreationUsage | None = Field(None) + + +class AnthropicMessagesResponse(BaseModel): + id: str | None = Field(None) + type: str | None = Field(None) + role: str | None = Field(None) + model: str | None = Field(None) + content: list[AnthropicResponseTextBlock] | None = Field(None) + stop_reason: str | None = Field(None) + stop_sequence: str | None = Field(None) + usage: AnthropicMessagesUsage | None = Field(None) diff --git a/comfy_api_nodes/nodes_anthropic.py b/comfy_api_nodes/nodes_anthropic.py new file mode 100644 index 000000000..60e1624f7 --- /dev/null +++ b/comfy_api_nodes/nodes_anthropic.py @@ -0,0 +1,245 @@ +"""API Nodes for Anthropic Claude (Messages API). See: https://docs.anthropic.com/en/api/messages""" + +from typing_extensions import override + +from comfy_api.latest import IO, ComfyExtension, Input +from comfy_api_nodes.apis.anthropic import ( + AnthropicImageContent, + AnthropicImageSourceUrl, + AnthropicMessage, + AnthropicMessagesRequest, + AnthropicMessagesResponse, + AnthropicRole, + AnthropicTextContent, +) +from comfy_api_nodes.util import ( + ApiEndpoint, + get_number_of_images, + sync_op, + upload_images_to_comfyapi, + validate_string, +) + +ANTHROPIC_MESSAGES_ENDPOINT = "/proxy/anthropic/v1/messages" +ANTHROPIC_IMAGE_MAX_PIXELS = 1568 * 1568 +CLAUDE_MAX_IMAGES = 20 + +CLAUDE_MODELS: dict[str, str] = { + "Opus 4.7": "claude-opus-4-7", + "Opus 4.6": "claude-opus-4-6", + "Sonnet 4.6": "claude-sonnet-4-6", + "Sonnet 4.5": "claude-sonnet-4-5-20250929", + "Haiku 4.5": "claude-haiku-4-5-20251001", +} + + +def _claude_model_inputs(): + return [ + IO.Int.Input( + "max_tokens", + default=16000, + min=32, + max=32000, + tooltip="Maximum number of tokens to generate before stopping.", + advanced=True, + ), + IO.Float.Input( + "temperature", + default=1.0, + min=0.0, + max=1.0, + step=0.01, + tooltip="Controls randomness. 0.0 is deterministic, 1.0 is most random.", + advanced=True, + ), + ] + + +def _model_price_per_million(model: str) -> tuple[float, float] | None: + """Return (input_per_1M, output_per_1M) USD for a Claude model, or None if unknown.""" + if "opus-4-7" in model or "opus-4-6" in model or "opus-4-5" in model: + return 5.0, 25.0 + if "sonnet-4" in model: + return 3.0, 15.0 + if "haiku-4-5" in model: + return 1.0, 5.0 + return None + + +def calculate_tokens_price(response: AnthropicMessagesResponse) -> float | None: + """Compute approximate USD price from response usage. Server-side billing is authoritative.""" + if not response.usage or not response.model: + return None + rates = _model_price_per_million(response.model) + if rates is None: + return None + input_rate, output_rate = rates + input_tokens = response.usage.input_tokens or 0 + output_tokens = response.usage.output_tokens or 0 + cache_read = response.usage.cache_read_input_tokens or 0 + cache_5m = 0 + cache_1h = 0 + if response.usage.cache_creation: + cache_5m = response.usage.cache_creation.ephemeral_5m_input_tokens or 0 + cache_1h = response.usage.cache_creation.ephemeral_1h_input_tokens or 0 + total = ( + input_tokens * input_rate + + output_tokens * output_rate + + cache_read * input_rate * 0.1 + + cache_5m * input_rate * 1.25 + + cache_1h * input_rate * 2.0 + ) + return total / 1_000_000.0 + + +def _get_text_from_response(response: AnthropicMessagesResponse) -> str: + if not response.content: + return "" + return "\n".join(block.text for block in response.content if block.text) + + +async def _build_image_content_blocks( + cls: type[IO.ComfyNode], + image_tensors: list[Input.Image], +) -> list[AnthropicImageContent]: + urls = await upload_images_to_comfyapi( + cls, + image_tensors, + max_images=CLAUDE_MAX_IMAGES, + total_pixels=ANTHROPIC_IMAGE_MAX_PIXELS, + wait_label="Uploading reference images", + ) + return [AnthropicImageContent(source=AnthropicImageSourceUrl(url=url)) for url in urls] + + +class ClaudeNode(IO.ComfyNode): + """Generate text responses from an Anthropic Claude model.""" + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="ClaudeNode", + display_name="Anthropic Claude", + category="api node/text/Anthropic", + essentials_category="Text Generation", + description="Generate text responses with Anthropic's Claude models. " + "Provide a text prompt and optionally one or more images for multimodal context.", + inputs=[ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Text input to the model.", + ), + IO.DynamicCombo.Input( + "model", + options=[IO.DynamicCombo.Option(label, _claude_model_inputs()) for label in CLAUDE_MODELS], + tooltip="The Claude model used to generate the response.", + ), + IO.Int.Input( + "seed", + default=0, + min=0, + max=2147483647, + control_after_generate=True, + tooltip="Seed controls whether the node should re-run; " + "results are non-deterministic regardless of seed.", + ), + IO.Autogrow.Input( + "images", + template=IO.Autogrow.TemplateNames( + IO.Image.Input("image"), + names=[f"image_{i}" for i in range(1, CLAUDE_MAX_IMAGES + 1)], + min=0, + ), + tooltip=f"Optional image(s) to use as context for the model. Up to {CLAUDE_MAX_IMAGES} images.", + ), + IO.String.Input( + "system_prompt", + multiline=True, + default="", + optional=True, + advanced=True, + tooltip="Foundational instructions that dictate the model's behavior.", + ), + ], + outputs=[IO.String.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["model"]), + expr=""" + ( + $m := widgets.model; + $contains($m, "opus") ? { + "type": "list_usd", + "usd": [0.005, 0.025], + "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } + } + : $contains($m, "sonnet") ? { + "type": "list_usd", + "usd": [0.003, 0.015], + "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } + } + : $contains($m, "haiku") ? { + "type": "list_usd", + "usd": [0.001, 0.005], + "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } + } + : {"type":"text", "text":"Token-based"} + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + prompt: str, + model: dict, + seed: int, + images: dict | None = None, + system_prompt: str = "", + ) -> IO.NodeOutput: + validate_string(prompt, strip_whitespace=True, min_length=1) + model_label = model["model"] + max_tokens = model["max_tokens"] + temperature = model["temperature"] + + image_tensors: list[Input.Image] = [t for t in (images or {}).values() if t is not None] + if sum(get_number_of_images(t) for t in image_tensors) > CLAUDE_MAX_IMAGES: + raise ValueError(f"Up to {CLAUDE_MAX_IMAGES} images are supported per request.") + + content: list[AnthropicTextContent | AnthropicImageContent] = [] + if image_tensors: + content.extend(await _build_image_content_blocks(cls, image_tensors)) + content.append(AnthropicTextContent(text=prompt)) + + response = await sync_op( + cls, + ApiEndpoint(path=ANTHROPIC_MESSAGES_ENDPOINT, method="POST"), + response_model=AnthropicMessagesResponse, + data=AnthropicMessagesRequest( + model=CLAUDE_MODELS[model_label], + max_tokens=max_tokens, + messages=[AnthropicMessage(role=AnthropicRole.user, content=content)], + system=system_prompt or None, + temperature=temperature, + ), + price_extractor=calculate_tokens_price, + ) + return IO.NodeOutput(_get_text_from_response(response) or "Empty response from Claude model.") + + +class AnthropicExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ClaudeNode] + + +async def comfy_entrypoint() -> AnthropicExtension: + return AnthropicExtension() diff --git a/comfy_extras/nodes_audio.py b/comfy_extras/nodes_audio.py index 5f514716f..fcc1c34d5 100644 --- a/comfy_extras/nodes_audio.py +++ b/comfy_extras/nodes_audio.py @@ -82,6 +82,8 @@ class VAEEncodeAudio(IO.ComfyNode): @classmethod def execute(cls, vae, audio) -> IO.NodeOutput: + if audio is None: + raise ValueError("VAEEncodeAudio: input audio is None (source video may have no audio track).") sample_rate = audio["sample_rate"] vae_sample_rate = getattr(vae, "audio_sample_rate", 44100) if vae_sample_rate != sample_rate: @@ -171,6 +173,8 @@ class SaveAudio(IO.ComfyNode): @classmethod def execute(cls, audio, filename_prefix="ComfyUI", format="flac") -> IO.NodeOutput: + if audio is None: + raise ValueError("SaveAudio: input audio is None (source video may have no audio track).") return IO.NodeOutput( ui=UI.AudioSaveHelper.get_save_audio_ui(audio, filename_prefix=filename_prefix, cls=cls, format=format) ) @@ -198,6 +202,8 @@ class SaveAudioMP3(IO.ComfyNode): @classmethod def execute(cls, audio, filename_prefix="ComfyUI", format="mp3", quality="128k") -> IO.NodeOutput: + if audio is None: + raise ValueError("SaveAudioMP3: input audio is None (source video may have no audio track).") return IO.NodeOutput( ui=UI.AudioSaveHelper.get_save_audio_ui( audio, filename_prefix=filename_prefix, cls=cls, format=format, quality=quality @@ -226,6 +232,8 @@ class SaveAudioOpus(IO.ComfyNode): @classmethod def execute(cls, audio, filename_prefix="ComfyUI", format="opus", quality="V3") -> IO.NodeOutput: + if audio is None: + raise ValueError("SaveAudioOpus: input audio is None (source video may have no audio track).") return IO.NodeOutput( ui=UI.AudioSaveHelper.get_save_audio_ui( audio, filename_prefix=filename_prefix, cls=cls, format=format, quality=quality @@ -252,6 +260,8 @@ class PreviewAudio(IO.ComfyNode): @classmethod def execute(cls, audio) -> IO.NodeOutput: + if audio is None: + raise ValueError("PreviewAudio: input audio is None (source video may have no audio track).") return IO.NodeOutput(ui=UI.PreviewAudio(audio, cls=cls)) save_flac = execute # TODO: remove @@ -297,6 +307,7 @@ class LoadAudio(IO.ComfyNode): @classmethod def define_schema(cls): input_dir = folder_paths.get_input_directory() + os.makedirs(input_dir, exist_ok=True) files = folder_paths.filter_files_content_types(os.listdir(input_dir), ["audio", "video"]) return IO.Schema( node_id="LoadAudio", @@ -391,21 +402,26 @@ class TrimAudioDuration(IO.ComfyNode): @classmethod def execute(cls, audio, start_index, duration) -> IO.NodeOutput: + if audio is None: + return IO.NodeOutput(None) waveform = audio["waveform"] sample_rate = audio["sample_rate"] audio_length = waveform.shape[-1] + if audio_length == 0: + return IO.NodeOutput(audio) + if start_index < 0: start_frame = audio_length + int(round(start_index * sample_rate)) else: start_frame = int(round(start_index * sample_rate)) - start_frame = max(0, min(start_frame, audio_length - 1)) + start_frame = max(0, min(start_frame, audio_length)) end_frame = start_frame + int(round(duration * sample_rate)) end_frame = max(0, min(end_frame, audio_length)) if start_frame >= end_frame: - raise ValueError("AudioTrim: Start time must be less than end time and be within the audio length.") + raise ValueError("TrimAudioDuration: Start time must be less than end time and be within the audio length.") return IO.NodeOutput({"waveform": waveform[..., start_frame:end_frame], "sample_rate": sample_rate}) @@ -432,11 +448,13 @@ class SplitAudioChannels(IO.ComfyNode): @classmethod def execute(cls, audio) -> IO.NodeOutput: + if audio is None: + return IO.NodeOutput(None, None) waveform = audio["waveform"] sample_rate = audio["sample_rate"] if waveform.shape[1] != 2: - raise ValueError("AudioSplit: Input audio has only one channel.") + raise ValueError(f"AudioSplit: Input audio must be stereo (2 channels), got {waveform.shape[1]} channel(s).") left_channel = waveform[..., 0:1, :] right_channel = waveform[..., 1:2, :] @@ -464,6 +482,12 @@ class JoinAudioChannels(IO.ComfyNode): @classmethod def execute(cls, audio_left, audio_right) -> IO.NodeOutput: + if audio_left is None and audio_right is None: + return IO.NodeOutput(None) + if audio_left is None: + return IO.NodeOutput(audio_right) + if audio_right is None: + return IO.NodeOutput(audio_left) waveform_left = audio_left["waveform"] sample_rate_left = audio_left["sample_rate"] waveform_right = audio_right["waveform"] @@ -537,6 +561,12 @@ class AudioConcat(IO.ComfyNode): @classmethod def execute(cls, audio1, audio2, direction) -> IO.NodeOutput: + if audio1 is None and audio2 is None: + return IO.NodeOutput(None) + if audio1 is None: + return IO.NodeOutput(audio2) + if audio2 is None: + return IO.NodeOutput(audio1) waveform_1 = audio1["waveform"] waveform_2 = audio2["waveform"] sample_rate_1 = audio1["sample_rate"] @@ -584,6 +614,12 @@ class AudioMerge(IO.ComfyNode): @classmethod def execute(cls, audio1, audio2, merge_method) -> IO.NodeOutput: + if audio1 is None and audio2 is None: + return IO.NodeOutput(None) + if audio1 is None: + return IO.NodeOutput(audio2) + if audio2 is None: + return IO.NodeOutput(audio1) waveform_1 = audio1["waveform"] waveform_2 = audio2["waveform"] sample_rate_1 = audio1["sample_rate"] @@ -594,6 +630,9 @@ class AudioMerge(IO.ComfyNode): length_1 = waveform_1.shape[-1] length_2 = waveform_2.shape[-1] + if length_1 == 0 or length_2 == 0: + return IO.NodeOutput({"waveform": waveform_1, "sample_rate": output_sample_rate}) + if length_2 > length_1: logging.info(f"AudioMerge: Trimming audio2 from {length_2} to {length_1} samples to match audio1 length.") waveform_2 = waveform_2[..., :length_1] @@ -645,6 +684,8 @@ class AudioAdjustVolume(IO.ComfyNode): @classmethod def execute(cls, audio, volume) -> IO.NodeOutput: + if audio is None: + return IO.NodeOutput(None) if volume == 0: return IO.NodeOutput(audio) waveform = audio["waveform"] @@ -728,8 +769,14 @@ class AudioEqualizer3Band(IO.ComfyNode): @classmethod def execute(cls, audio, low_gain_dB, low_freq, mid_gain_dB, mid_freq, mid_q, high_gain_dB, high_freq) -> IO.NodeOutput: + if audio is None: + return IO.NodeOutput(None) waveform = audio["waveform"] sample_rate = audio["sample_rate"] + + if waveform.shape[-1] == 0: + return IO.NodeOutput(audio) + eq_waveform = waveform.clone() # 1. Apply Low Shelf (Bass) diff --git a/comfy_extras/nodes_hunyuan3d.py b/comfy_extras/nodes_hunyuan3d.py index bf18ecb88..403eb855b 100644 --- a/comfy_extras/nodes_hunyuan3d.py +++ b/comfy_extras/nodes_hunyuan3d.py @@ -1,12 +1,7 @@ import torch -import os -import json -import struct -import numpy as np from comfy.ldm.modules.diffusionmodules.mmdit import get_1d_sincos_pos_embed_from_grid_torch -import folder_paths import comfy.model_management -from comfy.cli_args import args +from comfy_extras.nodes_save_3d import pack_variable_mesh_batch from typing_extensions import override from comfy_api.latest import ComfyExtension, IO, Types from comfy_api.latest._util import MESH, VOXEL # only for backward compatibility if someone import it from this file (will be removed later) # noqa @@ -444,7 +439,9 @@ class VoxelToMeshBasic(IO.ComfyNode): vertices.append(v) faces.append(f) - return IO.NodeOutput(Types.MESH(torch.stack(vertices), torch.stack(faces))) + if vertices and all(v.shape == vertices[0].shape for v in vertices) and all(f.shape == faces[0].shape for f in faces): + return IO.NodeOutput(Types.MESH(torch.stack(vertices), torch.stack(faces))) + return IO.NodeOutput(pack_variable_mesh_batch(vertices, faces)) decode = execute # TODO: remove @@ -481,206 +478,13 @@ class VoxelToMesh(IO.ComfyNode): vertices.append(v) faces.append(f) - return IO.NodeOutput(Types.MESH(torch.stack(vertices), torch.stack(faces))) + if vertices and all(v.shape == vertices[0].shape for v in vertices) and all(f.shape == faces[0].shape for f in faces): + return IO.NodeOutput(Types.MESH(torch.stack(vertices), torch.stack(faces))) + return IO.NodeOutput(pack_variable_mesh_batch(vertices, faces)) decode = execute # TODO: remove -def save_glb(vertices, faces, filepath, metadata=None): - """ - Save PyTorch tensor vertices and faces as a GLB file without external dependencies. - - Parameters: - vertices: torch.Tensor of shape (N, 3) - The vertex coordinates - faces: torch.Tensor of shape (M, 3) - The face indices (triangle faces) - filepath: str - Output filepath (should end with .glb) - """ - - # Convert tensors to numpy arrays - vertices_np = vertices.cpu().numpy().astype(np.float32) - faces_np = faces.cpu().numpy().astype(np.uint32) - - vertices_buffer = vertices_np.tobytes() - indices_buffer = faces_np.tobytes() - - def pad_to_4_bytes(buffer): - padding_length = (4 - (len(buffer) % 4)) % 4 - return buffer + b'\x00' * padding_length - - vertices_buffer_padded = pad_to_4_bytes(vertices_buffer) - indices_buffer_padded = pad_to_4_bytes(indices_buffer) - - buffer_data = vertices_buffer_padded + indices_buffer_padded - - vertices_byte_length = len(vertices_buffer) - vertices_byte_offset = 0 - indices_byte_length = len(indices_buffer) - indices_byte_offset = len(vertices_buffer_padded) - - gltf = { - "asset": {"version": "2.0", "generator": "ComfyUI"}, - "buffers": [ - { - "byteLength": len(buffer_data) - } - ], - "bufferViews": [ - { - "buffer": 0, - "byteOffset": vertices_byte_offset, - "byteLength": vertices_byte_length, - "target": 34962 # ARRAY_BUFFER - }, - { - "buffer": 0, - "byteOffset": indices_byte_offset, - "byteLength": indices_byte_length, - "target": 34963 # ELEMENT_ARRAY_BUFFER - } - ], - "accessors": [ - { - "bufferView": 0, - "byteOffset": 0, - "componentType": 5126, # FLOAT - "count": len(vertices_np), - "type": "VEC3", - "max": vertices_np.max(axis=0).tolist(), - "min": vertices_np.min(axis=0).tolist() - }, - { - "bufferView": 1, - "byteOffset": 0, - "componentType": 5125, # UNSIGNED_INT - "count": faces_np.size, - "type": "SCALAR" - } - ], - "meshes": [ - { - "primitives": [ - { - "attributes": { - "POSITION": 0 - }, - "indices": 1, - "mode": 4 # TRIANGLES - } - ] - } - ], - "nodes": [ - { - "mesh": 0 - } - ], - "scenes": [ - { - "nodes": [0] - } - ], - "scene": 0 - } - - if metadata is not None: - gltf["asset"]["extras"] = metadata - - # Convert the JSON to bytes - gltf_json = json.dumps(gltf).encode('utf8') - - def pad_json_to_4_bytes(buffer): - padding_length = (4 - (len(buffer) % 4)) % 4 - return buffer + b' ' * padding_length - - gltf_json_padded = pad_json_to_4_bytes(gltf_json) - - # Create the GLB header - # Magic glTF - glb_header = struct.pack('<4sII', b'glTF', 2, 12 + 8 + len(gltf_json_padded) + 8 + len(buffer_data)) - - # Create JSON chunk header (chunk type 0) - json_chunk_header = struct.pack('