Compare commits

...

10 Commits

Author SHA1 Message Date
Christian Byrne
b89c400f9c 1.13.10 (#3199) 2025-03-22 21:29:26 -07:00
Christian Byrne
2b6b8a5ecc Check glb file manually on Windows (#3198) 2025-03-22 21:09:56 -07:00
Chenlei Hu
cbb585fe25 1.13.9 (#3175) 2025-03-20 22:09:52 -04:00
Comfy Org PR Bot
9049d2a01c Update locales for node definitions (#3174)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-20 22:08:59 -04:00
Christian Byrne
e629f785b9 Add Hunyuan3d template workflow titles i18 fields (#3171)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-03-20 22:08:53 -04:00
Christian Byrne
5c7d9074b6 Load workflows from GLTF files (#3169)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-20 22:08:39 -04:00
Chenlei Hu
fc3c3c91c9 1.13.8 (#3167) 2025-03-20 13:00:41 -04:00
Terry Jia
4a409016a1 [3d] add preview 3d for saveGlb (#3156) 2025-03-20 12:57:56 -04:00
Robin Huang
dbc78b0a19 [Desktop] Add install path validation error messages (#3059)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-18 23:03:32 -04:00
Christian Byrne
07ea8712e3 Fix uploaded image not forcing re-render (#3115)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-17 20:30:53 -04:00
27 changed files with 1168 additions and 14 deletions

Binary file not shown.

View File

@@ -471,6 +471,7 @@ export class ComfyPage {
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}

View File

@@ -8,7 +8,8 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow.webm'
'workflow.webm',
'workflow.glb'
].forEach(async (fileName) => {
test(`Load workflow in ${fileName}`, async ({ comfyPage }) => {
await comfyPage.dragAndDropFile(fileName)

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

12
package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.13.7",
"version": "1.13.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.13.7",
"version": "1.13.10",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.10.7",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
@@ -460,9 +460,9 @@
}
},
"node_modules/@comfyorg/comfyui-electron-types": {
"version": "0.4.20",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.20.tgz",
"integrity": "sha512-JFKGk9wSx7CcYh9MRNo7bqTLJwQzVc+1Xg8V2Ghn9BS3RzpmkfktaWHi+waU7/CRQMzvjF+mnDPP58xk1xbVhA==",
"version": "0.4.31",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.4.31.tgz",
"integrity": "sha512-6tdUfrRyJ9mLlGhNxKqao0kdO+nKRLzQIbENmTK1EtJ1zhMmCp43a+pG7+kecjgp0pbfzxWKhTdCarS9A9fkqw==",
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.13.7",
"version": "1.13.10",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -72,7 +72,7 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.10.7",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
@@ -105,5 +105,6 @@
"vue-router": "^4.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
}
},
"packageManager": "yarn@4.5.0+sha512.837566d24eec14ec0f5f1411adb544e892b3454255e61fdef8fd05f3429480102806bac7446bc9daff3896b01ae4b62d00096c7e989f1596f2af10b927532f39"
}

View File

@@ -33,6 +33,9 @@
<Message v-if="pathExists" severity="warn">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn">
{{ $t('install.nonDefaultDrive') }}
</Message>
</div>
<!-- System Paths Info -->
@@ -80,6 +83,7 @@ const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true })
const pathExists = ref(false)
const nonDefaultDrive = ref(false)
const appData = ref('')
const appPath = ref('')
const inputTouched = ref(false)
@@ -100,6 +104,7 @@ const validatePath = async (path: string) => {
try {
pathError.value = ''
pathExists.value = false
nonDefaultDrive.value = false
const validation = await electron.validateInstallPath(path)
// Create a pre-formatted list of errors
@@ -111,12 +116,14 @@ const validatePath = async (path: string) => {
errors.push(`${t('install.insufficientFreeSpace')}: ${requiredGB} GB`)
}
if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`)
pathError.value = errors.join('\n')
}
// Display the path exists warning
if (validation.isNonDefaultDrive) nonDefaultDrive.value = true
if (validation.exists) pathExists.value = true
} catch (error) {
pathError.value = t('install.pathValidationFailed')

View File

@@ -95,6 +95,7 @@ export const useImageUploadWidget = () => {
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value)
node.graph?.setDirtyCanvas(true)
}
// On load if we have a value then render the image

View File

@@ -12,6 +12,7 @@ import './nodeTemplates'
import './noteNode'
import './rerouteNode'
import './saveImageExtraOutput'
import './saveMesh'
import './simpleTouchSupport'
import './slotDefaults'
import './uploadAudio'

View File

@@ -8,6 +8,11 @@ import { api } from '@/scripts/api'
class Load3DConfiguration {
constructor(private load3d: Load3d) {}
configureForSaveMesh(loadFolder: 'input' | 'output', filePath: string) {
this.setupModelHandlingForSaveMesh(filePath, loadFolder)
this.setupDefaultProperties()
}
configure(
loadFolder: 'input' | 'output',
modelWidget: IWidget,
@@ -34,6 +39,17 @@ class Load3DConfiguration {
}
}
private setupModelHandlingForSaveMesh(
filePath: string,
loadFolder: 'input' | 'output'
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(loadFolder)
if (filePath) {
onModelWidgetUpdate(filePath)
}
}
private setupModelHandling(
modelWidget: IWidget,
loadFolder: 'input' | 'output',

View File

@@ -0,0 +1,79 @@
import { IWidget } from '@comfyorg/litegraph'
import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { generateUUID } from '@/utils/formatUtil'
useExtensionService().registerExtension({
name: 'Comfy.SaveGLB',
async beforeRegisterNodeDef(_nodeType, nodeData) {
if ('SaveGLB' === nodeData.name) {
// @ts-expect-error InputSpec is not typed correctly
nodeData.input.required.image = ['PREVIEW_3D']
}
},
getCustomWidgets() {
return {
PREVIEW_3D(node) {
const inputSpec: CustomInputSpec = {
name: 'image',
type: 'Preview3D'
}
const widget = new ComponentWidgetImpl({
id: generateUUID(),
node,
name: inputSpec.name,
component: Load3D,
inputSpec,
options: {}
})
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'SaveGLB') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
const fileInfo = message['3d'][0]
const load3d = useLoad3dService().getLoad3d(node)
const modelWidget = node.widgets?.find((w: IWidget) => w.name === 'image')
if (load3d && modelWidget) {
const filePath = fileInfo['subfolder'] + '/' + fileInfo['filename']
console.log(filePath)
modelWidget.value = filePath
const config = new Load3DConfiguration(load3d)
config.configureForSaveMesh(fileInfo['type'], filePath)
}
}
}
})

View File

@@ -258,9 +258,11 @@
"pathExists": "Directory already exists - please ensure you have backed up all data",
"cannotWrite": "Unable to write to the selected path",
"insufficientFreeSpace": "Insufficient space - minimum free space",
"isOneDrive": "OneDrive is not supported. Please install ComfyUI in another location.",
"nonDefaultDrive": "Please install ComfyUI on your system drive (eg. C:\\). Drives with different file systems may cause unpredicable issues. Models and other files can be stored on other drives after installation.",
"parentMissing": "Path does not exist - create the containing directory first",
"unhandledError": "Unknown error",
"installLocationDescription": "Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~15GB) left.",
"installLocationDescription": "Select the directory for ComfyUI's user data. A python environment will be installed to the selected location.",
"installLocationTooltip": "ComfyUI's user data directory. Stores:\n- Python Environment\n- Models\n- Custom nodes\n",
"appDataLocationTooltip": "ComfyUI's app data directory. Stores:\n- Logs\n- Server configs",
"appPathLocationTooltip": "ComfyUI's app asset directory. Stores the ComfyUI code and assets",
@@ -506,7 +508,10 @@
"area_composition_square_area_for_subject": "Area Composition Square Area for Subject"
},
"3D": {
"stable_zero123_example": "Stable Zero123"
"stable_zero123_example": "Stable Zero123",
"hunyuan3d-non-multiview-train": "Hunyuan3D 2.0",
"hunyuan-3d-multiview-elf": "Hunyuan3D 2.0 MV",
"hunyuan-3d-turbo": "Hunyuan3D 2.0 MV Turbo"
},
"Audio": {
"stable_audio_example": "Stable Audio"
@@ -846,6 +851,7 @@
"latent": "latent",
"video": "video",
"audio": "audio",
"3d": "3d",
"ltxv": "ltxv",
"sd3": "sd3",
"sigmas": "sigmas",
@@ -861,7 +867,6 @@
"compositing": "compositing",
"samplers": "samplers",
"operations": "operations",
"3d": "3d",
"debug": "debug",
"model": "model",
"model_specific": "model_specific",
@@ -898,6 +903,7 @@
"LOAD_3D": "LOAD_3D",
"LOAD_3D_ANIMATION": "LOAD_3D_ANIMATION",
"MASK": "MASK",
"MESH": "MESH",
"MODEL": "MODEL",
"NOISE": "NOISE",
"PHOTOMAKER": "PHOTOMAKER",
@@ -908,6 +914,7 @@
"TIMESTEPS_RANGE": "TIMESTEPS_RANGE",
"UPSCALE_MODEL": "UPSCALE_MODEL",
"VAE": "VAE",
"VOXEL": "VOXEL",
"WEBCAM": "WEBCAM"
},
"maintenance": {

View File

@@ -1234,6 +1234,18 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"resolution": {
"name": "resolution"
},
"batch_size": {
"name": "batch_size",
"tooltip": "The number of latent images in the batch."
}
}
},
"EmptyLatentImage": {
"display_name": "Empty Latent Image",
"description": "Create a new batch of empty latent images to be denoised via sampling.",
@@ -1472,6 +1484,47 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "positive"
},
"1": {
"name": "negative"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"front": {
"name": "front"
},
"left": {
"name": "left"
},
"back": {
"name": "back"
},
"right": {
"name": "right"
}
},
"outputs": {
"0": {
"name": "positive"
},
"1": {
"name": "negative"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2364,6 +2417,9 @@
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
},
"upload": {
"name": "choose file to upload"
}
@@ -4609,6 +4665,9 @@
"inputs": {
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
}
}
},
@@ -4966,6 +5025,23 @@
},
"filename_prefix": {
"name": "filename_prefix"
},
"audioUI": {
"name": "audioUI"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"mesh": {
"name": "mesh"
},
"filename_prefix": {
"name": "filename_prefix"
},
"image": {
"name": "image"
}
}
},
@@ -5725,6 +5801,23 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"samples": {
"name": "samples"
},
"vae": {
"name": "vae"
},
"num_chunks": {
"name": "num_chunks"
},
"octree_resolution": {
"name": "octree_resolution"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAE Decode (Tiled)",
"inputs": {
@@ -5855,6 +5948,17 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"voxel": {
"name": "voxel"
},
"threshold": {
"name": "threshold"
}
}
},
"VPScheduler": {
"display_name": "VPScheduler",
"inputs": {

View File

@@ -74,6 +74,7 @@
"LOAD_3D": "CHARGER_3D",
"LOAD_3D_ANIMATION": "CHARGER_ANIMATION_3D",
"MASK": "MASQUE",
"MESH": "MAILLAGE",
"MODEL": "MODÈLE",
"NOISE": "BRUIT",
"PHOTOMAKER": "PHOTOMAKER",
@@ -84,6 +85,7 @@
"TIMESTEPS_RANGE": "PLAGE_DES_ÉTAPES_TEMPORELLES",
"UPSCALE_MODEL": "MODÈLE_DE_MISE_À_L'ÉCHELLE",
"VAE": "VAE",
"VOXEL": "VOXEL",
"WEBCAM": "WEBCAM"
},
"desktopMenu": {
@@ -266,6 +268,7 @@
"installLocationDescription": "Sélectionnez le répertoire pour les données utilisateur de ComfyUI. Un environnement python sera installé à l'emplacement sélectionné. Veuillez vous assurer que le disque sélectionné a suffisamment d'espace (~15GB) restant.",
"installLocationTooltip": "Répertoire des données utilisateur de ComfyUI. Stocke :\n- Environnement Python\n- Modèles\n- Nœuds personnalisés\n",
"insufficientFreeSpace": "Espace insuffisant - espace libre minimum",
"isOneDrive": "L'installation dans OneDrive peut causer des problèmes. Nous recommandons fortement d'installer dans un emplacement non-OneDrive.",
"manualConfiguration": {
"createVenv": "Vous devrez créer un environnement virtuel dans le répertoire suivant",
"requirements": "Exigences",
@@ -280,6 +283,7 @@
"migrationOptional": "La migration est facultative. Si vous n'avez pas d'installation existante, vous pouvez sauter cette étape.",
"migrationSourcePathDescription": "Si vous avez une installation existante de ComfyUI, nous pouvons copier/lier vos fichiers utilisateur et modèles existants à la nouvelle installation. Votre installation existante de ComfyUI ne sera pas affectée.",
"moreInfo": "Pour plus d'informations, veuillez lire notre",
"nonDefaultDrive": "Veuillez installer ComfyUI sur le disque système de votre ordinateur (par exemple C:\\). Les disques avec des systèmes de fichiers différents peuvent causer des problèmes imprévisibles. Les modèles et autres fichiers peuvent être stockés sur d'autres disques après l'installation.",
"parentMissing": "Le chemin n'existe pas - créez d'abord le répertoire contenant",
"pathExists": "Le répertoire existe déjà - veuillez vous assurer que vous avez sauvegardé toutes les données",
"pathValidationFailed": "Échec de la validation du chemin",
@@ -880,6 +884,9 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D Multivue",
"hunyuan-3d-turbo": "Hunyuan3D Turbo",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "Stable Zero123"
},
"Area Composition": {

View File

@@ -1251,6 +1251,18 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "taille_du_lot",
"tooltip": "Le nombre d'images latentes dans le lot."
},
"resolution": {
"name": "résolution"
}
}
},
"EmptyLatentImage": {
"description": "Créez un nouveau lot d'images latentes vides à débruiter via l'échantillonnage.",
"display_name": "Image Latente Vide",
@@ -1472,6 +1484,47 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "sortie_vision_clip"
}
},
"outputs": {
"0": {
"name": "positif"
},
"1": {
"name": "négatif"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "arrière"
},
"front": {
"name": "avant"
},
"left": {
"name": "gauche"
},
"right": {
"name": "droite"
}
},
"outputs": {
"0": {
"name": "positif"
},
"1": {
"name": "négatif"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2536,6 +2589,9 @@
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
},
"upload": {
"name": "choisissez le fichier à télécharger"
}
@@ -4614,6 +4670,9 @@
"inputs": {
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
}
}
},
@@ -5094,11 +5153,28 @@
"audio": {
"name": "audio"
},
"audioUI": {
"name": "audioUI"
},
"filename_prefix": {
"name": "préfixe_du_nom_de_fichier"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "préfixe_du_nom_de_fichier"
},
"image": {
"name": "image"
},
"mesh": {
"name": "maillage"
}
}
},
"SaveImage": {
"description": "Enregistre les images d'entrée dans votre répertoire de sortie ComfyUI.",
"display_name": "Enregistrer Image",
@@ -5705,6 +5781,23 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": "nombre_de_morceaux"
},
"octree_resolution": {
"name": "résolution_octree"
},
"samples": {
"name": "échantillons"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAE Decode (Tiled)",
"inputs": {
@@ -5852,6 +5945,17 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "seuil"
},
"voxel": {
"name": "voxel"
}
}
},
"WanImageToVideo": {
"display_name": "WanImageVersVidéo",
"inputs": {

View File

@@ -74,6 +74,7 @@
"LOAD_3D": "3Dをロード",
"LOAD_3D_ANIMATION": "3Dアニメーションをロード",
"MASK": "マスク",
"MESH": "メッシュ",
"MODEL": "モデル",
"NOISE": "ノイズ",
"PHOTOMAKER": "PHOTOMAKER",
@@ -84,6 +85,7 @@
"TIMESTEPS_RANGE": "タイムステップの範囲",
"UPSCALE_MODEL": "アップスケールモデル",
"VAE": "VAE",
"VOXEL": "ボクセル",
"WEBCAM": "ウェブカメラ"
},
"desktopMenu": {
@@ -266,6 +268,7 @@
"installLocationDescription": "ComfyUIのユーザーデータを保存するディレクトリを選択してください。Python環境が選択した場所にインストールされます。選択したディスクに約15GBの空き容量が必要です。",
"installLocationTooltip": "ComfyUIのユーザーデータディレクトリ。保存内容:\n- Python環境\n- モデル\n- カスタムノード\n",
"insufficientFreeSpace": "空き容量が不足しています - 最低限の空き容量",
"isOneDrive": "OneDriveにインストールすると問題が発生する可能性があります。非OneDriveの場所にインストールすることを強くお勧めします。",
"manualConfiguration": {
"createVenv": "次のディレクトリに仮想環境を作成する必要があります",
"requirements": "要件",
@@ -280,6 +283,7 @@
"migrationOptional": "移行は任意です。既存のインストールがない場合、このステップをスキップできます。",
"migrationSourcePathDescription": "既存のComfyUIインストールがある場合、既存のユーザーファイルとモデルを新しいインストールにコピー/リンクすることができます。既存のComfyUIインストールは影響を受けません。",
"moreInfo": "詳しくはこちらをご覧ください",
"nonDefaultDrive": "ComfyUIをシステムドライブC:\\)にインストールしてください。異なるファイルシステムを持つドライブでは、予測不能な問題が発生する可能性があります。インストール後にモデルやその他のファイルを他のドライブに保存することができます。",
"parentMissing": "パスが存在しません - 最初に含まれるディレクトリを作成してください",
"pathExists": "ディレクトリはすでに存在します - すべてのデータをバックアップしたことを確認してください",
"pathValidationFailed": "パスの検証に失敗しました",
@@ -880,6 +884,9 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D マルチビュー",
"hunyuan-3d-turbo": "Hunyuan3D ターボ",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "Stable Zero123"
},
"Area Composition": {

View File

@@ -1251,6 +1251,18 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "バッチサイズ",
"tooltip": "バッチ内の潜在画像の数。"
},
"resolution": {
"name": "解像度"
}
}
},
"EmptyLatentImage": {
"description": "サンプリングを通じてノイズを除去するための空の潜在画像の新しいバッチを作成します。",
"display_name": "空の潜在画像",
@@ -1472,6 +1484,47 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "ポジティブ"
},
"1": {
"name": "ネガティブ"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "バック"
},
"front": {
"name": "フロント"
},
"left": {
"name": "左"
},
"right": {
"name": "右"
}
},
"outputs": {
"0": {
"name": "ポジティブ"
},
"1": {
"name": "ネガティブ"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2536,6 +2589,9 @@
"audio": {
"name": "オーディオ"
},
"audioUI": {
"name": "audioUI"
},
"upload": {
"name": "アップロードするファイルを選択"
}
@@ -4614,6 +4670,9 @@
"inputs": {
"audio": {
"name": "オーディオ"
},
"audioUI": {
"name": "audioUI"
}
}
},
@@ -5094,11 +5153,28 @@
"audio": {
"name": "オーディオ"
},
"audioUI": {
"name": "audioUI"
},
"filename_prefix": {
"name": "ファイル名_プレフィックス"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "ファイル名のプレフィックス"
},
"image": {
"name": "画像"
},
"mesh": {
"name": "メッシュ"
}
}
},
"SaveImage": {
"description": "入力画像をComfyUI出力ディレクトリに保存します。",
"display_name": "画像を保存",
@@ -5705,6 +5781,23 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": "num_chunks"
},
"octree_resolution": {
"name": "octree_resolution"
},
"samples": {
"name": "サンプル"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAEデコードタイル",
"inputs": {
@@ -5852,6 +5945,17 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "閾値"
},
"voxel": {
"name": "ボクセル"
}
}
},
"WanImageToVideo": {
"display_name": "Wan画像からビデオへ",
"inputs": {

View File

@@ -74,6 +74,7 @@
"LOAD_3D": "3D 로드",
"LOAD_3D_ANIMATION": "3D 애니메이션 로드",
"MASK": "마스크",
"MESH": "메시",
"MODEL": "모델",
"NOISE": "노이즈",
"PHOTOMAKER": "PHOTOMAKER",
@@ -84,6 +85,7 @@
"TIMESTEPS_RANGE": "타임스텝 범위",
"UPSCALE_MODEL": "업스케일 모델",
"VAE": "VAE",
"VOXEL": "복셀",
"WEBCAM": "웹캠"
},
"desktopMenu": {
@@ -266,6 +268,7 @@
"installLocationDescription": "ComfyUI의 사용자 데이터 디렉토리를 선택하십시오. 선택한 위치에 Python 환경이 설치됩니다. 선택한 디스크에 충분한 공간(~15GB)이 남아 있는지 확인하십시오.",
"installLocationTooltip": "ComfyUI의 사용자 데이터 디렉토리. 저장소:\n- Python 환경\n- 모델\n- 사용자 정의 노드\n",
"insufficientFreeSpace": "공간이 부족합니다 - 최소한의 여유 공간",
"isOneDrive": "OneDrive에 설치하면 문제가 발생할 수 있습니다. OneDrive가 아닌 위치에 설치하는 것을 강력히 권장합니다.",
"manualConfiguration": {
"createVenv": "다음 디렉토리에 가상 환경을 생성해야 합니다",
"requirements": "요구 사항",
@@ -280,6 +283,7 @@
"migrationOptional": "마이그레이션은 선택 사항입니다. 기존에 설치된 것이 없다면, 이 단계를 건너뛸 수 있습니다.",
"migrationSourcePathDescription": "기존에 설치된 ComfyUI가 있으면, 기존 사용자 파일과 모델을 새 설치본으로 복사하거나 링크 할 수 있습니다. 기존의 ComfyUI 설치는 영향을 받지 않습니다.",
"moreInfo": "더 많은 정보를 원하시면, 다음을 읽어주세요",
"nonDefaultDrive": "ComfyUI를 시스템 드라이브(예: C:\\)에 설치하십시오. 다른 파일 시스템을 가진 드라이브는 예측할 수 없는 문제를 일으킬 수 있습니다. 설치 후에는 모델 및 기타 파일을 다른 드라이브에 저장할 수 있습니다.",
"parentMissing": "경로가 존재하지 않습니다 - 먼저 포함하는 디렉토리를 생성하세요",
"pathExists": "디렉토리가 이미 존재합니다 - 모든 데이터를 백업했는지 확인해 주세요",
"pathValidationFailed": "경로 유효성 검사 실패",
@@ -880,6 +884,9 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D 다중뷰",
"hunyuan-3d-turbo": "Hunyuan3D 터보",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "스테이블 제로123"
},
"Area Composition": {

View File

@@ -1251,6 +1251,18 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "배치 크기",
"tooltip": "배치에 있는 잠재 이미지의 수입니다."
},
"resolution": {
"name": "해상도"
}
}
},
"EmptyLatentImage": {
"description": "샘플링을 통해 디노이즈할 빈 잠재 이미지의 새 배치를 생성합니다.",
"display_name": "빈 잠재 이미지",
@@ -1472,6 +1484,47 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "clip_vision_output"
}
},
"outputs": {
"0": {
"name": "긍정적"
},
"1": {
"name": "부정적"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "뒤"
},
"front": {
"name": "앞"
},
"left": {
"name": "왼쪽"
},
"right": {
"name": "오른쪽"
}
},
"outputs": {
"0": {
"name": "긍정적"
},
"1": {
"name": "부정적"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2536,6 +2589,9 @@
"audio": {
"name": "오디오"
},
"audioUI": {
"name": "오디오UI"
},
"upload": {
"name": "업로드할 파일 선택"
}
@@ -4614,6 +4670,9 @@
"inputs": {
"audio": {
"name": "오디오"
},
"audioUI": {
"name": "오디오UI"
}
}
},
@@ -5094,11 +5153,28 @@
"audio": {
"name": "오디오"
},
"audioUI": {
"name": "오디오UI"
},
"filename_prefix": {
"name": "파일명 접두사"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "파일명 접두사"
},
"image": {
"name": "이미지"
},
"mesh": {
"name": "메시"
}
}
},
"SaveImage": {
"description": "입력 이미지를 ComfyUI 출력 디렉토리에 저장합니다.",
"display_name": "이미지 저장",
@@ -5705,6 +5781,23 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": "num_chunks"
},
"octree_resolution": {
"name": "옥트리 해상도"
},
"samples": {
"name": "샘플"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAE 디코드 (타일)",
"inputs": {
@@ -5852,6 +5945,17 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "임계값"
},
"voxel": {
"name": "복셀"
}
}
},
"WanImageToVideo": {
"display_name": "Wan 이미지를 비디오로",
"inputs": {

View File

@@ -74,6 +74,7 @@
"LOAD_3D": "ЗАГРУЗИТЬ_3D",
"LOAD_3D_ANIMATION": "ЗАГРУЗИТЬ_3D_АНИМАЦИЮ",
"MASK": "МАСКА",
"MESH": "СЕТКА",
"MODEL": "МОДЕЛЬ",
"NOISE": "ШУМ",
"PHOTOMAKER": "PHOTOMAKER",
@@ -84,6 +85,7 @@
"TIMESTEPS_RANGE": "ДИАПАЗОН_ВРЕМЕННЫХАГОВ",
"UPSCALE_MODEL": "МОДЕЛЬ_АПСКЕЙЛА",
"VAE": "VAE",
"VOXEL": "ВОКСЕЛ",
"WEBCAM": "ВЕБ-КАМЕРА"
},
"desktopMenu": {
@@ -266,6 +268,7 @@
"installLocationDescription": "Выберите директорию для пользовательских данных ComfyUI. В выбранном месте будет установлена среда Python. Пожалуйста, убедитесь, что на выбранном диске достаточно места (~15 ГБ).",
"installLocationTooltip": "Директория пользовательских данных ComfyUI. Хранит:\n- Среда Python\n- Модели\n- Пользовательские ноды\n",
"insufficientFreeSpace": "Недостаточно места — минимально необходимое свободное место",
"isOneDrive": "Установка в OneDrive может вызвать проблемы. Настоятельно рекомендуем устанавливать в месте, не связанном с OneDrive.",
"manualConfiguration": {
"createVenv": "Вам потребуется создать виртуальное окружение в следующем каталоге",
"requirements": "Требования",
@@ -280,6 +283,7 @@
"migrationOptional": "Миграция является необязательной. Если у вас нет существующей установки, вы можете пропустить этот шаг.",
"migrationSourcePathDescription": "Если у вас уже есть установленный ComfyUI, мы можем скопировать/связать ваши существующие пользовательские файлы и модели с новой установкой. Ваша существующая установка ComfyUI не будет затронута.",
"moreInfo": "Для получения дополнительной информации, пожалуйста, прочтите нашу",
"nonDefaultDrive": "Пожалуйста, установите ComfyUI на системный диск (например, C:\\). Диски с другими файловыми системами могут вызвать непредсказуемые проблемы. Модели и другие файлы можно хранить на других дисках после установки.",
"parentMissing": "Путь не существует — сначала создайте родительский каталог",
"pathExists": "Директория уже существует — пожалуйста, убедитесь, что вы сделали резервное копирование всех данных",
"pathValidationFailed": "Не удалось проверить путь",
@@ -880,6 +884,9 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D Многовидовой",
"hunyuan-3d-turbo": "Hunyuan3D Турбо",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "Stable Zero123"
},
"Area Composition": {

View File

@@ -1251,6 +1251,18 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "размер_пакета",
"tooltip": "Количество скрытых изображений в пакете."
},
"resolution": {
"name": "разрешение"
}
}
},
"EmptyLatentImage": {
"description": "Создаёт новую партию пустых латентных изображений для удаления шума через выборку.",
"display_name": "Пустое латентное изображение",
@@ -1472,6 +1484,47 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "выход_clip_vision"
}
},
"outputs": {
"0": {
"name": "положительный"
},
"1": {
"name": "отрицательный"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "сзади"
},
"front": {
"name": "фронт"
},
"left": {
"name": "слева"
},
"right": {
"name": "справа"
}
},
"outputs": {
"0": {
"name": "положительный"
},
"1": {
"name": "отрицательный"
}
}
},
"HunyuanImageToVideo": {
"display_name": "HunyuanImageToVideo",
"inputs": {
@@ -2536,6 +2589,9 @@
"audio": {
"name": "аудио"
},
"audioUI": {
"name": "audioUI"
},
"upload": {
"name": "выберите файл для загрузки"
}
@@ -4614,6 +4670,9 @@
"inputs": {
"audio": {
"name": "аудио"
},
"audioUI": {
"name": "audioUI"
}
}
},
@@ -5094,11 +5153,28 @@
"audio": {
"name": "аудио"
},
"audioUI": {
"name": "audioUI"
},
"filename_prefix": {
"name": "префиксазвания_файла"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "префикс_имени_файла"
},
"image": {
"name": "изображение"
},
"mesh": {
"name": "сетка"
}
}
},
"SaveImage": {
"description": "Сохраняет входные изображения в вашу директорию вывода ComfyUI.",
"display_name": "Сохранить изображение",
@@ -5705,6 +5781,23 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": оличествоастей"
},
"octree_resolution": {
"name": "разрешение_октодерева"
},
"samples": {
"name": "образцы"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "Декодировать VAE (плитками)",
"inputs": {
@@ -5852,6 +5945,17 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "порог"
},
"voxel": {
"name": "воксель"
}
}
},
"WanImageToVideo": {
"display_name": "WanИзображениеВВидео",
"inputs": {

View File

@@ -74,6 +74,7 @@
"LOAD_3D": "加载3D",
"LOAD_3D_ANIMATION": "加载3D动画",
"MASK": "遮罩",
"MESH": "网格",
"MODEL": "模型",
"NOISE": "噪波",
"PHOTOMAKER": "PhotoMaker",
@@ -84,6 +85,7 @@
"TIMESTEPS_RANGE": "时间间隔范围",
"UPSCALE_MODEL": "放大模型",
"VAE": "VAE",
"VOXEL": "体素",
"WEBCAM": "摄像头"
},
"desktopMenu": {
@@ -266,6 +268,7 @@
"installLocationDescription": "选择 ComfyUI 用户数据的存放目录。将安装一个 Python 环境到所选位置。请确保所选磁盘有足够的空间(约 15GB。",
"installLocationTooltip": "ComfyUI 的用户数据目录。存储:\n- Python 环境\n- 模型\n- 自定义节点\n",
"insufficientFreeSpace": "空间不足 - 最小可用空间",
"isOneDrive": "在OneDrive中安装可能会导致问题。强烈建议在非OneDrive位置安装。",
"manualConfiguration": {
"createVenv": "您需要在以下目录中创建虚拟环境",
"requirements": "依赖项",
@@ -280,6 +283,7 @@
"migrationOptional": "迁移是可选的。如果您之前没有安装过 ComfyUI可以跳过此步骤。",
"migrationSourcePathDescription": "如果您已有现有的ComfyUI安装我们可以复制/链接您现有的用户文件和模型到新的安装。您现有的ComfyUI安装将不会受到影响。",
"moreInfo": "有关更多信息,请阅读我们的",
"nonDefaultDrive": "请在您的系统驱动器上安装ComfyUI例如C:\\)。具有不同文件系统的驱动器可能会导致不可预测的问题。安装后,模型和其他文件可以存储在其他驱动器上。",
"parentMissing": "路径不存在 - 请先创建包含该路径的目录",
"pathExists": "目录已存在 - 请确保您已备份全部数据",
"pathValidationFailed": "路径验证失败",
@@ -880,6 +884,9 @@
},
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D多视图",
"hunyuan-3d-turbo": "Hunyuan3D Turbo",
"hunyuan3d-non-multiview-train": "Hunyuan3D",
"stable_zero123_example": "稳定Zero123"
},
"Area Composition": {

View File

@@ -1251,6 +1251,18 @@
}
}
},
"EmptyLatentHunyuan3Dv2": {
"display_name": "EmptyLatentHunyuan3Dv2",
"inputs": {
"batch_size": {
"name": "批量大小",
"tooltip": "批量中的潜在图像数量。"
},
"resolution": {
"name": "分辨率"
}
}
},
"EmptyLatentImage": {
"description": "创建一批新的空Latent图像以通过采样进行降噪。",
"display_name": "空Latent图像",
@@ -1472,6 +1484,47 @@
}
}
},
"Hunyuan3Dv2Conditioning": {
"display_name": "Hunyuan3Dv2Conditioning",
"inputs": {
"clip_vision_output": {
"name": "clip视觉输出"
}
},
"outputs": {
"0": {
"name": "正向"
},
"1": {
"name": "反向"
}
}
},
"Hunyuan3Dv2ConditioningMultiView": {
"display_name": "Hunyuan3Dv2ConditioningMultiView",
"inputs": {
"back": {
"name": "后"
},
"front": {
"name": "前"
},
"left": {
"name": "左"
},
"right": {
"name": "右"
}
},
"outputs": {
"0": {
"name": "正向"
},
"1": {
"name": "反向"
}
}
},
"HunyuanImageToVideo": {
"display_name": "Hunyuan图像到视频",
"inputs": {
@@ -2536,6 +2589,9 @@
"audio": {
"name": "音频"
},
"audioUI": {
"name": "音频UI"
},
"upload": {
"name": "选择文件上传"
}
@@ -4614,6 +4670,9 @@
"inputs": {
"audio": {
"name": "音频"
},
"audioUI": {
"name": "音频UI"
}
}
},
@@ -5094,11 +5153,28 @@
"audio": {
"name": "音频"
},
"audioUI": {
"name": "音频UI"
},
"filename_prefix": {
"name": "文件名前缀"
}
}
},
"SaveGLB": {
"display_name": "SaveGLB",
"inputs": {
"filename_prefix": {
"name": "文件名前缀"
},
"image": {
"name": "图像"
},
"mesh": {
"name": "网格"
}
}
},
"SaveImage": {
"description": "将输入图像保存到您的ComfyUI输出目录。",
"display_name": "保存图像",
@@ -5705,6 +5781,23 @@
}
}
},
"VAEDecodeHunyuan3D": {
"display_name": "VAEDecodeHunyuan3D",
"inputs": {
"num_chunks": {
"name": "块数"
},
"octree_resolution": {
"name": "八叉树分辨率"
},
"samples": {
"name": "样本"
},
"vae": {
"name": "vae"
}
}
},
"VAEDecodeTiled": {
"display_name": "VAE解码分块",
"inputs": {
@@ -5852,6 +5945,17 @@
}
}
},
"VoxelToMeshBasic": {
"display_name": "VoxelToMeshBasic",
"inputs": {
"threshold": {
"name": "阈值"
},
"voxel": {
"name": "体素"
}
}
},
"WanImageToVideo": {
"display_name": "Wan图像到视频",
"inputs": {

View File

@@ -23,6 +23,7 @@ import {
import { type ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
@@ -1391,6 +1392,18 @@ export class ComfyApp {
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'model/gltf-binary' ||
file.name?.endsWith('.glb')
) {
const gltfInfo = await getGltfBinaryMetadata(file)
if (gltfInfo.workflow) {
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
} else if (gltfInfo.prompt) {
this.loadApiJson(gltfInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'application/json' ||
file.name?.endsWith('.json')

View File

@@ -0,0 +1,170 @@
import {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/schemas/comfyWorkflowSchema'
import {
ASCII,
ComfyMetadata,
ComfyMetadataTags,
GltfChunkHeader,
GltfHeader,
GltfJsonData,
GltfSizeBytes
} from '@/types/metadataTypes'
const MAX_READ_BYTES = 1 << 20
const isJsonChunk = (chunk: GltfChunkHeader | null): boolean =>
!!chunk && chunk.chunkTypeIdentifier === ASCII.JSON
const isValidChunkRange = (
start: number,
length: number,
bufferSize: number
): boolean => start + length <= bufferSize
const byteArrayToString = (bytes: Uint8Array): string =>
new TextDecoder().decode(bytes)
const parseGltfBinaryHeader = (dataView: DataView): GltfHeader | null => {
if (dataView.byteLength < GltfSizeBytes.HEADER) return null
const magicNumber = dataView.getUint32(0, true)
if (magicNumber !== ASCII.GLTF) return null
return {
magicNumber,
gltfFormatVersion: dataView.getUint32(4, true),
totalLengthBytes: dataView.getUint32(8, true)
}
}
const parseChunkHeaderAtOffset = (
dataView: DataView,
offset: number
): GltfChunkHeader | null => {
if (offset + GltfSizeBytes.CHUNK_HEADER > dataView.byteLength) return null
return {
chunkLengthBytes: dataView.getUint32(offset, true),
chunkTypeIdentifier: dataView.getUint32(offset + 4, true)
}
}
const extractJsonChunk = (
buffer: ArrayBuffer
): { start: number; length: number } | null => {
const dataView = new DataView(buffer)
const header = parseGltfBinaryHeader(dataView)
if (!header) return null
const chunkOffset = GltfSizeBytes.HEADER
const firstChunk = parseChunkHeaderAtOffset(dataView, chunkOffset)
if (!firstChunk || !isJsonChunk(firstChunk)) return null
const jsonStart = chunkOffset + GltfSizeBytes.CHUNK_HEADER
const isValid = isValidChunkRange(
jsonStart,
firstChunk.chunkLengthBytes,
dataView.byteLength
)
if (!isValid) return null
return { start: jsonStart, length: firstChunk.chunkLengthBytes }
}
const extractJsonChunkData = (buffer: ArrayBuffer): Uint8Array | null => {
const chunkLocation = extractJsonChunk(buffer)
if (!chunkLocation) return null
return new Uint8Array(buffer, chunkLocation.start, chunkLocation.length)
}
const parseJson = (text: string): ReturnType<typeof JSON.parse> | null => {
try {
return JSON.parse(text)
} catch {
return null
}
}
const parseJsonBytes = (
bytes: Uint8Array
): ReturnType<typeof JSON.parse> | null => {
const jsonString = byteArrayToString(bytes)
return parseJson(jsonString)
}
const parseMetadataValue = (
value: string | object
): ComfyWorkflowJSON | ComfyApiWorkflow | undefined => {
if (typeof value !== 'string')
return value as ComfyWorkflowJSON | ComfyApiWorkflow
const parsed = parseJson(value)
if (!parsed) return undefined
return parsed as ComfyWorkflowJSON | ComfyApiWorkflow
}
const extractComfyMetadata = (jsonData: GltfJsonData): ComfyMetadata => {
const metadata: ComfyMetadata = {}
if (!jsonData?.asset?.extras) return metadata
const { extras } = jsonData.asset
if (extras.workflow) {
const parsedValue = parseMetadataValue(extras.workflow)
if (parsedValue) {
metadata[ComfyMetadataTags.WORKFLOW.toLowerCase()] = parsedValue
}
}
if (extras.prompt) {
const parsedValue = parseMetadataValue(extras.prompt)
if (parsedValue) {
metadata[ComfyMetadataTags.PROMPT.toLowerCase()] = parsedValue
}
}
return metadata
}
const processGltfFileBuffer = (buffer: ArrayBuffer): ComfyMetadata => {
const jsonChunk = extractJsonChunkData(buffer)
if (!jsonChunk) return {}
const parsedJson = parseJsonBytes(jsonChunk)
if (!parsedJson) return {}
return extractComfyMetadata(parsedJson)
}
/**
* Extract ComfyUI metadata from a GLTF binary file (GLB)
*/
export function getGltfBinaryMetadata(file: File): Promise<ComfyMetadata> {
return new Promise<ComfyMetadata>((resolve) => {
if (!file) return Promise.resolve({})
const bytesToRead = Math.min(file.size, MAX_READ_BYTES)
const reader = new FileReader()
reader.onload = (event) => {
try {
if (!event.target?.result) {
resolve({})
return
}
resolve(processGltfFileBuffer(event.target.result as ArrayBuffer))
} catch {
resolve({})
}
}
reader.onerror = () => resolve({})
reader.readAsArrayBuffer(file.slice(0, bytesToRead))
})
}

View File

@@ -43,3 +43,38 @@ export type TextRange = {
start: number
end: number
}
export enum ASCII {
GLTF = 0x46546c67,
JSON = 0x4e4f534a
}
export enum GltfSizeBytes {
HEADER = 12,
CHUNK_HEADER = 8
}
export type GltfHeader = {
magicNumber: number
gltfFormatVersion: number
totalLengthBytes: number
}
export type GltfChunkHeader = {
chunkLengthBytes: number
chunkTypeIdentifier: number
}
export type GltfExtras = {
workflow?: string | object
prompt?: string | object
[key: string]: any
}
export type GltfJsonData = {
asset?: {
extras?: GltfExtras
[key: string]: any
}
[key: string]: any
}

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest'
import { ASCII, GltfSizeBytes } from '@/types/metadataTypes'
import { getGltfBinaryMetadata } from '../../../../src/scripts/metadata/gltf'
describe('GLTF binary metadata parser', () => {
const createGLTFFileStructure = () => {
const header = new ArrayBuffer(GltfSizeBytes.HEADER)
const headerView = new DataView(header)
return { header, headerView }
}
const jsonToBinary = (json: object) => {
const jsonString = JSON.stringify(json)
const jsonData = new TextEncoder().encode(jsonString)
return jsonData
}
const createJSONChunk = (jsonData: ArrayBuffer) => {
const chunkHeader = new ArrayBuffer(GltfSizeBytes.CHUNK_HEADER)
const chunkView = new DataView(chunkHeader)
chunkView.setUint32(0, jsonData.byteLength, true)
chunkView.setUint32(4, ASCII.JSON, true)
return chunkHeader
}
const setVersionHeader = (headerView: DataView, version: number) => {
headerView.setUint32(4, version, true)
}
const setTypeHeader = (headerView: DataView, type: number) => {
headerView.setUint32(0, type, true)
}
const setTotalLengthHeader = (headerView: DataView, length: number) => {
headerView.setUint32(8, length, true)
}
const setHeaders = (headerView: DataView, jsonData: ArrayBuffer) => {
setTypeHeader(headerView, ASCII.GLTF)
setVersionHeader(headerView, 2)
setTotalLengthHeader(
headerView,
GltfSizeBytes.HEADER + GltfSizeBytes.CHUNK_HEADER + jsonData.byteLength
)
}
function createMockGltfFile(jsonContent: object): File {
const jsonData = jsonToBinary(jsonContent)
const { header, headerView } = createGLTFFileStructure()
setHeaders(headerView, jsonData)
const chunkHeader = createJSONChunk(jsonData)
const fileContent = new Uint8Array(
header.byteLength + chunkHeader.byteLength + jsonData.byteLength
)
fileContent.set(new Uint8Array(header), 0)
fileContent.set(new Uint8Array(chunkHeader), header.byteLength)
fileContent.set(jsonData, header.byteLength + chunkHeader.byteLength)
return new File([fileContent], 'test.glb', { type: 'model/gltf-binary' })
}
it('should extract workflow metadata from GLTF binary file', async () => {
const testWorkflow = {
nodes: [
{
id: 1,
type: 'TestNode',
pos: [100, 100]
}
],
links: []
}
const mockFile = createMockGltfFile({
asset: {
version: '2.0',
generator: 'ComfyUI GLTF Test',
extras: {
workflow: testWorkflow
}
},
scenes: []
})
const metadata = await getGltfBinaryMetadata(mockFile)
expect(metadata).toBeDefined()
expect(metadata.workflow).toBeDefined()
const workflow = metadata.workflow as {
nodes: Array<{ id: number; type: string }>
}
expect(workflow.nodes[0].id).toBe(1)
expect(workflow.nodes[0].type).toBe('TestNode')
})
it('should extract prompt metadata from GLTF binary file', async () => {
const testPrompt = {
node1: {
class_type: 'TestNode',
inputs: {
seed: 123456
}
}
}
const mockFile = createMockGltfFile({
asset: {
version: '2.0',
generator: 'ComfyUI GLTF Test',
extras: {
prompt: testPrompt
}
},
scenes: []
})
const metadata = await getGltfBinaryMetadata(mockFile)
expect(metadata).toBeDefined()
expect(metadata.prompt).toBeDefined()
const prompt = metadata.prompt as Record<string, any>
expect(prompt.node1.class_type).toBe('TestNode')
expect(prompt.node1.inputs.seed).toBe(123456)
})
it('should handle string JSON content', async () => {
const workflowStr = JSON.stringify({
nodes: [{ id: 1, type: 'StringifiedNode' }],
links: []
})
const mockFile = createMockGltfFile({
asset: {
version: '2.0',
extras: {
workflow: workflowStr // As string instead of object
}
}
})
const metadata = await getGltfBinaryMetadata(mockFile)
expect(metadata).toBeDefined()
expect(metadata.workflow).toBeDefined()
const workflow = metadata.workflow as {
nodes: Array<{ id: number; type: string }>
}
expect(workflow.nodes[0].type).toBe('StringifiedNode')
})
it('should handle invalid GLTF binary files gracefully', async () => {
const invalidEmptyFile = new File([], 'invalid.glb')
const metadata = await getGltfBinaryMetadata(invalidEmptyFile)
expect(metadata).toEqual({})
})
})