Compare commits
1 Commits
v1.46.7
...
feat/previ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dec5aa6eb |
111
CODEOWNERS
@@ -1,60 +1,95 @@
|
||||
# Desktop/Electron
|
||||
/apps/desktop-ui/ @benceruleanlu
|
||||
/src/stores/electronDownloadStore.ts @benceruleanlu
|
||||
/src/extensions/core/electronAdapter.ts @benceruleanlu
|
||||
/vite.electron.config.mts @benceruleanlu
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Partner Nodes
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @comfy_frontend_devs
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
|
||||
/src/components/templates/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/components/imagecrop/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useImageCrop.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
|
||||
# Image Compare
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
|
||||
|
||||
# Painter
|
||||
/src/extensions/core/painter.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/components/painter/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/painter/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/painter.ts @jtydhr88
|
||||
/src/components/painter/ @jtydhr88
|
||||
/src/composables/painter/ @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# GLSL
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @comfy_frontend_devs
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/load3d/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/components/load3d/ @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3d.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/services/load3dService.ts @jtydhr88 @comfy_frontend_devs
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
/src/extensions/core/load3d/ @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/composables/useLoad3d.ts @jtydhr88
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88
|
||||
/src/services/load3dService.ts @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @comfy_frontend_devs
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987 @comfy_frontend_devs
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 61 KiB |
@@ -2,6 +2,7 @@
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
|
||||
@@ -115,6 +116,8 @@ const plans: PricingPlan[] = [
|
||||
|
||||
const standardPlans = plans.filter((p) => !p.isEnterprise)
|
||||
const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
|
||||
const activePlanIndex = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,7 +134,28 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
|
||||
<!-- Mobile plan tabs -->
|
||||
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
|
||||
<button
|
||||
v-for="(plan, index) in plans"
|
||||
:key="plan.id"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 rounded-full px-4 py-2 text-xs font-bold tracking-wider transition-colors',
|
||||
activePlanIndex === index
|
||||
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
|
||||
: 'bg-transparency-white-t4 text-primary-comfy-canvas'
|
||||
)
|
||||
"
|
||||
@click="activePlanIndex = index"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t(plan.labelKey, locale) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
@@ -249,9 +273,13 @@ const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</PricingTierCard>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: stacked plans -->
|
||||
<div class="flex flex-col gap-8 lg:hidden">
|
||||
<div v-for="plan in plans" :key="plan.id" class="flex flex-col">
|
||||
<!-- Mobile: single plan view -->
|
||||
<div class="lg:hidden">
|
||||
<div
|
||||
v-for="(plan, index) in plans"
|
||||
:key="plan.id"
|
||||
:class="cn('flex-col', activePlanIndex !== index ? 'hidden' : 'flex')"
|
||||
>
|
||||
<!-- Main info card -->
|
||||
<div class="bg-transparency-white-t4 rounded-3xl p-6">
|
||||
<!-- Label + badge -->
|
||||
|
||||
@@ -11,12 +11,14 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl relative mx-auto mb-12 flex flex-col items-center overflow-hidden px-4 md:flex-row md:overflow-visible md:pt-20 lg:items-center lg:space-x-20"
|
||||
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row lg:items-center lg:overflow-visible lg:pb-[min(8vw,10rem)]"
|
||||
>
|
||||
<!-- Illustration (stacks above on mobile, left on lg) -->
|
||||
<div class="pointer-events-none mx-auto w-full flex-1 md:-translate-x-20">
|
||||
<div
|
||||
class="aspect-square w-4/5 max-w-md scale-125 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-10 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
|
||||
>
|
||||
<svg
|
||||
class="mx-auto block size-full max-w-lg overflow-visible md:ml-auto md:scale-125"
|
||||
class="block size-full overflow-visible"
|
||||
viewBox="50 50 900 900"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
@@ -376,7 +378,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="relative z-10 lg:flex-1">
|
||||
<div
|
||||
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[25%] lg:px-20 lg:py-14"
|
||||
>
|
||||
<ProductHeroBadge text="CLOUD" />
|
||||
|
||||
<h1
|
||||
@@ -386,7 +390,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-6 max-w-lg text-sm lg:mt-6 lg:text-base"
|
||||
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
|
||||
>
|
||||
{{ t('cloud.hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -168,7 +168,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<!-- Illustration (stacks above on mobile, left on lg) -->
|
||||
<div
|
||||
class="aspect-550/800 w-4/5 max-w-xs self-center overflow-visible md:max-w-sm lg:pointer-events-none lg:z-1 lg:-mr-12 lg:max-w-md lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
|
||||
class="aspect-550/800 w-4/5 max-w-md scale-150 self-center overflow-visible md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-mr-12 lg:translate-x-[10%] lg:translate-y-20 lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
|
||||
>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
|
||||
@@ -12,7 +12,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-canvas relative z-10 text-sm font-semibold lg:text-sm lg:font-normal"
|
||||
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
|
||||
>
|
||||
<span class="whitespace-nowrap">
|
||||
{{ t('download.cloud.prefix', locale) }}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockInit: vi.fn(),
|
||||
mockCapture: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: hoisted.mockInit,
|
||||
capture: hoisted.mockCapture
|
||||
}
|
||||
}))
|
||||
|
||||
describe('initPostHog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
|
||||
const { initPostHog } = await import('./posthog')
|
||||
initPostHog()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledOnce()
|
||||
const initOptions = hoisted.mockInit.mock.calls[0][1]
|
||||
expect(initOptions.person_profiles).toBe('identified_only')
|
||||
expect(typeof initOptions.before_send).toBe('function')
|
||||
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: { email: 'd@example.com', name: 'keep me' },
|
||||
$set_once: { $email: 'e@example.com', plan: 'free' }
|
||||
}
|
||||
|
||||
const result = initOptions.before_send(event)
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
expect(result.$set_once).not.toHaveProperty('$email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -20,9 +18,7 @@ export function initPostHog() {
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
|
||||
before_send: createPostHogBeforeSend()
|
||||
person_profiles: 'identified_only'
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
class UserSelectPage {
|
||||
export class UserSelectPage {
|
||||
public readonly selectionUrl: string
|
||||
public readonly container: Locator
|
||||
public readonly newUserInput: Locator
|
||||
|
||||
@@ -18,7 +18,7 @@ class ShortcutsTab {
|
||||
}
|
||||
}
|
||||
|
||||
class LogsTab {
|
||||
export class LogsTab {
|
||||
readonly tab: Locator
|
||||
readonly terminalRoot: Locator
|
||||
readonly terminalHost: Locator
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
class ComfyNodeSearchFilterSelectionPanel {
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
readonly root: Locator
|
||||
readonly header: Locator
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { Position } from '@e2e/fixtures/types'
|
||||
|
||||
const { searchBoxV2 } = TestIds
|
||||
|
||||
export type { RootCategoryId }
|
||||
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
|
||||
@@ -27,10 +27,6 @@ export class ContextMenu {
|
||||
await this.waitForHidden()
|
||||
}
|
||||
|
||||
menuItem(name: string): Locator {
|
||||
return this.anyMenu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a litegraph menu entry. Selects the most recently opened matching
|
||||
* entry so nested submenu items can be reached without being shadowed by
|
||||
|
||||
@@ -2,3 +2,8 @@ export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
@@ -86,6 +86,46 @@ export const STABLE_LORA: Asset = createModelAsset({
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_LORA_2: Asset = createModelAsset({
|
||||
id: 'test-lora-002',
|
||||
name: 'add_detail_v2.safetensors',
|
||||
size: 226_492_416,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Add Detail LoRA v2'
|
||||
},
|
||||
created_at: '2025-02-25T11:00:00Z',
|
||||
updated_at: '2025-02-25T11:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_VAE: Asset = createModelAsset({
|
||||
id: 'test-vae-001',
|
||||
name: 'sdxl_vae.safetensors',
|
||||
size: 334_641_152,
|
||||
tags: ['models', 'vae'],
|
||||
user_metadata: {
|
||||
base_model: 'sdxl',
|
||||
description: 'SDXL VAE'
|
||||
},
|
||||
created_at: '2025-01-18T16:00:00Z',
|
||||
updated_at: '2025-01-18T16:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_EMBEDDING: Asset = createModelAsset({
|
||||
id: 'test-embedding-001',
|
||||
name: 'bad_prompt_v2.pt',
|
||||
size: 32_768,
|
||||
mime_type: 'application/x-pytorch',
|
||||
tags: ['models', 'embeddings'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Negative Embedding: Bad Prompt v2'
|
||||
},
|
||||
created_at: '2025-02-01T09:30:00Z',
|
||||
updated_at: '2025-02-01T09:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
id: 'test-input-001',
|
||||
name: 'reference_photo.png',
|
||||
@@ -96,6 +136,26 @@ export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
updated_at: '2025-03-01T09:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE_2: Asset = createInputAsset({
|
||||
id: 'test-input-002',
|
||||
name: 'mask_layer.png',
|
||||
size: 1_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-05T10:00:00Z',
|
||||
updated_at: '2025-03-05T10:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_VIDEO: Asset = createInputAsset({
|
||||
id: 'test-input-003',
|
||||
name: 'clip_720p.mp4',
|
||||
size: 15_728_640,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-08T14:30:00Z',
|
||||
updated_at: '2025-03-08T14:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT: Asset = createOutputAsset({
|
||||
id: 'test-output-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
@@ -106,6 +166,31 @@ export const STABLE_OUTPUT: Asset = createOutputAsset({
|
||||
updated_at: '2025-03-10T12:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT_2: Asset = createOutputAsset({
|
||||
id: 'test-output-002',
|
||||
name: 'ComfyUI_00002_.png',
|
||||
size: 3_670_016,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-03-10T12:05:00Z',
|
||||
updated_at: '2025-03-10T12:05:00Z'
|
||||
})
|
||||
export const ALL_MODEL_FIXTURES: Asset[] = [
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2,
|
||||
STABLE_LORA,
|
||||
STABLE_LORA_2,
|
||||
STABLE_VAE,
|
||||
STABLE_EMBEDDING
|
||||
]
|
||||
|
||||
export const ALL_INPUT_FIXTURES: Asset[] = [
|
||||
STABLE_INPUT_IMAGE,
|
||||
STABLE_INPUT_IMAGE_2,
|
||||
STABLE_INPUT_VIDEO
|
||||
]
|
||||
|
||||
export const ALL_OUTPUT_FIXTURES: Asset[] = [STABLE_OUTPUT, STABLE_OUTPUT_2]
|
||||
const CHECKPOINT_NAMES = [
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
|
||||
155
browser_tests/fixtures/data/nodeDefinitions.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
/**
|
||||
* Base node definitions covering the default workflow.
|
||||
* Use {@link createMockNodeDefinitions} to extend with per-test overrides.
|
||||
*/
|
||||
const baseNodeDefinitions: Record<string, ComfyNodeDef> = {
|
||||
KSampler: {
|
||||
input: {
|
||||
required: {
|
||||
model: ['MODEL', {}],
|
||||
seed: [
|
||||
'INT',
|
||||
{
|
||||
default: 0,
|
||||
min: 0,
|
||||
max: 0xfffffffffffff,
|
||||
control_after_generate: true
|
||||
}
|
||||
],
|
||||
steps: ['INT', { default: 20, min: 1, max: 10000 }],
|
||||
cfg: ['FLOAT', { default: 8.0, min: 0.0, max: 100.0, step: 0.1 }],
|
||||
sampler_name: [['euler', 'euler_ancestral', 'heun', 'dpm_2'], {}],
|
||||
scheduler: [['normal', 'karras', 'exponential', 'simple'], {}],
|
||||
positive: ['CONDITIONING', {}],
|
||||
negative: ['CONDITIONING', {}],
|
||||
latent_image: ['LATENT', {}]
|
||||
},
|
||||
optional: {
|
||||
denoise: ['FLOAT', { default: 1.0, min: 0.0, max: 1.0, step: 0.01 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
description: 'Samples latents using the provided model and conditioning.',
|
||||
category: 'sampling',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CheckpointLoaderSimple: {
|
||||
input: {
|
||||
required: {
|
||||
ckpt_name: [
|
||||
['v1-5-pruned.safetensors', 'sd_xl_base_1.0.safetensors'],
|
||||
{}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MODEL', 'CLIP', 'VAE'],
|
||||
output_is_list: [false, false, false],
|
||||
output_name: ['MODEL', 'CLIP', 'VAE'],
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint',
|
||||
description: 'Loads a diffusion model checkpoint.',
|
||||
category: 'loaders',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
CLIPTextEncode: {
|
||||
input: {
|
||||
required: {
|
||||
text: ['STRING', { multiline: true, dynamicPrompts: true }],
|
||||
clip: ['CLIP', {}]
|
||||
}
|
||||
},
|
||||
output: ['CONDITIONING'],
|
||||
output_is_list: [false],
|
||||
output_name: ['CONDITIONING'],
|
||||
name: 'CLIPTextEncode',
|
||||
display_name: 'CLIP Text Encode (Prompt)',
|
||||
description: 'Encodes a text prompt using a CLIP model.',
|
||||
category: 'conditioning',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
EmptyLatentImage: {
|
||||
input: {
|
||||
required: {
|
||||
width: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
height: ['INT', { default: 512, min: 16, max: 16384, step: 8 }],
|
||||
batch_size: ['INT', { default: 1, min: 1, max: 4096 }]
|
||||
}
|
||||
},
|
||||
output: ['LATENT'],
|
||||
output_is_list: [false],
|
||||
output_name: ['LATENT'],
|
||||
name: 'EmptyLatentImage',
|
||||
display_name: 'Empty Latent Image',
|
||||
description: 'Creates an empty latent image of the specified dimensions.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
VAEDecode: {
|
||||
input: {
|
||||
required: {
|
||||
samples: ['LATENT', {}],
|
||||
vae: ['VAE', {}]
|
||||
}
|
||||
},
|
||||
output: ['IMAGE'],
|
||||
output_is_list: [false],
|
||||
output_name: ['IMAGE'],
|
||||
name: 'VAEDecode',
|
||||
display_name: 'VAE Decode',
|
||||
description: 'Decodes latent images back into pixel space.',
|
||||
category: 'latent',
|
||||
output_node: false,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
},
|
||||
|
||||
SaveImage: {
|
||||
input: {
|
||||
required: {
|
||||
images: ['IMAGE', {}],
|
||||
filename_prefix: ['STRING', { default: 'ComfyUI' }]
|
||||
}
|
||||
},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
name: 'SaveImage',
|
||||
display_name: 'Save Image',
|
||||
description: 'Saves images to the output directory.',
|
||||
category: 'image',
|
||||
output_node: true,
|
||||
python_module: 'nodes',
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockNodeDefinitions(
|
||||
overrides?: Record<string, ComfyNodeDef>
|
||||
): Record<string, ComfyNodeDef> {
|
||||
const base = structuredClone(baseNodeDefinitions)
|
||||
return overrides ? { ...base, ...overrides } : base
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'cloud-stable',
|
||||
title: 'Cloud Stable',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
|
||||
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'desktop-stable',
|
||||
title: 'Desktop Stable',
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
|
||||
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'local-stable',
|
||||
title: 'Local Stable',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
|
||||
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'unrestricted-stable',
|
||||
title: 'Unrestricted Stable'
|
||||
})
|
||||
|
||||
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
|
||||
STABLE_CLOUD_TEMPLATE,
|
||||
STABLE_DESKTOP_TEMPLATE,
|
||||
STABLE_LOCAL_TEMPLATE,
|
||||
STABLE_UNRESTRICTED_TEMPLATE
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
generateOutputAssets
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
interface MutationRecord {
|
||||
export interface MutationRecord {
|
||||
endpoint: string
|
||||
method: string
|
||||
url: string
|
||||
@@ -23,7 +23,7 @@ interface PaginationOptions {
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
interface AssetConfig {
|
||||
export interface AssetConfig {
|
||||
readonly assets: ReadonlyMap<string, Asset>
|
||||
readonly pagination: PaginationOptions | null
|
||||
readonly uploadResponse: Record<string, unknown> | null
|
||||
@@ -33,7 +33,7 @@ function emptyConfig(): AssetConfig {
|
||||
return { assets: new Map(), pagination: null, uploadResponse: null }
|
||||
}
|
||||
|
||||
type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
export type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
|
||||
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
|
||||
const merged = new Map(config.assets)
|
||||
|
||||
@@ -26,7 +26,7 @@ const historyRoutePattern = /\/api\/history$/
|
||||
* The sidebar filter ultimately matches on the filename extension, so the
|
||||
* fixture also picks an extension-appropriate filename for each kind.
|
||||
*/
|
||||
type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
|
||||
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
|
||||
images: 'png',
|
||||
@@ -134,6 +134,16 @@ export function createJobsWithExecutionTimes(
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
type ReleaseNote = components['schemas']['ReleaseNote']
|
||||
|
||||
type HelpMenuItemKey =
|
||||
export type HelpMenuItemKey =
|
||||
| 'feedback'
|
||||
| 'help'
|
||||
| 'docs'
|
||||
@@ -17,7 +17,7 @@ type HelpMenuItemKey =
|
||||
| 'update-comfyui'
|
||||
| 'more'
|
||||
|
||||
class HelpCenterHelper {
|
||||
export class HelpCenterHelper {
|
||||
public readonly button: Locator
|
||||
public readonly popup: Locator
|
||||
public readonly backdrop: Locator
|
||||
|
||||
@@ -7,9 +7,9 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
const RGB_CANVAS_INDEX = 1
|
||||
|
||||
type BrushSliderLabel = 'thickness'
|
||||
export type BrushSliderLabel = 'thickness'
|
||||
|
||||
class MaskEditorHelper {
|
||||
export class MaskEditorHelper {
|
||||
constructor(private comfyPage: ComfyPage) {}
|
||||
|
||||
private get page() {
|
||||
|
||||
@@ -9,7 +9,7 @@ const modelFoldersRoutePattern = /\/api\/experiment\/models$/
|
||||
const modelFilesRoutePattern = /\/api\/experiment\/models\/([^?]+)/
|
||||
const viewMetadataRoutePattern = /\/api\/view_metadata\/([^?]+)/
|
||||
|
||||
interface MockModelMetadata {
|
||||
export interface MockModelMetadata {
|
||||
'modelspec.title'?: string
|
||||
'modelspec.author'?: string
|
||||
'modelspec.architecture'?: string
|
||||
@@ -18,11 +18,14 @@ interface MockModelMetadata {
|
||||
'modelspec.tags'?: string
|
||||
}
|
||||
|
||||
function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
export function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
return names.map((name) => ({ name, folders: [] }))
|
||||
}
|
||||
|
||||
function createMockModelFiles(filenames: string[], pathIndex = 0): ModelFile[] {
|
||||
export function createMockModelFiles(
|
||||
filenames: string[],
|
||||
pathIndex = 0
|
||||
): ModelFile[] {
|
||||
return filenames.map((name) => ({ name, pathIndex }))
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const DEFAULT_UPLOAD_URL_RESPONSE: HubAssetUploadUrlResponse = {
|
||||
token: 'mock-upload-token'
|
||||
}
|
||||
|
||||
class PublishApiHelper {
|
||||
export class PublishApiHelper {
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import { SubgraphBreadcrumbPanel } from '@e2e/fixtures/components/SubgraphBreadcrumbPanel'
|
||||
|
||||
class SubgraphBreadcrumbHelper {
|
||||
export class SubgraphBreadcrumbHelper {
|
||||
readonly panel: SubgraphBreadcrumbPanel
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
|
||||
@@ -4,9 +4,33 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
interface TemplateConfig {
|
||||
/**
|
||||
* Generate N deterministic templates, optionally restricted to a distribution.
|
||||
*
|
||||
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
|
||||
* for static test data with no executable fixture logic.
|
||||
*/
|
||||
function generateTemplates(
|
||||
count: number,
|
||||
distribution?: TemplateIncludeOnDistributionEnum
|
||||
): TemplateInfo[] {
|
||||
const slug = distribution ?? 'unrestricted'
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeTemplate({
|
||||
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
|
||||
title: `Generated ${slug} ${i + 1}`,
|
||||
...(distribution ? { includeOnDistributions: [distribution] } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
}
|
||||
@@ -15,7 +39,7 @@ function emptyConfig(): TemplateConfig {
|
||||
return { templates: [], index: null }
|
||||
}
|
||||
|
||||
type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
|
||||
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
|
||||
return templates.map((t) => structuredClone(t))
|
||||
@@ -38,6 +62,46 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
return (config) => addTemplates(config, templates)
|
||||
}
|
||||
|
||||
export function withTemplate(template: TemplateInfo): TemplateOperator {
|
||||
return (config) => addTemplates(config, [template])
|
||||
}
|
||||
|
||||
export function withCloudTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
|
||||
)
|
||||
}
|
||||
|
||||
export function withDesktopTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
|
||||
)
|
||||
}
|
||||
|
||||
export function withLocalTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
|
||||
)
|
||||
}
|
||||
|
||||
export function withUnrestrictedTemplates(count: number): TemplateOperator {
|
||||
return (config) => addTemplates(config, generateTemplates(count))
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the index payload entirely. Useful when a test needs a custom
|
||||
* `WorkflowTemplates[]` shape (e.g. multiple modules).
|
||||
*/
|
||||
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
|
||||
return (config) => ({ ...config, index })
|
||||
}
|
||||
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
|
||||
@@ -121,7 +121,7 @@ export function createRouteMockJob({
|
||||
}
|
||||
}
|
||||
|
||||
class JobsRouteMocker {
|
||||
export class JobsRouteMocker {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobsHistory(
|
||||
|
||||
@@ -137,8 +137,7 @@ export const TestIds = {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
colorRed: 'red'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
@@ -304,3 +303,12 @@ export const TestIds = {
|
||||
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
|
||||
}
|
||||
} as const
|
||||
|
||||
export type TestId<K extends keyof typeof TestIds> = Exclude<
|
||||
(typeof TestIds)[K][keyof (typeof TestIds)[K]],
|
||||
(...args: never[]) => string
|
||||
>
|
||||
|
||||
export type TestIdValue = {
|
||||
[K in keyof typeof TestIds]: TestId<K>
|
||||
}[keyof typeof TestIds]
|
||||
|
||||
@@ -19,7 +19,7 @@ export const sharedWorkflowImportScenario = {
|
||||
inputFileName: 'shared_imported_image.png'
|
||||
} as const
|
||||
|
||||
type SharedWorkflowRequestEvent =
|
||||
export type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { Page } from '@playwright/test'
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
|
||||
interface MeasureResult {
|
||||
export type { CanvasRect }
|
||||
|
||||
export interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ class NodeSlotReference {
|
||||
}
|
||||
}
|
||||
|
||||
class NodeWidgetReference {
|
||||
export class NodeWidgetReference {
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly node: NodeReference
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from 'path'
|
||||
|
||||
import type { PerfMeasurement } from '@e2e/fixtures/helpers/PerformanceHelper'
|
||||
|
||||
interface PerfReport {
|
||||
export interface PerfReport {
|
||||
timestamp: string
|
||||
gitSha: string
|
||||
branch: string
|
||||
|
||||
@@ -20,7 +20,9 @@ function previewExposureToEntry(
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
export function isPromotedWidgetSource(
|
||||
value: unknown
|
||||
): value is PromotedWidgetSource {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
@@ -31,7 +33,7 @@ function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
)
|
||||
}
|
||||
|
||||
function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
export function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
if (value === null || value === undefined) return false
|
||||
const t = typeof value
|
||||
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
interface SlotMeasurement {
|
||||
export interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
interface NodeSlotData {
|
||||
export interface NodeSlotData {
|
||||
nodeId: string
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
type ChangeTrackerDebugState = {
|
||||
changeCount: number
|
||||
@@ -311,28 +310,4 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Tracks convert to subgraph as undo step',
|
||||
{ tag: ['@vue-nodes', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Empty Latent')
|
||||
const width = comfyPage.vueNodes.getWidgetByName('Empty Latent', 'width')
|
||||
const { input } = comfyPage.vueNodes.getInputNumberControls(width)
|
||||
|
||||
await input.fill('40')
|
||||
await node.title.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.selectionToolbox.convertSubgraph)
|
||||
.click()
|
||||
await expect(input).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('40')
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('512')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
|
||||
|
||||
type SharedWorkflowResponse = z.input<typeof zSharedWorkflowResponse>
|
||||
|
||||
const shareId = 'fe828-long-name'
|
||||
|
||||
// Unbroken, space-free name (mimics a content-hash workflow name) that cannot
|
||||
// wrap at whitespace and previously forced the dialog to scroll horizontally.
|
||||
const longWorkflowName =
|
||||
'c23df0133afe9cf61a9c0e3b1f5d8a7e6429bd14f0a3c8e2d9b7165430fedcba99887766554433221100ffeeddccbbaa'
|
||||
|
||||
const longNameWorkflowResponse: SharedWorkflowResponse = {
|
||||
share_id: shareId,
|
||||
workflow_id: 'fe828-long-name-workflow',
|
||||
name: longWorkflowName,
|
||||
listed: true,
|
||||
publish_time: '2026-05-01T00:00:00Z',
|
||||
workflow_json: {
|
||||
version: 0.4,
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: []
|
||||
},
|
||||
assets: []
|
||||
}
|
||||
|
||||
async function mockLongNameSharedWorkflow(page: Page): Promise<void> {
|
||||
await page.route(`**/workflows/published/${shareId}`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(longNameWorkflowResponse)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
test.describe('Open shared workflow dialog', { tag: '@cloud' }, () => {
|
||||
test('wraps a long workflow name instead of scrolling horizontally', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
await mockLongNameSharedWorkflow(page)
|
||||
await comfyPage.setup({ clearStorage: false, url: `/?share=${shareId}` })
|
||||
|
||||
const dialog = page.getByTestId(TestIds.dialogs.openSharedWorkflow)
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.dialogs.openSharedWorkflowTitle)
|
||||
).toBeVisible()
|
||||
|
||||
const heading = dialog.locator('main h2')
|
||||
await expect(heading).toHaveText(longWorkflowName)
|
||||
|
||||
const { scrollWidth, clientWidth } = await dialog.evaluate((el) => ({
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth
|
||||
}))
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1)
|
||||
})
|
||||
})
|
||||
@@ -166,6 +166,15 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||
// Move mouse away to avoid hover highlight on the node at the drop position.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Duplication', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Pin this suite to the legacy canvas path so Alt+drag exercises
|
||||
|
||||
|
After Width: | Height: | Size: 93 KiB |
@@ -166,7 +166,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
@@ -174,33 +174,12 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
})
|
||||
|
||||
test('shows exactly one bypass menu item per state (FE-720 regression)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeTitle = 'Load Checkpoint'
|
||||
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
||||
const bypassItem = comfyPage.contextMenu.menuItem('Bypass')
|
||||
const removeBypassItem = comfyPage.contextMenu.menuItem('Remove Bypass')
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await expect(bypassItem).toHaveCount(1)
|
||||
await expect(removeBypassItem).toHaveCount(0)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await expect(removeBypassItem).toHaveCount(1)
|
||||
await expect(bypassItem).toHaveCount(0)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
})
|
||||
|
||||
test('should minimize and expand node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -472,7 +451,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
|
||||
}
|
||||
|
||||
@@ -481,7 +460,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>ComfyUI</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, user-scalable=no"
|
||||
|
||||
@@ -73,7 +73,7 @@ const config: KnipConfig = {
|
||||
},
|
||||
playwright: {
|
||||
config: ['playwright?(.*).config.ts'],
|
||||
entry: ['browser_tests/**/*.@(spec|test).?(c|m)[jt]s?(x)']
|
||||
entry: ['**/*.@(spec|test).?(c|m)[jt]s?(x)', 'browser_tests/**/*.ts']
|
||||
},
|
||||
tags: [
|
||||
'-knipIgnoreUnusedButUsedByCustomNodes',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.7",
|
||||
"version": "1.46.6",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "catalog:",
|
||||
"@iconify/tailwind4": "catalog:",
|
||||
"@iconify/tools": "catalog:",
|
||||
"@iconify/utils": "catalog:",
|
||||
"tailwindcss-primeui": "catalog:",
|
||||
"tw-animate-css": "catalog:"
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
cleanupSVG,
|
||||
importDirectorySync,
|
||||
isEmptyColor,
|
||||
parseColors,
|
||||
runSVGO
|
||||
} from '@iconify/tools'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export const COMFY_ICON_PREFIX = 'comfy'
|
||||
|
||||
const COMFY_ICONS_DIR = resolve(import.meta.dirname, '../icons')
|
||||
|
||||
let cached
|
||||
|
||||
/**
|
||||
* Load the comfy icon folder as a normalized Iconify icon set.
|
||||
*
|
||||
* Mirrors the pipeline that `@plugin "@iconify/tailwind4" { from-folder(...) }`
|
||||
* runs internally so monotone hardcoded colors become `currentColor` and
|
||||
* outer-svg attributes like `fill="none"` survive the body extraction.
|
||||
*/
|
||||
export function loadComfyIconSet() {
|
||||
if (cached) return cached
|
||||
const iconSet = importDirectorySync(COMFY_ICONS_DIR)
|
||||
iconSet.forEachSync((name, type) => {
|
||||
if (type !== 'icon') return
|
||||
const svg = iconSet.toSVG(name)
|
||||
if (!svg) {
|
||||
iconSet.remove(name)
|
||||
return
|
||||
}
|
||||
try {
|
||||
cleanupSVG(svg)
|
||||
const palette = parseColors(svg)
|
||||
const colors = palette.colors.filter(
|
||||
(color) => typeof color === 'string' || !isEmptyColor(color)
|
||||
)
|
||||
const totalColors = colors.length + (palette.hasUnsetColor ? 1 : 0)
|
||||
if (totalColors < 2) {
|
||||
parseColors(svg, {
|
||||
defaultColor: 'currentColor',
|
||||
callback: (_attr, colorStr, color) =>
|
||||
!color || isEmptyColor(color) ? colorStr : 'currentColor'
|
||||
})
|
||||
}
|
||||
runSVGO(svg)
|
||||
iconSet.fromSVG(name, svg)
|
||||
} catch {
|
||||
iconSet.remove(name)
|
||||
}
|
||||
})
|
||||
cached = iconSet.export()
|
||||
return cached
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getDynamicCSSRules } from '@iconify/tailwind4/lib/plugins/dynamic.js'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
|
||||
|
||||
const SCALE = 1.2
|
||||
|
||||
const options = {
|
||||
iconSets: { [COMFY_ICON_PREFIX]: loadComfyIconSet() },
|
||||
scale: SCALE
|
||||
}
|
||||
|
||||
export default plugin(({ matchComponents }) => {
|
||||
matchComponents({
|
||||
icon: (icon) => {
|
||||
try {
|
||||
return getDynamicCSSRules(icon, options)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,11 @@
|
||||
import { getIconsCSSData } from '@iconify/utils/lib/css/icons'
|
||||
import { matchIconName } from '@iconify/utils/lib/icon/name'
|
||||
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
|
||||
import { getIconsCSSData } from '@iconify/utils/lib/css/icons'
|
||||
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
|
||||
import { matchIconName } from '@iconify/utils/lib/icon/name'
|
||||
|
||||
/**
|
||||
* Tailwind 4 plugin that provides icon variants with configurable
|
||||
* stroke-width via class prefix. Supports lucide and comfy icon sets.
|
||||
* Tailwind 4 plugin that provides lucide icon variants with configurable
|
||||
* stroke-width via class prefix.
|
||||
*
|
||||
* Usage in CSS:
|
||||
* @plugin "./lucideStrokePlugin.js";
|
||||
@@ -15,40 +13,25 @@ import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
|
||||
* Usage in templates:
|
||||
* <i class="icon-s1-[lucide--settings]" /> <!-- stroke-width: 1 -->
|
||||
* <i class="icon-s1.5-[lucide--settings]" /> <!-- stroke-width: 1.5 -->
|
||||
* <i class="icon-s2.5-[comfy--workflow]" /> <!-- stroke-width: 2.5 -->
|
||||
* <i class="icon-s2.5-[lucide--settings]" /> <!-- stroke-width: 2.5 -->
|
||||
*
|
||||
* The plain `icon-[...]` class keeps each icon's native stroke-width.
|
||||
* The default class remains stroke-width: 2.
|
||||
*/
|
||||
|
||||
const STROKE_WIDTHS = ['1', '1.3', '1.5', '2', '2.5']
|
||||
|
||||
const LUCIDE_PREFIX = 'lucide'
|
||||
const SUPPORTED_PREFIXES = new Set([LUCIDE_PREFIX, COMFY_ICON_PREFIX])
|
||||
|
||||
const SCALE = 1.2
|
||||
|
||||
const STROKE_WIDTH_ATTR_RE = /stroke-width="[^"]*"/g
|
||||
|
||||
class InvalidIconProbeError extends Error {}
|
||||
|
||||
function resolveIconSet(prefix) {
|
||||
if (prefix === COMFY_ICON_PREFIX) return loadComfyIconSet()
|
||||
return loadIconSet(prefix)
|
||||
}
|
||||
|
||||
function getDynamicCSSRulesWithStroke(icon, strokeWidth) {
|
||||
const nameParts = icon.split(/--|:/)
|
||||
if (nameParts.length !== 2) {
|
||||
throw new InvalidIconProbeError(`Invalid icon name: "${icon}"`)
|
||||
throw new Error(`Invalid icon name: "${icon}"`)
|
||||
}
|
||||
const [prefix, name] = nameParts
|
||||
if (!SUPPORTED_PREFIXES.has(prefix)) {
|
||||
throw new InvalidIconProbeError(`Unsupported icon prefix: "${prefix}"`)
|
||||
}
|
||||
if (!(prefix.match(matchIconName) && name.match(matchIconName))) {
|
||||
throw new InvalidIconProbeError(`Invalid icon name: "${icon}"`)
|
||||
throw new Error(`Invalid icon name: "${icon}"`)
|
||||
}
|
||||
const iconSet = resolveIconSet(prefix)
|
||||
const iconSet = loadIconSet(prefix)
|
||||
if (!iconSet) {
|
||||
throw new Error(
|
||||
`Cannot load icon set for "${prefix}". Install "@iconify-json/${prefix}" as dev dependency?`
|
||||
@@ -57,7 +40,7 @@ function getDynamicCSSRulesWithStroke(icon, strokeWidth) {
|
||||
const generated = getIconsCSSData(iconSet, [name], {
|
||||
iconSelector: '.icon',
|
||||
customise: (content) =>
|
||||
content.replace(STROKE_WIDTH_ATTR_RE, `stroke-width="${strokeWidth}"`)
|
||||
content.replaceAll('stroke-width="2"', `stroke-width="${strokeWidth}"`)
|
||||
})
|
||||
if (generated.css.length !== 1) {
|
||||
throw new Error(`Cannot find "${icon}". Bad icon name?`)
|
||||
@@ -79,8 +62,8 @@ export default plugin(({ matchComponents }) => {
|
||||
try {
|
||||
return getDynamicCSSRulesWithStroke(icon, sw)
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidIconProbeError) return {}
|
||||
throw err
|
||||
console.warn(err.message)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
|
||||
@plugin 'tailwindcss-primeui';
|
||||
|
||||
@plugin "./iconifyDynamicPlugin.js";
|
||||
@plugin "@iconify/tailwind4" {
|
||||
scale: 1.2;
|
||||
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
|
||||
}
|
||||
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts",
|
||||
"./piiUtil": "./src/piiUtil.ts"
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createPostHogBeforeSend } from './piiUtil'
|
||||
|
||||
describe('createPostHogBeforeSend', () => {
|
||||
const beforeSend = createPostHogBeforeSend()
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(beforeSend(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('strips all PII keys from properties, $set, and $set_once', () => {
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'd@example.com',
|
||||
user_email: 'e@example.com',
|
||||
$email: 'f@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'g@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = beforeSend(event)!
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('handles missing property bags gracefully', () => {
|
||||
const event = { properties: { email: 'a@example.com', safe: true } }
|
||||
const result = beforeSend(event)!
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).toHaveProperty('safe', true)
|
||||
expect(result.$set).toBeUndefined()
|
||||
expect(result.$set_once).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
|
||||
|
||||
function stripPiiKeys(obj?: Record<string, unknown>): void {
|
||||
if (!obj) return
|
||||
for (const key of PII_KEYS) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog before_send hook that strips PII from all three property bags
|
||||
* an event can carry: properties, $set, and $set_once.
|
||||
*
|
||||
* posthog.identify(id, { email }) lands in $set, not properties, so all
|
||||
* three bags must be sanitized.
|
||||
*
|
||||
* Ref: posthog.com/tutorials/web-redact-properties
|
||||
*/
|
||||
interface PostHogEventLike {
|
||||
properties?: Record<string, unknown>
|
||||
$set?: Record<string, unknown>
|
||||
$set_once?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createPostHogBeforeSend() {
|
||||
return function beforeSend<E extends PostHogEventLike>(
|
||||
event: E | null
|
||||
): E | null {
|
||||
if (!event) return null
|
||||
stripPiiKeys(event.properties)
|
||||
stripPiiKeys(event.$set)
|
||||
stripPiiKeys(event.$set_once)
|
||||
return event
|
||||
}
|
||||
}
|
||||
2014
pnpm-lock.yaml
generated
@@ -9,16 +9,15 @@ publicHoistPattern:
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@astrojs/check': ^0.9.9
|
||||
'@astrojs/sitemap': ^3.7.3
|
||||
'@astrojs/vue': ^6.0.1
|
||||
'@astrojs/check': ^0.9.8
|
||||
'@astrojs/sitemap': ^3.7.1
|
||||
'@astrojs/vue': ^5.0.0
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
'@eslint/js': ^10.0.1
|
||||
'@formkit/auto-animate': ^0.9.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind4': ^1.2.3
|
||||
'@iconify/tools': ^5.0.3
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
@@ -66,7 +65,7 @@ catalog:
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
astro: ^6.4.2
|
||||
astro: ^5.10.0
|
||||
axios: ^1.15.2
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 9.4 KiB |
@@ -44,14 +44,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
|
||||
v-if="canFitToViewer"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
>
|
||||
<div
|
||||
v-if="canFitToViewer || canCenterCameraOnModel"
|
||||
class="flex flex-col rounded-lg bg-backdrop/30"
|
||||
>
|
||||
<div class="flex flex-col rounded-lg bg-backdrop/30">
|
||||
<Button
|
||||
v-if="canFitToViewer"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.fitToViewer'),
|
||||
showDelay: 300
|
||||
@@ -64,29 +61,25 @@
|
||||
>
|
||||
<i class="pi pi-window-maximize text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canCenterCameraOnModel"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.centerCameraOnModel'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.centerCameraOnModel')"
|
||||
@click="handleCenterCameraOnModel"
|
||||
>
|
||||
<i class="pi pi-compass text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ViewerControls
|
||||
v-if="enable3DViewer && node"
|
||||
:node="node as LGraphNode"
|
||||
/>
|
||||
<div
|
||||
v-if="enable3DViewer && node"
|
||||
class="pointer-events-auto absolute top-24 right-2 z-20"
|
||||
>
|
||||
<ViewerControls :node="node as LGraphNode" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isPreview"
|
||||
class="pointer-events-auto absolute right-2 z-20"
|
||||
:class="{
|
||||
'top-24': !enable3DViewer,
|
||||
'top-36': enable3DViewer
|
||||
}"
|
||||
>
|
||||
<RecordingControls
|
||||
v-if="!isPreview"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
@@ -149,7 +142,6 @@ const {
|
||||
isRecording,
|
||||
isPreview,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
@@ -183,7 +175,6 @@ const {
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -18,32 +18,10 @@
|
||||
v-model="fov"
|
||||
:tooltip-text="$t('load3d.fov')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.retainViewOnReload'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.retainViewOnReload')"
|
||||
:aria-pressed="retainViewOnReload"
|
||||
@click="retainViewOnReload = !retainViewOnReload"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi text-lg text-base-foreground',
|
||||
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
@@ -52,9 +30,6 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
|
||||
default: false
|
||||
})
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
|
||||
@@ -258,34 +258,6 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns true when every selected node matches', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
const node1 = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const node2 = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': node1, '1': node2 }
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns false on mixed selection', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
const bypassed = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const active = { id: 2, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': bypassed, '1': active }
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns false for empty selection', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
|
||||
app.canvas.selected_nodes = {}
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('getSelectedNodes should include nodes from subgraphs', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
|
||||
@@ -93,22 +93,6 @@ export function useSelectedLiteGraphItems() {
|
||||
return collectFromNodes(nodeArray)
|
||||
}
|
||||
|
||||
const getSelectedNodesShallow = (): LGraphNode[] =>
|
||||
Object.values(app.canvas.selected_nodes ?? {})
|
||||
|
||||
/**
|
||||
* True iff every selected node is in `mode`. Mirrors the predicate used by
|
||||
* {@link toggleSelectedNodesMode} so labels match the toggle's effect.
|
||||
* An empty selection returns `false` (no node is in the mode).
|
||||
*/
|
||||
const areAllSelectedNodesInMode = (mode: LGraphEventMode): boolean => {
|
||||
const selectedNodeArray = getSelectedNodesShallow()
|
||||
return (
|
||||
selectedNodeArray.length > 0 &&
|
||||
selectedNodeArray.every((node) => node.mode === mode)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the execution mode of all selected nodes
|
||||
*
|
||||
@@ -118,10 +102,18 @@ export function useSelectedLiteGraphItems() {
|
||||
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
|
||||
*/
|
||||
const toggleSelectedNodesMode = (mode: LGraphEventMode): void => {
|
||||
const selectedNodeArray = getSelectedNodesShallow()
|
||||
const newModeForSelectedNode = areAllSelectedNodesInMode(mode)
|
||||
? LGraphEventMode.ALWAYS
|
||||
: mode
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
if (!selectedNodes) return
|
||||
|
||||
// Convert selected_nodes object to array
|
||||
const selectedNodeArray: LGraphNode[] = []
|
||||
for (const i in selectedNodes) {
|
||||
selectedNodeArray.push(selectedNodes[i])
|
||||
}
|
||||
const allNodesMatch = !selectedNodeArray.some(
|
||||
(selectedNode) => selectedNode.mode !== mode
|
||||
)
|
||||
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
|
||||
|
||||
for (const selectedNode of selectedNodeArray)
|
||||
selectedNode.mode = newModeForSelectedNode
|
||||
@@ -134,7 +126,6 @@ export function useSelectedLiteGraphItems() {
|
||||
hasSelectableItems,
|
||||
hasMultipleSelectableItems,
|
||||
getSelectedNodes,
|
||||
areAllSelectedNodesInMode,
|
||||
toggleSelectedNodesMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,51 +137,6 @@ describe('contextMenuConverter', () => {
|
||||
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
|
||||
})
|
||||
|
||||
it('blacklists the legacy Bypass push so Vue supplies the only item', () => {
|
||||
const legacyOptions = convertContextMenuToOptions(
|
||||
[{ content: 'Bypass', callback: () => {} }],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
expect(
|
||||
legacyOptions.find(
|
||||
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
|
||||
)
|
||||
).toBeUndefined()
|
||||
|
||||
const vueBypass: MenuOption = {
|
||||
label: 'Remove Bypass',
|
||||
icon: 'icon-[lucide--redo-dot]',
|
||||
shortcut: 'Ctrl+B',
|
||||
action: () => {},
|
||||
source: 'vue'
|
||||
}
|
||||
const result = buildStructuredMenu([...legacyOptions, vueBypass])
|
||||
|
||||
const bypassItems = result.filter(
|
||||
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
|
||||
)
|
||||
expect(bypassItems).toHaveLength(1)
|
||||
expect(bypassItems[0].source).toBe('vue')
|
||||
expect(bypassItems[0].shortcut).toBe('Ctrl+B')
|
||||
})
|
||||
|
||||
it('does not treat Bypass and Remove Bypass as label equivalents', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Bypass', action: () => {}, source: 'vue' },
|
||||
{ label: 'Remove Bypass', action: () => {}, source: 'litegraph' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
const labels = result
|
||||
.map((opt) => opt.label)
|
||||
.filter((l) => l === 'Bypass' || l === 'Remove Bypass')
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining(['Bypass', 'Remove Bypass'])
|
||||
)
|
||||
})
|
||||
|
||||
it('should recognize Frame Nodes as a core menu item', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Rename', source: 'vue' },
|
||||
|
||||
@@ -21,10 +21,7 @@ const HARD_BLACKLIST = new Set([
|
||||
'Title',
|
||||
'Mode',
|
||||
'Properties Panel',
|
||||
'Copy (Clipspace)',
|
||||
// Vue getBypassOption supplies the single state-aware Bypass/Remove Bypass item
|
||||
'Bypass',
|
||||
'Remove Bypass'
|
||||
'Copy (Clipspace)'
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -211,7 +211,7 @@ export function useMoreOptionsMenu() {
|
||||
}
|
||||
if (!groupContext) {
|
||||
const pin = getPinOption(states, bump)
|
||||
const bypass = getBypassOption(bump)
|
||||
const bypass = getBypassOption(states, bump)
|
||||
options.push(pin)
|
||||
options.push(bypass)
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selected_nodes: null as Record<string, LGraphNode> | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [],
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
colorOptions: [],
|
||||
isLightTheme: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const setSelectedNodes = (nodes: LGraphNode[]) => {
|
||||
const dict: Record<string, LGraphNode> = {}
|
||||
nodes.forEach((n, i) => {
|
||||
dict[String(i)] = n
|
||||
})
|
||||
mockApp.canvas.selected_nodes = dict
|
||||
}
|
||||
|
||||
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode =>
|
||||
({ id, mode }) as LGraphNode
|
||||
|
||||
const getBypassLabel = (): string => {
|
||||
let label = ''
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
const { getBypassOption } = useNodeMenuOptions()
|
||||
label = getBypassOption(() => {}).label ?? ''
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
render(Wrapper, { global: { plugins: [i18n] } })
|
||||
return label
|
||||
}
|
||||
|
||||
describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockApp.canvas.selected_nodes = null
|
||||
})
|
||||
|
||||
it('labels as "Bypass" when no node is bypassed', () => {
|
||||
setSelectedNodes([nodeWithMode(LGraphEventMode.ALWAYS, 1)])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Bypass')
|
||||
})
|
||||
|
||||
it('labels as "Remove Bypass" when every selected node is bypassed', () => {
|
||||
setSelectedNodes([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 2)
|
||||
])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Remove Bypass')
|
||||
})
|
||||
|
||||
it('labels as "Bypass" on mixed selection so it matches the toggle action', () => {
|
||||
setSelectedNodes([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.ALWAYS, 2)
|
||||
])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Bypass')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,6 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeCustomization } from './useNodeCustomization'
|
||||
import { useSelectedNodeActions } from './useSelectedNodeActions'
|
||||
@@ -23,7 +20,6 @@ export function useNodeMenuOptions() {
|
||||
toggleNodeBypass,
|
||||
runBranch
|
||||
} = useSelectedNodeActions()
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
|
||||
const shapeSubmenu = computed(() =>
|
||||
shapeOptions.map((shape) => ({
|
||||
@@ -95,8 +91,11 @@ export function useNodeMenuOptions() {
|
||||
}
|
||||
})
|
||||
|
||||
const getBypassOption = (bump: () => void): MenuOption => ({
|
||||
label: areAllSelectedNodesInMode(LGraphEventMode.BYPASS)
|
||||
const getBypassOption = (
|
||||
states: NodeSelectionState,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: states.bypassed
|
||||
? t('contextMenu.Remove Bypass')
|
||||
: t('contextMenu.Bypass'),
|
||||
icon: 'icon-[lucide--redo-dot]',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -13,6 +13,7 @@ import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
export interface NodeSelectionState {
|
||||
collapsed: boolean
|
||||
pinned: boolean
|
||||
bypassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,10 +78,12 @@ export function useSelectionState() {
|
||||
const computeSelectionStatesFromNodes = (
|
||||
nodes: LGraphNode[]
|
||||
): NodeSelectionState => {
|
||||
if (!nodes.length) return { collapsed: false, pinned: false }
|
||||
if (!nodes.length)
|
||||
return { collapsed: false, pinned: false, bypassed: false }
|
||||
return {
|
||||
collapsed: nodes.some((n) => n.flags?.collapsed),
|
||||
pinned: nodes.some((n) => n.pinned)
|
||||
pinned: nodes.some((n) => n.pinned),
|
||||
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ describe('useLoad3d', () => {
|
||||
setMaterialMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setRetainViewOnReload: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -570,21 +569,17 @@ describe('useLoad3d', () => {
|
||||
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
|
||||
vi.mocked(mockLoad3d.setFOV!).mockClear()
|
||||
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
|
||||
|
||||
composable.cameraConfig.value.cameraType = 'orthographic'
|
||||
composable.cameraConfig.value.fov = 90
|
||||
composable.cameraConfig.value.retainViewOnReload = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
|
||||
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
|
||||
expect(mockNode.properties['Camera Config']).toEqual({
|
||||
cameraType: 'orthographic',
|
||||
fov: 90,
|
||||
state: null,
|
||||
retainViewOnReload: true
|
||||
state: null
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const isSplatModel = ref(false)
|
||||
const isPlyModel = ref(false)
|
||||
const canFitToViewer = ref(true)
|
||||
const canCenterCameraOnModel = ref(false)
|
||||
const canUseGizmo = ref(true)
|
||||
const canUseLighting = ref(true)
|
||||
const canExport = ref(true)
|
||||
@@ -484,7 +483,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Camera Config'] = newValue
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -854,7 +852,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
loading.value = false
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
canCenterCameraOnModel.value = isSplatModel.value || isPlyModel.value
|
||||
const caps = load3d?.getCurrentModelCapabilities()
|
||||
canFitToViewer.value = caps?.fitToViewer ?? true
|
||||
canUseGizmo.value = caps?.gizmoTransform ?? true
|
||||
@@ -973,10 +970,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
syncSceneModels()
|
||||
}
|
||||
|
||||
const handleCenterCameraOnModel = () => {
|
||||
load3d?.centerCameraOnModel()
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
if (load3d) {
|
||||
load3d.resetGizmoTransform()
|
||||
@@ -1017,7 +1010,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
@@ -1053,7 +1045,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
@@ -369,7 +368,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
| LightConfig
|
||||
| undefined
|
||||
|
||||
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
|
||||
isPreview.value = node.type === 'Preview3D'
|
||||
|
||||
if (sceneConfig) {
|
||||
backgroundColor.value =
|
||||
|
||||
@@ -9,17 +9,21 @@ const {
|
||||
waitForLoad3dMock,
|
||||
onLoad3dReadyMock,
|
||||
configureMock,
|
||||
configureForSaveMeshMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock,
|
||||
getNodeByLocatorIdMock
|
||||
getNodeByLocatorIdMock,
|
||||
nodeToLoad3dMap
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
onLoad3dReadyMock: vi.fn(),
|
||||
configureMock: vi.fn(),
|
||||
configureForSaveMeshMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn(),
|
||||
getNodeByLocatorIdMock: vi.fn()
|
||||
getNodeByLocatorIdMock: vi.fn(),
|
||||
nodeToLoad3dMap: new Map<object, unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
@@ -38,12 +42,13 @@ vi.mock('@/composables/useLoad3d', () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap: new Map()
|
||||
nodeToLoad3dMap
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configure = configureMock
|
||||
configureForSaveMesh = configureForSaveMeshMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -121,13 +126,15 @@ type ExtCreated = ComfyExtension & {
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
preview3DAdvancedExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
|
||||
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +160,22 @@ function makePreview3DNode(
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makePreview3DAdvancedNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeLoad3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
@@ -164,7 +187,6 @@ function makeLoad3DNode(
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
|
||||
size: [300, 600],
|
||||
setSize: vi.fn(),
|
||||
addWidget: vi.fn(),
|
||||
widgets: overrides.widgets ?? [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
@@ -179,7 +201,13 @@ interface FakeLoad3d {
|
||||
whenLoadIdle: () => Promise<void>
|
||||
setCameraFromMatrices: ReturnType<typeof vi.fn>
|
||||
setBackgroundImage: ReturnType<typeof vi.fn>
|
||||
setCameraState: ReturnType<typeof vi.fn>
|
||||
getCameraState: ReturnType<typeof vi.fn>
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
getModelInfo: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
cameraManager: { perspectiveCamera: { fov: number } }
|
||||
currentLoadGeneration: number
|
||||
}
|
||||
|
||||
@@ -188,7 +216,13 @@ function makeLoad3dMock(): FakeLoad3d {
|
||||
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
|
||||
setCameraFromMatrices: vi.fn(),
|
||||
setBackgroundImage: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
getCameraState: vi.fn(() => ({ position: [0, 0, 5], target: [0, 0, 0] })),
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
getModelInfo: vi.fn(() => null),
|
||||
applyModelTransform: vi.fn(),
|
||||
isSplatModel: vi.fn(() => false),
|
||||
cameraManager: { perspectiveCamera: { fov: 35 } },
|
||||
currentLoadGeneration: 0
|
||||
}
|
||||
}
|
||||
@@ -199,6 +233,7 @@ async function flush() {
|
||||
|
||||
function setupBaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
@@ -210,12 +245,14 @@ function setupBaseMocks() {
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -610,3 +647,272 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Preview3DAdvanced', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call configureForSaveMesh on creation when no Last Time Model File is persisted', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores via configureForSaveMesh when Last Time Model File is persisted', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
properties: { 'Last Time Model File': 'prev/model.glb' }
|
||||
})
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'prev/model.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches a camera-only serializeValue to the image widget', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(typeof widgets[0].serializeValue).toBe('function')
|
||||
})
|
||||
|
||||
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
const payload = await widgets[0].serializeValue!()
|
||||
|
||||
expect(payload).toEqual({
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: { position: [0, 0, 5], target: [0, 0, 0] },
|
||||
recording: '',
|
||||
model_3d_info: []
|
||||
})
|
||||
})
|
||||
|
||||
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
const modelInfo = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
load3d.getModelInfo = vi.fn(() => modelInfo)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
const payload = (await widgets[0].serializeValue!()) as {
|
||||
model_3d_info: unknown[]
|
||||
}
|
||||
|
||||
expect(payload.model_3d_info).toEqual([modelInfo])
|
||||
})
|
||||
|
||||
it('onExecuted persists Last Time Model File with normalized slashes and calls configureForSaveMesh', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'sub/nested/mesh.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('onExecuted applies the input cameraState when one is forwarded via PreviewUI3D', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const cameraState = { position: [1, 2, 3] }
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', cameraState] })
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).toHaveBeenCalledWith(cameraState)
|
||||
})
|
||||
|
||||
it('onExecuted applies the first model_3d_info entry to the viewport when present', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const transform = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, [transform]]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
})
|
||||
|
||||
it('onExecuted does not call applyModelTransform when model_3d_info is empty', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, []]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.applyModelTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted defensively skips cameraState apply when result[1] is missing', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb'] })
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted skips cameraState apply when load3d generation changes before whenLoadIdle resolves', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
let resolveIdle: () => void = () => {}
|
||||
load3d.whenLoadIdle = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveIdle = resolve
|
||||
})
|
||||
)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', { position: [1, 2, 3] }] })
|
||||
|
||||
load3d.currentLoadGeneration = 6
|
||||
resolveIdle()
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted shows an error toast when no file path is returned', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: [] })
|
||||
|
||||
expect(toastAddAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.unableToGetModelFilePath'
|
||||
)
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('returns [] for non-Preview3DAdvanced nodes', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when no load3d instance exists for the node', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue(null)
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for splat models', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns export menu items for non-splat models', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([
|
||||
{ content: 'Export' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ type Matrix = number[][]
|
||||
type Load3dPreviewOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, string?, Matrix?, Matrix?]
|
||||
}>
|
||||
type Preview3DAdvancedOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, Model3DInfo?]
|
||||
}>
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ComfyApp, app } from '@/scripts/app'
|
||||
@@ -219,15 +222,13 @@ useExtensionService().registerExtension({
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Load3D.PLYEngine',
|
||||
category: ['3D', 'PointCloud', 'Point Cloud Engine'],
|
||||
name: 'Point Cloud Engine',
|
||||
category: ['3D', 'PLY', 'PLY Engine'],
|
||||
name: 'PLY Engine',
|
||||
tooltip:
|
||||
'Select the engine for loading point cloud PLY files. "threejs" uses the native Three.js PLYLoader (handles binary + ASCII, mesh-capable). "fastply" uses an optimized parser for ASCII PLY files. 3D Gaussian Splat PLYs are detected automatically and always rendered via sparkjs regardless of this setting.',
|
||||
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
|
||||
type: 'combo',
|
||||
options: ['threejs', 'fastply'],
|
||||
options: ['threejs', 'fastply', 'sparkjs'],
|
||||
defaultValue: 'threejs',
|
||||
migrateDeprecatedValue: (value) =>
|
||||
value === 'sparkjs' ? 'threejs' : value,
|
||||
experimental: true
|
||||
}
|
||||
],
|
||||
@@ -651,3 +652,130 @@ useExtensionService().registerExtension({
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAdvanced',
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraState = result?.[1]
|
||||
const modelTransform = result?.[2]?.[0]
|
||||
if (cameraState || modelTransform) {
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -287,6 +287,41 @@ describe('GizmoManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyModelTransform', () => {
|
||||
it('sets position, quaternion, and scale on target and notifies', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
manager.applyModelTransform({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.92 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
})
|
||||
|
||||
expect(model.position.x).toBeCloseTo(1)
|
||||
expect(model.position.y).toBeCloseTo(2)
|
||||
expect(model.position.z).toBeCloseTo(3)
|
||||
expect(model.quaternion.x).toBeCloseTo(0.1)
|
||||
expect(model.quaternion.y).toBeCloseTo(0.2)
|
||||
expect(model.quaternion.z).toBeCloseTo(0.3)
|
||||
expect(model.quaternion.w).toBeCloseTo(0.92)
|
||||
expect(model.scale.x).toBeCloseTo(2)
|
||||
expect(onTransformChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does nothing without a target', () => {
|
||||
manager.init()
|
||||
expect(() =>
|
||||
manager.applyModelTransform({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransform', () => {
|
||||
it('returns current target transform', () => {
|
||||
manager.init()
|
||||
|
||||
@@ -159,6 +159,27 @@ export class GizmoManager {
|
||||
}
|
||||
}
|
||||
|
||||
applyModelTransform(transform: Model3DTransform): void {
|
||||
if (!this.targetObject) return
|
||||
this.targetObject.position.set(
|
||||
transform.position.x,
|
||||
transform.position.y,
|
||||
transform.position.z
|
||||
)
|
||||
this.targetObject.quaternion.set(
|
||||
transform.quaternion.x,
|
||||
transform.quaternion.y,
|
||||
transform.quaternion.z,
|
||||
transform.quaternion.w
|
||||
)
|
||||
this.targetObject.scale.set(
|
||||
transform.scale.x,
|
||||
transform.scale.y,
|
||||
transform.scale.z
|
||||
)
|
||||
this.onTransformChange?.()
|
||||
}
|
||||
|
||||
getInitialTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
|
||||
@@ -39,6 +39,7 @@ type GizmoStub = {
|
||||
setMode: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
applyTransform: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
getTransform: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
updateCamera: ReturnType<typeof vi.fn>
|
||||
@@ -73,6 +74,7 @@ function makeGizmoStub(): GizmoStub {
|
||||
setMode: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
applyTransform: vi.fn(),
|
||||
applyModelTransform: vi.fn(),
|
||||
getTransform: vi.fn(() => ({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
@@ -203,6 +205,19 @@ describe('Load3d', () => {
|
||||
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
|
||||
})
|
||||
|
||||
it('applyModelTransform forwards the full position/quaternion/scale payload', () => {
|
||||
const transform = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.4 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
|
||||
ctx.load3d.applyModelTransform(transform)
|
||||
|
||||
expect(ctx.gizmo.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('getGizmoTransform returns the gizmoManager transform', () => {
|
||||
const transform = {
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
@@ -772,8 +787,8 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('retainViewOnReload', () => {
|
||||
function setupLoadInternal(initialFlag: boolean) {
|
||||
describe('camera framing across reloads', () => {
|
||||
function setupLoadInternal() {
|
||||
const getCameraState = vi.fn<() => CameraState>(() => ({
|
||||
position: new THREE.Vector3(1, 2, 3),
|
||||
target: new THREE.Vector3(),
|
||||
@@ -802,25 +817,23 @@ describe('Load3d', () => {
|
||||
setupModelAnimations: vi.fn()
|
||||
},
|
||||
handleResize: vi.fn(),
|
||||
retainViewOnReload: initialFlag,
|
||||
hasLoadedModel: false
|
||||
})
|
||||
return { getCameraState, setCameraState, getCurrentCameraType }
|
||||
}
|
||||
|
||||
it('first load uses default framing even with retain enabled', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
it('first load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
|
||||
// hasLoadedModel started false, so retain shouldn't kick in yet.
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('subsequent load captures camera state, skips reset, and restores it', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
it('subsequent load preserves the user-adjusted camera framing', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
@@ -834,23 +847,8 @@ describe('Load3d', () => {
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not retain when the flag is off, even after a prior load', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles to the saved camera type before restoring state when types differ', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
const mocks = setupLoadInternal()
|
||||
mocks.getCameraState.mockImplementation(() => ({
|
||||
position: new THREE.Vector3(0, 0, 5),
|
||||
target: new THREE.Vector3(),
|
||||
@@ -870,7 +868,7 @@ describe('Load3d', () => {
|
||||
})
|
||||
|
||||
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
const mocks = setupLoadInternal()
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.clearModel()
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
@@ -881,22 +879,6 @@ describe('Load3d', () => {
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.setRetainViewOnReload(true)
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
|
||||
expect(mocks.getCameraState).toHaveBeenCalledOnce()
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
|
||||
@@ -105,7 +105,6 @@ class Load3d {
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
private retainViewOnReload: boolean = false
|
||||
private hasLoadedModel: boolean = false
|
||||
|
||||
constructor(
|
||||
@@ -161,23 +160,11 @@ class Load3d {
|
||||
this.handleResize()
|
||||
this.startAnimation()
|
||||
|
||||
this.eventManager.addEventListener('modelReady', () => {
|
||||
if (this.adapterRef.current?.kind !== 'splat') return
|
||||
void this.repaintWhenSparkPaintable()
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
this.forceRender()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
private async repaintWhenSparkPaintable(): Promise<void> {
|
||||
const sortComplete = this.sceneManager.awaitNextSparkDirty()
|
||||
this.forceRender()
|
||||
await sortComplete
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
private initResizeObserver(container: Element | HTMLElement): void {
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
|
||||
@@ -579,17 +566,14 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
public setRetainViewOnReload(value: boolean): void {
|
||||
this.retainViewOnReload = value
|
||||
}
|
||||
|
||||
private async _loadModelInternal(
|
||||
url: string,
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
// First load always uses default framing; retain only applies on reload.
|
||||
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
|
||||
// First load always uses default framing; subsequent reloads preserve
|
||||
// the user's framing.
|
||||
const shouldRetainView = this.hasLoadedModel
|
||||
const savedCameraState = shouldRetainView
|
||||
? this.cameraManager.getCameraState()
|
||||
: null
|
||||
@@ -638,7 +622,7 @@ class Load3d {
|
||||
}
|
||||
|
||||
getCurrentModelCapabilities(): ModelAdapterCapabilities {
|
||||
return this.adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES
|
||||
return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
@@ -919,6 +903,12 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public applyModelTransform(transform: Model3DTransform): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.applyModelTransform(transform)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public getGizmoTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
@@ -936,22 +926,6 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public centerCameraOnModel(): void {
|
||||
const bounds = this.modelManager.getCurrentBounds()
|
||||
if (!bounds || bounds.isEmpty()) return
|
||||
|
||||
const center = bounds.getCenter(new THREE.Vector3())
|
||||
const camera = this.cameraManager.activeCamera
|
||||
const controls = this.controlsManager.controls
|
||||
const offset = center.clone().sub(camera.position)
|
||||
|
||||
camera.position.add(offset)
|
||||
controls.target.add(offset)
|
||||
camera.updateMatrixWorld(true)
|
||||
controls.update()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
|
||||
@@ -7,11 +7,7 @@ import type {
|
||||
ModelManagerInterface
|
||||
} from './interfaces'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
|
||||
function makeEventManagerStub() {
|
||||
return {
|
||||
@@ -32,12 +28,6 @@ type ModelManagerStub = {
|
||||
originalURL: string | null
|
||||
}
|
||||
|
||||
const STUB_CAPS = {} as ModelAdapterCapabilities
|
||||
const loadResult = (object: THREE.Object3D) => ({
|
||||
object,
|
||||
capabilities: STUB_CAPS
|
||||
})
|
||||
|
||||
function makeModelManagerStub(): ModelManagerStub {
|
||||
return {
|
||||
clearModel: vi.fn(),
|
||||
@@ -51,21 +41,14 @@ function makeModelManagerStub(): ModelManagerStub {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
meshLoad,
|
||||
splatLoad,
|
||||
pointCloudLoad,
|
||||
fetchModelDataMock,
|
||||
isGaussianSplatPLYMock,
|
||||
addAlert
|
||||
} = vi.hoisted(() => ({
|
||||
meshLoad: vi.fn(),
|
||||
splatLoad: vi.fn(),
|
||||
pointCloudLoad: vi.fn(),
|
||||
fetchModelDataMock: vi.fn<() => Promise<ArrayBuffer>>(),
|
||||
isGaussianSplatPLYMock: vi.fn<(b: ArrayBuffer) => Promise<boolean>>(),
|
||||
addAlert: vi.fn()
|
||||
}))
|
||||
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
|
||||
vi.hoisted(() => ({
|
||||
meshLoad: vi.fn(),
|
||||
splatLoad: vi.fn(),
|
||||
pointCloudLoad: vi.fn(),
|
||||
getPLYEngineMock: vi.fn<() => string>(),
|
||||
addAlert: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./MeshModelAdapter', () => ({
|
||||
MeshModelAdapter: class {
|
||||
@@ -82,35 +65,19 @@ vi.mock('./PointCloudModelAdapter', () => ({
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities = {}
|
||||
load = pointCloudLoad
|
||||
}
|
||||
},
|
||||
getPLYEngine: () => getPLYEngineMock()
|
||||
}))
|
||||
|
||||
vi.mock('./SplatModelAdapter', () => ({
|
||||
SplatModelAdapter: class {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities = {}
|
||||
matches = async (
|
||||
ext: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<boolean> => {
|
||||
if (ext !== 'ply') return true
|
||||
return isGaussianSplatPLYMock(await fetchBytes())
|
||||
}
|
||||
load = splatLoad
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./ModelAdapter', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./ModelAdapter')>('./ModelAdapter')
|
||||
return { ...actual, fetchModelData: fetchModelDataMock }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/metadata/ply', () => ({
|
||||
isGaussianSplatPLY: isGaussianSplatPLYMock
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
@@ -120,10 +87,7 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
}))
|
||||
|
||||
type LoaderManagerInternals = {
|
||||
pickAdapter(
|
||||
extension: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelAdapter | null>
|
||||
pickAdapter(extension: string): ModelAdapter | null
|
||||
}
|
||||
|
||||
function makeLoaderManager() {
|
||||
@@ -134,21 +98,21 @@ function makeLoaderManager() {
|
||||
eventManager
|
||||
)
|
||||
const internals = lm as unknown as LoaderManagerInternals
|
||||
const pick = (ext: string) =>
|
||||
internals.pickAdapter.call(lm, ext, () =>
|
||||
fetchModelDataMock()
|
||||
) as Promise<ModelAdapter | null>
|
||||
return { lm, modelManager, eventManager, pick }
|
||||
return {
|
||||
lm,
|
||||
modelManager,
|
||||
eventManager,
|
||||
pick: internals.pickAdapter.bind(lm)
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoaderManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
meshLoad.mockResolvedValue(null)
|
||||
splatLoad.mockResolvedValue(null)
|
||||
pointCloudLoad.mockResolvedValue(null)
|
||||
fetchModelDataMock.mockResolvedValue(new ArrayBuffer(0))
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
describe('getCurrentAdapter', () => {
|
||||
@@ -159,7 +123,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('exposes the picked adapter after a successful load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
@@ -168,7 +132,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('resets to null at the start of a new load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
@@ -180,7 +144,7 @@ describe('LoaderManager', () => {
|
||||
it('stays null when the adapter rejects (does not publish stale adapter)', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
|
||||
@@ -231,10 +195,7 @@ describe('LoaderManager', () => {
|
||||
}
|
||||
|
||||
let adapterDuringClear: ModelAdapter | null | undefined
|
||||
const adapterRef = {
|
||||
current: oldAdapter as ModelAdapter | null,
|
||||
capabilities: oldAdapter.capabilities as ModelAdapterCapabilities | null
|
||||
}
|
||||
const adapterRef = { current: oldAdapter as ModelAdapter | null }
|
||||
const lm = new LoaderManager(
|
||||
modelManager,
|
||||
eventManager,
|
||||
@@ -262,8 +223,8 @@ describe('LoaderManager', () => {
|
||||
const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => {
|
||||
resolveSplatLoad = resolve
|
||||
})
|
||||
splatLoad.mockReturnValueOnce(slowSplatLoad.then(loadResult))
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
splatLoad.mockReturnValueOnce(slowSplatLoad)
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
const aPromise = lm.loadModel('api/view?filename=a.splat')
|
||||
|
||||
@@ -282,36 +243,42 @@ describe('LoaderManager', () => {
|
||||
describe('pickAdapter', () => {
|
||||
it.for(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
|
||||
'routes %s to the mesh adapter',
|
||||
async (ext) => {
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect((await pick(ext))?.kind).toBe('mesh')
|
||||
expect(pick(ext)?.kind).toBe('mesh')
|
||||
}
|
||||
)
|
||||
|
||||
it.for(['spz', 'splat', 'ksplat'])(
|
||||
'routes %s to the splat adapter',
|
||||
async (ext) => {
|
||||
(ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect((await pick(ext))?.kind).toBe('splat')
|
||||
expect(pick(ext)?.kind).toBe('splat')
|
||||
}
|
||||
)
|
||||
|
||||
it('routes .ply to the splat adapter when the bytes look like 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(true)
|
||||
it('routes .ply to the point-cloud adapter for the default three engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect((await pick('ply'))?.kind).toBe('splat')
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('falls back to the point-cloud adapter for .ply that is not 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('fastply')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect((await pick('ply'))?.kind).toBe('pointCloud')
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('returns null for unknown extensions', async () => {
|
||||
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(await pick('xyz')).toBeNull()
|
||||
expect(await pick('')).toBeNull()
|
||||
expect(pick('ply')?.kind).toBe('splat')
|
||||
})
|
||||
|
||||
it('returns null for unknown extensions', () => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('xyz')).toBeNull()
|
||||
expect(pick('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -381,7 +348,7 @@ describe('LoaderManager', () => {
|
||||
it('passes setupModel the object returned by the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const loaded = new THREE.Object3D()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(loaded))
|
||||
meshLoad.mockResolvedValueOnce(loaded)
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
@@ -399,7 +366,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('emits modelLoadingEnd when the load completes', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
@@ -411,7 +378,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('forwards a decoded path and filename to the adapter', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel(
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
|
||||
@@ -423,105 +390,32 @@ describe('LoaderManager', () => {
|
||||
registerOriginalMaterial: expect.any(Function)
|
||||
}),
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=',
|
||||
'cube.glb',
|
||||
expect.any(Function)
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the path to type=input when no type param is given', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(meshLoad).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'api/view?type=input&subfolder=&filename=',
|
||||
'cube.glb',
|
||||
expect.any(Function)
|
||||
'cube.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes .ply to the point-cloud adapter when the header does not look like 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
const { lm } = makeLoaderManager()
|
||||
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(pointCloudLoad).toHaveBeenCalled()
|
||||
expect(splatLoad).not.toHaveBeenCalled()
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('reroutes .ply through the splat adapter when the header looks like 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(true)
|
||||
const { lm } = makeLoaderManager()
|
||||
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(splatLoad).toHaveBeenCalled()
|
||||
expect(pointCloudLoad).not.toHaveBeenCalled()
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('splat')
|
||||
})
|
||||
|
||||
it('shares a single fetch between matches() and load() so .ply is not re-downloaded', async () => {
|
||||
const buf = new ArrayBuffer(16)
|
||||
fetchModelDataMock.mockResolvedValueOnce(buf)
|
||||
isGaussianSplatPLYMock.mockResolvedValue(true)
|
||||
const { lm } = makeLoaderManager()
|
||||
splatLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
// Adapter receives a fetchBytes function (memoized), not bytes directly.
|
||||
expect(splatLoad).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(String),
|
||||
'scan.ply',
|
||||
expect.any(Function)
|
||||
)
|
||||
// matches() called fetchBytes once; load()'s call hit the cached promise.
|
||||
expect(fetchModelDataMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispatches .ply via the adapter matches() tiebreaker, not extension order — a splat adapter whose matches() returns false yields to point-cloud', async () => {
|
||||
const modelManager =
|
||||
makeModelManagerStub() as unknown as ConstructorParameters<
|
||||
typeof LoaderManager
|
||||
>[0]
|
||||
const eventManager = makeEventManagerStub()
|
||||
// A splat adapter that ALSO claims '.ply' and is listed first.
|
||||
// Without matches(), it would short-circuit. With matches() returning
|
||||
// false (not a 3DGS PLY), the dispatcher must skip to the next
|
||||
// candidate (point cloud).
|
||||
const splatAdapter = {
|
||||
kind: 'splat' as const,
|
||||
extensions: ['ply', 'spz', 'splat', 'ksplat'] as const,
|
||||
capabilities: {} as never,
|
||||
matches: async (ext: string, fetchBytes: () => Promise<ArrayBuffer>) =>
|
||||
ext === 'ply' ? isGaussianSplatPLYMock(await fetchBytes()) : true,
|
||||
load: splatLoad
|
||||
}
|
||||
const pointCloudAdapter = {
|
||||
kind: 'pointCloud' as const,
|
||||
extensions: ['ply'] as const,
|
||||
capabilities: {} as never,
|
||||
load: pointCloudLoad
|
||||
}
|
||||
const lm = new LoaderManager(modelManager, eventManager, [
|
||||
splatAdapter,
|
||||
pointCloudAdapter
|
||||
])
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
pointCloudLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=scan.ply')
|
||||
|
||||
expect(pointCloudLoad).toHaveBeenCalled()
|
||||
expect(splatLoad).not.toHaveBeenCalled()
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('handles adapter errors by alerting and still emitting modelLoadingEnd', async () => {
|
||||
@@ -604,8 +498,8 @@ describe('LoaderManager', () => {
|
||||
secondModel.name = 'second'
|
||||
|
||||
meshLoad
|
||||
.mockImplementationOnce(() => firstLoad.then(loadResult))
|
||||
.mockResolvedValueOnce(loadResult(secondModel))
|
||||
.mockImplementationOnce(() => firstLoad)
|
||||
.mockResolvedValueOnce(secondModel)
|
||||
|
||||
const firstPromise = lm.loadModel('api/view?filename=first.glb')
|
||||
const secondPromise = lm.loadModel('api/view?filename=second.glb')
|
||||
|
||||
@@ -4,14 +4,9 @@ import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import { createAdapterRef, fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
AdapterRef,
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
|
||||
import { createAdapterRef } from './ModelAdapter'
|
||||
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
import type {
|
||||
EventManagerInterface,
|
||||
@@ -41,16 +36,14 @@ function isNotFoundError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default adapter set: mesh + splat + pointCloud. Each adapter declares the
|
||||
* file extensions it owns. For shared extensions (.ply), the adapter with an
|
||||
* async `matches()` tiebreaker is tried first; the unconditional adapter acts
|
||||
* as the fallback — so SplatModelAdapter precedes PointCloudModelAdapter.
|
||||
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
|
||||
* file extensions it owns; LoaderManager picks one by extension.
|
||||
*/
|
||||
function defaultAdapters(): ModelAdapter[] {
|
||||
return [
|
||||
new MeshModelAdapter(),
|
||||
new SplatModelAdapter(),
|
||||
new PointCloudModelAdapter()
|
||||
new PointCloudModelAdapter(),
|
||||
new SplatModelAdapter()
|
||||
]
|
||||
}
|
||||
|
||||
@@ -93,7 +86,6 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
|
||||
this.modelManager.clearModel()
|
||||
this.adapterRef.current = null
|
||||
this.adapterRef.capabilities = null
|
||||
|
||||
this.modelManager.originalURL = url
|
||||
|
||||
@@ -130,8 +122,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
// can't clobber adapterRef.current that a newer load already
|
||||
// wrote (or cleared).
|
||||
this.adapterRef.current = result.adapter
|
||||
this.adapterRef.capabilities = result.capabilities
|
||||
await this.modelManager.setupModel(result.object)
|
||||
await this.modelManager.setupModel(result.model)
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
@@ -146,18 +137,19 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private async pickAdapter(
|
||||
extension: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelAdapter | null> {
|
||||
const candidates = this.adapters.filter((a) =>
|
||||
a.extensions.includes(extension)
|
||||
private pickAdapter(extension: string): ModelAdapter | null {
|
||||
const match = this.adapters.find((adapter) =>
|
||||
adapter.extensions.includes(extension)
|
||||
)
|
||||
for (const adapter of candidates) {
|
||||
if (!adapter.matches) return adapter
|
||||
if (await adapter.matches(extension, fetchBytes)) return adapter
|
||||
if (!match) return null
|
||||
|
||||
// PLY may be routed through the splat adapter when the PLYEngine setting
|
||||
// is sparkjs. Only honor the routing when both adapters are registered.
|
||||
if (match.kind === 'pointCloud' && getPLYEngine() === 'sparkjs') {
|
||||
const splat = this.adapters.find((adapter) => adapter.kind === 'splat')
|
||||
if (splat) return splat
|
||||
}
|
||||
return null
|
||||
return match
|
||||
}
|
||||
|
||||
private createLoadContext(): ModelLoadContext {
|
||||
@@ -178,11 +170,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
private async loadModelInternal(
|
||||
url: string,
|
||||
fileExtension: string
|
||||
): Promise<{
|
||||
object: THREE.Object3D
|
||||
adapter: ModelAdapter
|
||||
capabilities: ModelAdapterCapabilities
|
||||
} | null> {
|
||||
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
|
||||
const params = new URLSearchParams(url.split('?')[1])
|
||||
const filename = params.get('filename')
|
||||
|
||||
@@ -200,24 +188,10 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
encodeURIComponent(subfolder) +
|
||||
'&filename='
|
||||
|
||||
let bytesPromise: Promise<ArrayBuffer> | null = null
|
||||
const fetchBytes = () => (bytesPromise ??= fetchModelData(path, filename))
|
||||
|
||||
const adapter = await this.pickAdapter(fileExtension, fetchBytes)
|
||||
const adapter = this.pickAdapter(fileExtension)
|
||||
if (!adapter) return null
|
||||
|
||||
const loadResult = await adapter.load(
|
||||
this.createLoadContext(),
|
||||
path,
|
||||
filename,
|
||||
fetchBytes
|
||||
)
|
||||
return loadResult
|
||||
? {
|
||||
object: loadResult.object,
|
||||
capabilities: loadResult.capabilities,
|
||||
adapter
|
||||
}
|
||||
: null
|
||||
const model = await adapter.load(this.createLoadContext(), path, filename)
|
||||
return model ? { model, adapter } : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@ describe('MeshModelAdapter', () => {
|
||||
expect(stlLoaderStub.setPath).toHaveBeenCalledWith('/api/view/')
|
||||
expect(stlLoaderStub.loadAsync).toHaveBeenCalledWith('model.stl')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(geometry)
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.object.children[0]).toBeInstanceOf(THREE.Mesh)
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('MeshModelAdapter', () => {
|
||||
expect(fbxLoaderStub.loadAsync).toHaveBeenCalledWith('rig.fbx')
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(fbxModel)
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result!.object).toBe(fbxModel)
|
||||
expect(result).toBe(fbxModel)
|
||||
})
|
||||
|
||||
it('disables frustum culling on SkinnedMesh children', async () => {
|
||||
@@ -224,7 +224,7 @@ describe('MeshModelAdapter', () => {
|
||||
'cube.obj'
|
||||
)
|
||||
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(objLoaderStub.setMaterials).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -271,7 +271,7 @@ describe('MeshModelAdapter', () => {
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(gltf)
|
||||
expect(computeNormals).toHaveBeenCalled()
|
||||
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
|
||||
expect(result!.object).toBe(scene)
|
||||
expect(result).toBe(scene)
|
||||
})
|
||||
|
||||
it('also handles .gltf filenames', async () => {
|
||||
|
||||
@@ -11,8 +11,7 @@ import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext,
|
||||
ModelLoadResult
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class MeshModelAdapter implements ModelAdapter {
|
||||
@@ -46,18 +45,20 @@ export class MeshModelAdapter implements ModelAdapter {
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<ModelLoadResult | null> {
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
const object = await (extension === 'stl'
|
||||
? this.loadSTL(ctx, path, filename)
|
||||
: extension === 'fbx'
|
||||
? this.loadFBX(ctx, path, filename)
|
||||
: extension === 'obj'
|
||||
? this.loadOBJ(ctx, path, filename)
|
||||
: extension === 'gltf' || extension === 'glb'
|
||||
? this.loadGLTF(ctx, path, filename)
|
||||
: Promise.resolve(null))
|
||||
return object ? { object, capabilities: this.capabilities } : null
|
||||
switch (extension) {
|
||||
case 'stl':
|
||||
return this.loadSTL(ctx, path, filename)
|
||||
case 'fbx':
|
||||
return this.loadFBX(ctx, path, filename)
|
||||
case 'obj':
|
||||
return this.loadOBJ(ctx, path, filename)
|
||||
case 'gltf':
|
||||
case 'glb':
|
||||
return this.loadGLTF(ctx, path, filename)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async loadSTL(
|
||||
|
||||
@@ -65,59 +65,24 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by `ModelAdapter.load()`. Capabilities ride with the model
|
||||
* because some adapters (notably PLY) produce different capability sets
|
||||
* depending on the file contents — face-less point clouds expose only the
|
||||
* 'pointCloud' material mode, indexed meshes expose the full set. Keeping
|
||||
* capabilities per-load (not per-adapter) prevents stale state on the
|
||||
* adapter instance between two successive loads.
|
||||
* Mutable handle to the currently active ModelAdapter. A single ref is
|
||||
* created in `createLoad3d` and shared between LoaderManager (writer) and
|
||||
* SceneModelManager + Load3d (readers), so capability/bounds/dispose lookups
|
||||
* don't depend on construction order between those collaborators.
|
||||
*/
|
||||
export type ModelLoadResult = {
|
||||
object: THREE.Object3D
|
||||
capabilities: ModelAdapterCapabilities
|
||||
}
|
||||
export type AdapterRef = { current: ModelAdapter | null }
|
||||
|
||||
/**
|
||||
* Mutable handle to the currently active ModelAdapter plus the capabilities
|
||||
* reported by its most recent load. A single ref is created in `createLoad3d`
|
||||
* and shared between LoaderManager (writer) and SceneModelManager + Load3d
|
||||
* (readers), so capability/bounds/dispose lookups don't depend on
|
||||
* construction order between those collaborators.
|
||||
*/
|
||||
export type AdapterRef = {
|
||||
current: ModelAdapter | null
|
||||
capabilities: ModelAdapterCapabilities | null
|
||||
}
|
||||
|
||||
export const createAdapterRef = (): AdapterRef => ({
|
||||
current: null,
|
||||
capabilities: null
|
||||
})
|
||||
export const createAdapterRef = (): AdapterRef => ({ current: null })
|
||||
|
||||
export interface ModelAdapter {
|
||||
readonly kind: ModelAdapterKind
|
||||
readonly extensions: readonly string[]
|
||||
/**
|
||||
* Default capabilities for this adapter family. `load()` may return a
|
||||
* narrowed set for a specific model — read `adapterRef.capabilities` for
|
||||
* the live per-model value rather than this.
|
||||
*/
|
||||
readonly capabilities: ModelAdapterCapabilities
|
||||
/**
|
||||
* Async tiebreaker when multiple adapters claim the same extension
|
||||
* (e.g. .ply is shared by Gaussian splats and classic point clouds).
|
||||
* Adapters that uniquely own their extensions can omit this.
|
||||
*/
|
||||
matches?(
|
||||
extension: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<boolean>
|
||||
load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string,
|
||||
fetchBytes?: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelLoadResult | null>
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null>
|
||||
/**
|
||||
* Optional. Return a world-space AABB for the given model. Adapters for
|
||||
* renderers whose geometry is not walked by `Box3.setFromObject` (e.g.
|
||||
|
||||
@@ -15,40 +15,31 @@ vi.mock('@/scripts/metadata/ply', () => ({
|
||||
isPLYAsciiFormat: vi.fn().mockReturnValue(false)
|
||||
}))
|
||||
|
||||
const plyLoaderParse = vi.fn(() => makePLYGeometry({ withFaces: true }))
|
||||
const fastPlyLoaderParse = vi.fn(() => makePLYGeometry({ withFaces: true }))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/PLYLoader', () => ({
|
||||
PLYLoader: class {
|
||||
setPath = vi.fn()
|
||||
parse = plyLoaderParse
|
||||
parse = vi.fn(() => makePLYGeometry(false))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./loader/FastPLYLoader', () => ({
|
||||
FastPLYLoader: class {
|
||||
parse = fastPlyLoaderParse
|
||||
parse = vi.fn(() => makePLYGeometry(false))
|
||||
}
|
||||
}))
|
||||
|
||||
function makePLYGeometry(opts: {
|
||||
withColors?: boolean
|
||||
withFaces?: boolean
|
||||
}): THREE.BufferGeometry {
|
||||
function makePLYGeometry(withColors: boolean): THREE.BufferGeometry {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3)
|
||||
)
|
||||
if (opts.withColors) {
|
||||
if (withColors) {
|
||||
geometry.setAttribute(
|
||||
'color',
|
||||
new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3)
|
||||
)
|
||||
}
|
||||
if (opts.withFaces) {
|
||||
geometry.setIndex([0, 1, 2])
|
||||
}
|
||||
return geometry
|
||||
}
|
||||
|
||||
@@ -105,8 +96,8 @@ describe('PointCloudModelAdapter', () => {
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.object.children[0]
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Mesh)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -117,57 +108,9 @@ describe('PointCloudModelAdapter', () => {
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.object.children[0]
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Points)
|
||||
})
|
||||
|
||||
it('forces Points rendering for a face-less PLY even on materialMode=original', async () => {
|
||||
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: false }))
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
const ctx = makeContext('original')
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
const child = result!.object.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Points)
|
||||
})
|
||||
|
||||
it('returns narrowed materialModes capability for a face-less PLY', async () => {
|
||||
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: false }))
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
|
||||
const result = await adapter.load(
|
||||
makeContext('original'),
|
||||
'/api/view?',
|
||||
'cloud.ply'
|
||||
)
|
||||
|
||||
expect([...result!.capabilities.materialModes]).toEqual(['pointCloud'])
|
||||
})
|
||||
|
||||
it('returns full materialModes capability for a face-bearing PLY (independent of prior loads)', async () => {
|
||||
const adapter = new PointCloudModelAdapter()
|
||||
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: false }))
|
||||
const faceless = await adapter.load(
|
||||
makeContext('original'),
|
||||
'/api/view?',
|
||||
'cloud.ply'
|
||||
)
|
||||
expect([...faceless!.capabilities.materialModes]).toEqual(['pointCloud'])
|
||||
|
||||
plyLoaderParse.mockReturnValueOnce(makePLYGeometry({ withFaces: true }))
|
||||
const faceful = await adapter.load(
|
||||
makeContext('original'),
|
||||
'/api/view?',
|
||||
'mesh.ply'
|
||||
)
|
||||
expect([...faceful!.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'pointCloud',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,30 +8,27 @@ import { fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext,
|
||||
ModelLoadResult
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import type { MaterialMode } from './interfaces'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
function getPLYEngine(): string {
|
||||
export function getPLYEngine(): string {
|
||||
return useSettingStore().get('Comfy.Load3D.PLYEngine') as string
|
||||
}
|
||||
|
||||
const POINT_CLOUD_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: true,
|
||||
gizmoTransform: false,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
export class PointCloudModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'pointCloud' as const
|
||||
readonly extensions = ['ply'] as const
|
||||
readonly capabilities = POINT_CLOUD_CAPABILITIES
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: true,
|
||||
gizmoTransform: false,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
private readonly plyLoader = new PLYLoader()
|
||||
private readonly fastPlyLoader = new FastPLYLoader()
|
||||
@@ -39,10 +36,9 @@ export class PointCloudModelAdapter implements ModelAdapter {
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string,
|
||||
fetchBytes?: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelLoadResult | null> {
|
||||
const arrayBuffer = await (fetchBytes?.() ?? fetchModelData(path, filename))
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
const isASCII = isPLYAsciiFormat(arrayBuffer)
|
||||
|
||||
const plyGeometry =
|
||||
@@ -54,18 +50,12 @@ export class PointCloudModelAdapter implements ModelAdapter {
|
||||
plyGeometry.computeVertexNormals()
|
||||
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
const hasFaces = (plyGeometry.index?.count ?? 0) > 0
|
||||
|
||||
const object =
|
||||
ctx.materialMode === 'pointCloud' || !hasFaces
|
||||
? buildPointsGroup(ctx, plyGeometry, hasVertexColors)
|
||||
: buildMeshGroup(ctx, plyGeometry, hasVertexColors)
|
||||
if (ctx.materialMode === 'pointCloud') {
|
||||
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
|
||||
}
|
||||
|
||||
const capabilities = hasFaces
|
||||
? POINT_CLOUD_CAPABILITIES
|
||||
: { ...POINT_CLOUD_CAPABILITIES, materialModes: ['pointCloud'] as const }
|
||||
|
||||
return { object, capabilities }
|
||||
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,17 +14,6 @@ export class SceneManager implements SceneManagerInterface {
|
||||
gridHelper: THREE.GridHelper
|
||||
private sparkRenderer: SparkRenderer
|
||||
|
||||
private nextSparkDirtyPromise: Promise<void> | null = null
|
||||
private nextSparkDirtyResolve: (() => void) | null = null
|
||||
|
||||
awaitNextSparkDirty(): Promise<void> {
|
||||
if (this.nextSparkDirtyPromise) return this.nextSparkDirtyPromise
|
||||
this.nextSparkDirtyPromise = new Promise<void>((resolve) => {
|
||||
this.nextSparkDirtyResolve = resolve
|
||||
})
|
||||
return this.nextSparkDirtyPromise
|
||||
}
|
||||
|
||||
backgroundScene!: THREE.Scene
|
||||
backgroundCamera: THREE.OrthographicCamera
|
||||
backgroundMesh: THREE.Mesh | null = null
|
||||
@@ -56,23 +45,9 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.getActiveCamera = getActiveCamera
|
||||
|
||||
// Spark 2.x requires a SparkRenderer in the scene tree to render SplatMesh
|
||||
// instances; without it splats are silent no-ops.
|
||||
//
|
||||
// onDirty fires twice per splat first-paint cycle: once from updateInternal
|
||||
// (data uploaded) and again from driveSort (sort completed; line 1105 in
|
||||
// SparkRenderer.ts). We expose it as a passive promise — awaiters get
|
||||
// notified, but the callback itself does NOT trigger a render. Wiring
|
||||
// forceRender directly into onDirty caused a per-frame render-setDirty
|
||||
// cascade that made splats visibly "balloon" during camera interaction.
|
||||
this.sparkRenderer = new SparkRenderer({
|
||||
renderer,
|
||||
onDirty: () => {
|
||||
const resolve = this.nextSparkDirtyResolve
|
||||
this.nextSparkDirtyResolve = null
|
||||
this.nextSparkDirtyPromise = null
|
||||
resolve?.()
|
||||
}
|
||||
})
|
||||
// (Gaussian splat) instances; without it splats are silent no-ops. Kept
|
||||
// alive across model reloads by SceneModelManager.clearModel.
|
||||
this.sparkRenderer = new SparkRenderer({ renderer })
|
||||
this.scene.add(this.sparkRenderer)
|
||||
|
||||
this.gridHelper = new THREE.GridHelper(20, 20)
|
||||
|
||||
@@ -435,11 +435,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
)
|
||||
}
|
||||
|
||||
getCurrentBounds(): THREE.Box3 | null {
|
||||
if (!this.currentModel) return null
|
||||
return this.computeWorldBounds(this.currentModel)
|
||||
}
|
||||
|
||||
async setupModel(model: THREE.Object3D): Promise<void> {
|
||||
this.currentModel = model
|
||||
model.name = 'MainModel'
|
||||
@@ -461,12 +456,6 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.setMaterialMode(pendingMaterialMode)
|
||||
}
|
||||
|
||||
const validModes = this.getCurrentCapabilities().materialModes
|
||||
if (validModes.length > 0 && !validModes.includes(this.materialMode)) {
|
||||
this.materialMode = validModes[0]
|
||||
this.eventManager.emitEvent('materialModeChange', this.materialMode)
|
||||
}
|
||||
|
||||
if (this.currentUpDirection !== 'original') {
|
||||
this.setUpDirection(this.currentUpDirection)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ModelLoadContext } from './ModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
|
||||
const splatMeshSpies = {
|
||||
ctor: vi.fn<(opts: { fileBytes: ArrayBuffer; fileName?: string }) => void>(),
|
||||
ctor: vi.fn<(opts: { fileBytes: ArrayBuffer }) => void>(),
|
||||
dispose: vi.fn(),
|
||||
getBoundingBox: vi.fn(
|
||||
() =>
|
||||
@@ -23,7 +23,7 @@ vi.mock('@sparkjsdev/spark', async () => {
|
||||
dispose = splatMeshSpies.dispose
|
||||
getBoundingBox = splatMeshSpies.getBoundingBox
|
||||
|
||||
constructor(opts: { fileBytes: ArrayBuffer; fileName?: string }) {
|
||||
constructor(opts: { fileBytes: ArrayBuffer }) {
|
||||
super()
|
||||
splatMeshSpies.ctor(opts)
|
||||
}
|
||||
@@ -69,7 +69,7 @@ describe('SplatModelAdapter', () => {
|
||||
|
||||
it('handles the Gaussian splat extensions', () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat', 'ply'])
|
||||
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat'])
|
||||
})
|
||||
|
||||
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
|
||||
@@ -85,18 +85,12 @@ describe('SplatModelAdapter', () => {
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
expect(splatMeshSpies.ctor).toHaveBeenCalledWith({
|
||||
fileBytes: buf,
|
||||
fileName: 'scene.splat'
|
||||
})
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.object.children).toHaveLength(1)
|
||||
expect(result!.capabilities.lighting).toBe(false)
|
||||
expect(splatMeshSpies.ctor).toHaveBeenCalledWith({ fileBytes: buf })
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result.children).toHaveLength(1)
|
||||
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(
|
||||
result!.object.children[0]
|
||||
)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
|
||||
})
|
||||
|
||||
it('rotates the splat 180° around X (OpenCV → three.js convention)', async () => {
|
||||
@@ -106,7 +100,7 @@ describe('SplatModelAdapter', () => {
|
||||
'scene.splat'
|
||||
)
|
||||
|
||||
const splat = result!.object.children[0]
|
||||
const splat = result.children[0]
|
||||
expect(splat.quaternion.x).toBe(1)
|
||||
expect(splat.quaternion.y).toBe(0)
|
||||
expect(splat.quaternion.z).toBe(0)
|
||||
@@ -127,12 +121,11 @@ describe('SplatModelAdapter', () => {
|
||||
describe('computeBounds', () => {
|
||||
it('returns the SplatMesh bounding box transformed to world space', async () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
const result = await adapter.load(
|
||||
const group = await adapter.load(
|
||||
makeContext(),
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
const group = result!.object
|
||||
const splat = group.children[0]
|
||||
splat.position.set(10, 0, 0)
|
||||
|
||||
@@ -159,13 +152,13 @@ describe('SplatModelAdapter', () => {
|
||||
describe('disposeModel', () => {
|
||||
it('calls dispose on every SplatMesh in the model tree', async () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
const result = await adapter.load(
|
||||
const group = await adapter.load(
|
||||
makeContext(),
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
|
||||
adapter.disposeModel(result!.object)
|
||||
adapter.disposeModel(group)
|
||||
|
||||
expect(splatMeshSpies.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { SplatMesh } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { isGaussianSplatPLY } from '@/scripts/metadata/ply'
|
||||
|
||||
import { fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext,
|
||||
ModelLoadResult
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class SplatModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
@@ -24,33 +21,21 @@ export class SplatModelAdapter implements ModelAdapter {
|
||||
fitTargetSize: 20
|
||||
}
|
||||
|
||||
async matches(
|
||||
extension: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<boolean> {
|
||||
if (extension !== 'ply') return true
|
||||
return isGaussianSplatPLY(await fetchBytes())
|
||||
}
|
||||
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string,
|
||||
fetchBytes?: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelLoadResult> {
|
||||
const arrayBuffer = await (fetchBytes?.() ?? fetchModelData(path, filename))
|
||||
filename: string
|
||||
): Promise<THREE.Object3D> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
|
||||
const splatMesh = new SplatMesh({
|
||||
fileBytes: arrayBuffer,
|
||||
fileName: filename
|
||||
})
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
await splatMesh.initialized
|
||||
splatMesh.quaternion.set(1, 0, 0, 0)
|
||||
ctx.setOriginalModel(splatMesh)
|
||||
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return { object: splatGroup, capabilities: this.capabilities }
|
||||
return splatGroup
|
||||
}
|
||||
|
||||
computeBounds(model: THREE.Object3D): THREE.Box3 | null {
|
||||
|
||||
@@ -125,11 +125,7 @@ vi.mock('./Load3d', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
type FakeAdapterRef = {
|
||||
current: ModelAdapter | null
|
||||
capabilities: ModelAdapterCapabilities | null
|
||||
}
|
||||
type FakeLoaderManager = { adapterRefArg: FakeAdapterRef }
|
||||
type FakeLoaderManager = { adapterRefArg: { current: ModelAdapter | null } }
|
||||
type FakeSceneModelManager = {
|
||||
getCurrentCapabilities: () => unknown
|
||||
getBoundsFromAdapter: (model: unknown) => unknown
|
||||
@@ -138,7 +134,7 @@ type FakeSceneModelManager = {
|
||||
}
|
||||
type FakeLoad3d = {
|
||||
deps: {
|
||||
adapterRef: FakeAdapterRef
|
||||
adapterRef: { current: ModelAdapter | null }
|
||||
loaderManager: FakeLoaderManager
|
||||
modelManager: FakeSceneModelManager
|
||||
}
|
||||
@@ -226,7 +222,6 @@ describe('createLoad3d', () => {
|
||||
function withAdapter(adapter: ModelAdapter) {
|
||||
const instance = createLoad3d(createContainer()) as unknown as FakeLoad3d
|
||||
instance.deps.adapterRef.current = adapter
|
||||
instance.deps.adapterRef.capabilities = adapter.capabilities
|
||||
return instance
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
getActiveCamera,
|
||||
(size, center) => cameraManager.setupForModel(size, center),
|
||||
(model) => gizmoManager.setupForModel(model),
|
||||
() => adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
|
||||
() => adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
|
||||
(model) => adapterRef.current?.computeBounds?.(model) ?? null,
|
||||
(model) => adapterRef.current?.disposeModel?.(model),
|
||||
() => adapterRef.current?.defaultCameraPose?.() ?? null
|
||||
|
||||
@@ -80,7 +80,6 @@ export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
retainViewOnReload?: boolean
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Canonical lists of node types backed by the Load3D viewer infrastructure.
|
||||
* Adding a new node type that uses the viewer = one line change here.
|
||||
*/
|
||||
|
||||
const LOAD3D_PREVIEW_NODES = new Set([
|
||||
'Preview3D',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud'
|
||||
])
|
||||
|
||||
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
|
||||
|
||||
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
|
||||
LOAD3D_PREVIEW_NODES.has(nodeType)
|
||||
|
||||
export const isLoad3dNode = (nodeType: string): boolean =>
|
||||
LOAD3D_ALL_NODES.has(nodeType)
|
||||
@@ -26,7 +26,6 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
type Hook = (
|
||||
@@ -84,13 +83,7 @@ describe('load3dLazy', () => {
|
||||
expect(enabledExtensionsGetter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for([
|
||||
'Load3D',
|
||||
'Preview3D',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud',
|
||||
'SaveGLB'
|
||||
])(
|
||||
it.for(['Load3D', 'Preview3D', 'SaveGLB'])(
|
||||
'recognizes %s as a 3D node type and triggers the lazy-load path',
|
||||
async (nodeType) => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import { isLoad3dNode } from './load3d/nodeTypes'
|
||||
const LOAD3D_NODE_TYPES = new Set(['Load3D', 'Preview3D', 'SaveGLB'])
|
||||
|
||||
let load3dExtensionsLoaded = false
|
||||
let load3dExtensionsLoading: Promise<ComfyExtension[]> | null = null
|
||||
@@ -34,12 +34,8 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
|
||||
|
||||
load3dExtensionsLoading = (async () => {
|
||||
const before = new Set(useExtensionStore().enabledExtensions)
|
||||
// Import extensions - they self-register via useExtensionService()
|
||||
await Promise.all([
|
||||
import('./load3d'),
|
||||
import('./load3dPreviewExtensions'),
|
||||
import('./saveMesh')
|
||||
])
|
||||
// Import both extensions - they will self-register via useExtensionService()
|
||||
await Promise.all([import('./load3d'), import('./saveMesh')])
|
||||
load3dExtensionsLoaded = true
|
||||
return useExtensionStore().enabledExtensions.filter(
|
||||
(ext) => !before.has(ext)
|
||||
@@ -49,6 +45,13 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
|
||||
return load3dExtensionsLoading
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node type is a 3D node that requires THREE.js
|
||||
*/
|
||||
function isLoad3dNodeType(nodeTypeName: string): boolean {
|
||||
return LOAD3D_NODE_TYPES.has(nodeTypeName)
|
||||
}
|
||||
|
||||
// Register a lightweight extension that triggers lazy loading
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Load3DLazy',
|
||||
@@ -57,7 +60,7 @@ useExtensionService().registerExtension({
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
if (isLoad3dNode(nodeData.name)) {
|
||||
if (isLoad3dNodeType(nodeData.name)) {
|
||||
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
|
||||
// Load3D's model_file as a mesh upload widget without hardcoding.
|
||||
if (nodeData.name === 'Load3D') {
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const {
|
||||
registerExtensionMock,
|
||||
waitForLoad3dMock,
|
||||
onLoad3dReadyMock,
|
||||
configureForSaveMeshMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock,
|
||||
getNodeByLocatorIdMock,
|
||||
nodeToLoad3dMapMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
onLoad3dReadyMock: vi.fn(),
|
||||
configureForSaveMeshMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn(),
|
||||
getNodeByLocatorIdMock: vi.fn(),
|
||||
nodeToLoad3dMapMock: new Map()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension: registerExtensionMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: () => ({ getLoad3d: getLoad3dMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap: nodeToLoad3dMapMock
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configureForSaveMesh = configureForSaveMeshMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
|
||||
createExportMenuItems: vi.fn(() => [{ content: 'Export' }])
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: {} }
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByLocatorId: getNodeByLocatorIdMock
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: toastAddAlertMock })
|
||||
}))
|
||||
|
||||
type ExtCreated = ComfyExtension & {
|
||||
nodeCreated: (node: LGraphNode) => Promise<void>
|
||||
getNodeMenuItems: (node: LGraphNode) => unknown[]
|
||||
onNodeOutputsUpdated: (
|
||||
nodeOutputs: Record<string, Record<string, unknown>>
|
||||
) => void
|
||||
}
|
||||
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
splatExt: ExtCreated
|
||||
pointCloudExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3dPreviewExtensions')
|
||||
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
|
||||
return {
|
||||
splatExt: splatCall[0] as ExtCreated,
|
||||
pointCloudExt: pointCloudCall[0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeLoad3d {
|
||||
whenLoadIdle: () => Promise<void>
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
forceRender: ReturnType<typeof vi.fn>
|
||||
setCameraState: ReturnType<typeof vi.fn>
|
||||
setTargetSize: ReturnType<typeof vi.fn>
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
getCameraState: ReturnType<typeof vi.fn>
|
||||
getModelInfo: ReturnType<typeof vi.fn>
|
||||
cameraManager: { perspectiveCamera: { fov: number } }
|
||||
currentLoadGeneration: number
|
||||
}
|
||||
|
||||
function makeLoad3dMock(): FakeLoad3d {
|
||||
return {
|
||||
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
|
||||
isSplatModel: vi.fn(() => false),
|
||||
forceRender: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
setTargetSize: vi.fn(),
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
|
||||
getModelInfo: vi.fn(() => null),
|
||||
cameraManager: { perspectiveCamera: { fov: 75 } },
|
||||
currentLoadGeneration: 0
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
function makePreviewNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: {
|
||||
comfyClass: overrides.comfyClass ?? 'PreviewGaussianSplat'
|
||||
},
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'model_file', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function setupBaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
onLoad3dReadyMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
}
|
||||
|
||||
describe('load3dPreviewExtensions module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers both preview extensions on import', async () => {
|
||||
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
|
||||
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not PreviewGaussianSplat', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const node = makePreviewNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await splatExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('triggers a model load against the output folder on execute', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreviewNode()
|
||||
|
||||
await splatExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['scene.ply'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('scene.ply')
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'scene.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('persists backend-provided camera_info into node.properties so onLoad3dReady can restore it after remount', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreviewNode()
|
||||
const cameraState = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 1
|
||||
}
|
||||
|
||||
await splatExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['scene.ply', cameraState] })
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| { state?: typeof cameraState }
|
||||
| undefined
|
||||
expect(cameraConfig?.state).toEqual(cameraState)
|
||||
})
|
||||
|
||||
it('syncs width/height widgets to load3d.setTargetSize and registers callbacks', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const widthWidget: FakeWidget & { callback?: (v: number) => void } = {
|
||||
name: 'width',
|
||||
value: 800
|
||||
}
|
||||
const heightWidget: FakeWidget & { callback?: (v: number) => void } = {
|
||||
name: 'height',
|
||||
value: 600
|
||||
}
|
||||
const node = makePreviewNode({
|
||||
widgets: [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'image', value: '' },
|
||||
widthWidget,
|
||||
heightWidget
|
||||
]
|
||||
})
|
||||
|
||||
await splatExt.nodeCreated(node)
|
||||
|
||||
expect(load3d.setTargetSize).toHaveBeenCalledWith(800, 600)
|
||||
expect(typeof widthWidget.callback).toBe('function')
|
||||
expect(typeof heightWidget.callback).toBe('function')
|
||||
|
||||
widthWidget.callback!(1024)
|
||||
expect(load3d.setTargetSize).toHaveBeenLastCalledWith(1024, 600)
|
||||
})
|
||||
|
||||
it("installs a sceneWidget.serializeValue that returns the viewer's current camera_info + model_3d_info", async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
const cameraState = { position: { x: 1, y: 2, z: 3 } }
|
||||
load3d.getCameraState = vi.fn(() => cameraState)
|
||||
load3d.getModelInfo = vi.fn(() => ({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}))
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const sceneWidget: FakeWidget & {
|
||||
serializeValue?: () => Promise<unknown>
|
||||
} = { name: 'image', value: '' }
|
||||
const node = makePreviewNode({
|
||||
widgets: [{ name: 'model_file', value: '' }, sceneWidget]
|
||||
})
|
||||
nodeToLoad3dMapMock.set(node, load3d)
|
||||
|
||||
await splatExt.nodeCreated(node)
|
||||
|
||||
expect(typeof sceneWidget.serializeValue).toBe('function')
|
||||
const payload = (await sceneWidget.serializeValue!()) as {
|
||||
camera_info: unknown
|
||||
model_3d_info: unknown[]
|
||||
}
|
||||
expect(payload.camera_info).toEqual(cameraState)
|
||||
expect(payload.model_3d_info).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows an error toast when onExecuted has no file path', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const node = makePreviewNode()
|
||||
|
||||
await splatExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: [] })
|
||||
|
||||
expect(toastAddAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.unableToGetModelFilePath'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.PreviewPointCloud.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not PreviewPointCloud', async () => {
|
||||
const { pointCloudExt } = await loadExtensionsFresh()
|
||||
const node = makePreviewNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await pointCloudExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('triggers a model load against the output folder on execute', async () => {
|
||||
const { pointCloudExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreviewNode({ comfyClass: 'PreviewPointCloud' })
|
||||
|
||||
await pointCloudExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['pointcloud.ply'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('pointcloud.ply')
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'output',
|
||||
'pointcloud.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.PreviewGaussianSplat.onNodeOutputsUpdated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips entries whose comfyClass is not PreviewGaussianSplat', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
getNodeByLocatorIdMock.mockReturnValue(makePreviewNode({ comfyClass: 'X' }))
|
||||
|
||||
splatExt.onNodeOutputsUpdated({
|
||||
'node:1': { result: ['scene.ply'] }
|
||||
})
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips entries with no result file path', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
getNodeByLocatorIdMock.mockReturnValue(makePreviewNode())
|
||||
|
||||
splatExt.onNodeOutputsUpdated({ 'node:1': { result: [] } })
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.PreviewGaussianSplat.getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('returns [] for non-PreviewGaussianSplat nodes', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const items = splatExt.getNodeMenuItems(
|
||||
makePreviewNode({ comfyClass: 'OtherNode' })
|
||||
)
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for splat models', async () => {
|
||||
const { splatExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.isSplatModel = vi.fn(() => true)
|
||||
getLoad3dMock.mockReturnValue(load3d)
|
||||
|
||||
const items = splatExt.getNodeMenuItems(makePreviewNode())
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,212 +0,0 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { NodeExecutionOutput, NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
type PreviewOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, Model3DInfo?]
|
||||
}>
|
||||
|
||||
function applyResultToLoad3d(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d,
|
||||
filePath: string,
|
||||
cameraState: CameraState | undefined
|
||||
): void {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
if (cameraState) {
|
||||
const existing = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
node.properties['Camera Config'] = {
|
||||
cameraType: load3d.getCurrentCameraType(),
|
||||
fov: 75,
|
||||
...existing,
|
||||
state: cameraState
|
||||
}
|
||||
}
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d.whenLoadIdle().then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
}
|
||||
|
||||
function createPreview3DExtension(
|
||||
comfyClass: string,
|
||||
extensionName: string
|
||||
): ComfyExtension {
|
||||
const applyPreviewOutput = (
|
||||
node: LGraphNode,
|
||||
result: NonNullable<PreviewOutput['result']>
|
||||
): void => {
|
||||
const filePath = result[0]
|
||||
const cameraState = result[1]
|
||||
if (!filePath) return
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
applyResultToLoad3d(node, load3d, filePath, cameraState)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
name: extensionName,
|
||||
|
||||
onNodeOutputsUpdated(
|
||||
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
|
||||
) {
|
||||
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const result = (output as PreviewOutput).result
|
||||
if (!result?.[0]) continue
|
||||
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
if (!node || node.constructor.comfyClass !== comfyClass) continue
|
||||
|
||||
applyPreviewOutput(node, result)
|
||||
}
|
||||
},
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== comfyClass) return []
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== comfyClass) return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
const { onLoad3dReady, waitForLoad3d } = useLoad3d(node)
|
||||
|
||||
onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d.whenLoadIdle().then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
|
||||
if (sceneWidget) {
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties[
|
||||
'Camera Config'
|
||||
] as CameraConfig | undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: PreviewOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
applyResultToLoad3d(node, load3d, filePath, result?.[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
|
||||
)
|
||||
@@ -1675,7 +1675,6 @@ export class LGraph
|
||||
|
||||
// Record state before conversion for proper undo support
|
||||
this.beforeChange()
|
||||
this.canvasAction((c) => c.emitBeforeChange())
|
||||
|
||||
try {
|
||||
function extractNodes(item: Positionable): Positionable[] {
|
||||
@@ -1690,7 +1689,6 @@ export class LGraph
|
||||
} finally {
|
||||
// Mark state change complete for proper undo support
|
||||
this.afterChange()
|
||||
this.canvasAction((c) => c.emitAfterChange())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -805,11 +805,7 @@
|
||||
"FILE_3D_FBX": "ملف FBX ثلاثي الأبعاد",
|
||||
"FILE_3D_GLB": "ملف GLB ثلاثي الأبعاد",
|
||||
"FILE_3D_GLTF": "ملف GLTF ثلاثي الأبعاد",
|
||||
"FILE_3D_KSPLAT": "FILE_3D_KSPLAT",
|
||||
"FILE_3D_OBJ": "ملف OBJ ثلاثي الأبعاد",
|
||||
"FILE_3D_PLY": "FILE_3D_PLY",
|
||||
"FILE_3D_SPLAT": "FILE_3D_SPLAT",
|
||||
"FILE_3D_SPZ": "FILE_3D_SPZ",
|
||||
"FILE_3D_STL": "ملف STL ثلاثي الأبعاد",
|
||||
"FILE_3D_USDZ": "ملف USDZ ثلاثي الأبعاد",
|
||||
"FLOAT": "رقم عشري",
|
||||
@@ -860,7 +856,6 @@
|
||||
"SAM3_TRACK_DATA": "SAM3_TRACK_DATA",
|
||||
"SAMPLER": "جهاز تجميع",
|
||||
"SIGMAS": "سيجمات",
|
||||
"SPLAT": "SPLAT",
|
||||
"STRING": "نص",
|
||||
"STYLE_MODEL": "نموذج النمط",
|
||||
"SVG": "SVG",
|
||||
@@ -2006,7 +2001,6 @@
|
||||
"orthographic": "أرثوغرافي",
|
||||
"perspective": "منظور"
|
||||
},
|
||||
"centerCameraOnModel": "توسيط الكاميرا على النموذج",
|
||||
"clearRecording": "مسح التسجيل",
|
||||
"dropToLoad": "أسقط نموذج ثلاثي الأبعاد للتحميل",
|
||||
"edgeThreshold": "عتبة الحافة",
|
||||
@@ -2053,7 +2047,6 @@
|
||||
"reloadingModel": "جاري إعادة تحميل النموذج...",
|
||||
"removeBackgroundImage": "إزالة صورة الخلفية",
|
||||
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
|
||||
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
|
||||
"scene": "المشهد",
|
||||
"showGrid": "عرض الشبكة",
|
||||
"showSkeleton": "إظهار الهيكل العظمي",
|
||||
@@ -2697,7 +2690,6 @@
|
||||
"sd3": "sd3",
|
||||
"shader": "shader",
|
||||
"sigmas": "سيجمات",
|
||||
"splat": "splat",
|
||||
"stable_cascade": "سلسلة ثابتة",
|
||||
"style_model": "نموذج النمط",
|
||||
"supir": "supir",
|
||||
@@ -3300,7 +3292,6 @@
|
||||
"Other": "أخرى",
|
||||
"PLY": "PLY",
|
||||
"PlanCredits": "الخطة والاعتمادات",
|
||||
"PointCloud": "سحابة نقطية",
|
||||
"Pointer": "المؤشر",
|
||||
"Queue": "قائمة الانتظار",
|
||||
"QueueButton": "زر قائمة الانتظار",
|
||||
|
||||
@@ -2523,57 +2523,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateCameraInfo": {
|
||||
"description": "إنشاء camera_info. وضع 'orbit' يوجه الكاميرا حول الهدف باستخدام yaw/pitch/distance؛ وضع 'look_at' يضع الكاميرا في موقع محدد في العالم. الإحداثيات في فضاء العالم للمشاهد (نظام اليد اليمنى، المحور Y للأعلى).",
|
||||
"display_name": "إنشاء معلومات الكاميرا",
|
||||
"inputs": {
|
||||
"camera_type": {
|
||||
"name": "camera_type",
|
||||
"tooltip": "الإسقاط المستخدم بواسطة Render Splat: منظور (تقريب المسافات) أو متوازي (خطوط متوازية)."
|
||||
},
|
||||
"fov": {
|
||||
"name": "fov",
|
||||
"tooltip": "مجال الرؤية العمودي بالدرجات."
|
||||
},
|
||||
"mode": {
|
||||
"name": "الوضع",
|
||||
"tooltip": "كيفية تحديد الكاميرا: زوايا المدار، موقع محدد، أو موقع + كواتيرنيون."
|
||||
},
|
||||
"mode_distance": {
|
||||
"name": "المسافة"
|
||||
},
|
||||
"mode_pitch": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"mode_yaw": {
|
||||
"name": "الانحراف"
|
||||
},
|
||||
"roll": {
|
||||
"name": "roll",
|
||||
"tooltip": "تدوير الكاميرا حول محور الرؤية، بالدرجات."
|
||||
},
|
||||
"target_x": {
|
||||
"name": "target_x",
|
||||
"tooltip": "نقطة النظر (محور المدار / الهدف). في وضع المدار، حركها لتحريك/ترجمة الكاميرا بالكامل. يتم تجاهلها في وضع الكواتيرنيون. الافتراضي هو الأصل."
|
||||
},
|
||||
"target_y": {
|
||||
"name": "target_y"
|
||||
},
|
||||
"target_z": {
|
||||
"name": "target_z"
|
||||
},
|
||||
"zoom": {
|
||||
"name": "zoom",
|
||||
"tooltip": "تكبير رقمي (معامل الطول البؤري). >١ للتكبير دون تحريك الكاميرا."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateHookKeyframe": {
|
||||
"display_name": "إنشاء إطار مفتاحي للخطاف",
|
||||
"inputs": {
|
||||
@@ -3747,22 +3696,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"File3DToSplat": {
|
||||
"description": "تحويل ملف File3D splat إلى gaussian splat. عكس عملية إنشاء ملف ثلاثي الأبعاد (من Splat). الصيغ المدعومة: PLY، SPLAT، KSPLAT، SPZ. صيغة PLY تدعم التوافقيات الكروية الكاملة، أما الصيغ الأخرى فهي تدعم اللون الأساسي فقط. يتم اكتشاف الصيغة تلقائيًا من محتوى الملف.",
|
||||
"display_name": "الحصول على Splat",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "نموذج_٣دي",
|
||||
"tooltip": "ملف ثلاثي الأبعاد من نوع gaussian splat"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FlipSigmas": {
|
||||
"display_name": "عكس قيم السيغما",
|
||||
"inputs": {
|
||||
@@ -3912,28 +3845,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxEraseNode": {
|
||||
"description": "إزالة الكائن المحدد بالقناع من الصورة وإعادة بناء الخلفية. قم برسم القناع فوق ما تريد مسحه.",
|
||||
"display_name": "Flux مسح الصورة",
|
||||
"inputs": {
|
||||
"dilate_pixels": {
|
||||
"name": "توسيع_البكسلات",
|
||||
"tooltip": "يوسع حدود القناع لضمان تغطية حواف الكائن بشكل نظيف."
|
||||
},
|
||||
"image": {
|
||||
"name": "الصورة"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask",
|
||||
"tooltip": "المناطق البيضاء تُزال؛ المناطق السوداء تُحفظ."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxGuidance": {
|
||||
"display_name": "إرشاد فلوكس",
|
||||
"inputs": {
|
||||
@@ -4211,36 +4122,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxVTONode": {
|
||||
"description": "تجربة الملابس الافتراضية: يلبس الشخص الملابس المقدمة.",
|
||||
"display_name": "Flux تجربة الملابس الافتراضية",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"garment": {
|
||||
"name": "الملابس",
|
||||
"tooltip": "صورة الملابس التي سيتم تطبيقها."
|
||||
},
|
||||
"person": {
|
||||
"name": "الشخص",
|
||||
"tooltip": "صورة الشخص الذي سيتم تلبيسه."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "التعليمات",
|
||||
"tooltip": "تعليمات اختيارية لوصف النمط (مثال: كيف يجب أن يكون مقاس الملابس)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة العشوائية المستخدمة لإنشاء الضوضاء."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FrameInterpolate": {
|
||||
"display_name": "استيفاء الإطارات",
|
||||
"inputs": {
|
||||
@@ -4835,25 +4716,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GetSplatCount": {
|
||||
"description": "يعيد عدد السبلاطات المجمعة عبر الدفعة.",
|
||||
"display_name": "احصل على عدد السبلاطات",
|
||||
"inputs": {
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "count",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GetVideoComponents": {
|
||||
"description": "يستخرج جميع المكونات من الفيديو: الإطارات، الصوت، ومعدل الإطارات.",
|
||||
"display_name": "استخراج مكونات الفيديو",
|
||||
@@ -5064,12 +4926,10 @@
|
||||
"tooltip": "مدة الفيديو الناتج بالثواني."
|
||||
},
|
||||
"image": {
|
||||
"name": "الصورة",
|
||||
"tooltip": "صورة بداية اختيارية لـ grok-imagine-video. مطلوبة لـ grok-imagine-video-1.5."
|
||||
"name": "الصورة"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "يتطلب grok-imagine-video-1.5 دائماً صورة إدخال."
|
||||
"name": "النموذج"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "المطالبة",
|
||||
@@ -9802,21 +9662,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"MergeSplat": {
|
||||
"description": "دمج أي عدد من السبلاطات الغاوسية في واحدة. توحيد عدة عمليات فك تشفير لنفس الـ latent مع بذور مختلفة يزيد من كثافة السطح، مما قد يحسن جودة السطح عند إنشاء الشبكة.",
|
||||
"display_name": "دمج السبلاطات",
|
||||
"inputs": {
|
||||
"splats": {
|
||||
"name": "splats"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"MergeTextLists": {
|
||||
"display_name": "دمج قوائم النصوص",
|
||||
"inputs": {
|
||||
@@ -13582,52 +13427,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Preview3DAdvanced": {
|
||||
"display_name": "معاينة ثلاثية الأبعاد (متقدم)",
|
||||
"inputs": {
|
||||
"camera_info": {
|
||||
"name": "camera_info"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "model_3d_info"
|
||||
},
|
||||
"model_file": {
|
||||
"name": "model_file",
|
||||
"tooltip": "ملف نموذج ثلاثي الأبعاد من عقدة ثلاثية الأبعاد سابقة."
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_file",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "width",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "height",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewAny": {
|
||||
"display_name": "معاينة أي",
|
||||
"inputs": {
|
||||
@@ -14616,66 +14415,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenderSplat": {
|
||||
"description": "عرض gaussian splat كصورة باستخدام أداة rasterizer EWA غير متجانسة (splat بيضاوي موجه، مضاد للتعرج، مرتبة حسب العمق من الأمام إلى الخلف). تأتي الكاميرا من مدخل camera_info (تحميل / معاينة ثلاثية الأبعاد، أو عقدة Create Camera Info)؛ اتركه فارغًا لتأطير splat تلقائيًا. عيّن الإطارات أكبر من 1 للحصول على مجموعة صور دوران لتغذية عقدة فيديو.",
|
||||
"display_name": "عرض Splat",
|
||||
"inputs": {
|
||||
"background": {
|
||||
"name": "الخلفية"
|
||||
},
|
||||
"bg_image": {
|
||||
"name": "صورة الخلفية",
|
||||
"tooltip": "صورة خلفية اختيارية توضع خلف splat (تتجاوز لون الخلفية الصلب). يتم تغيير حجمها حسب حجم العرض؛ تُستخدم مجموعة لكل إطار، أو صورة واحدة للجميع. للون/الطين فقط."
|
||||
},
|
||||
"camera_info": {
|
||||
"name": "معلومات الكاميرا",
|
||||
"tooltip": "الكاميرا التي يتم العرض منها - كاميرا Load3D / Preview3D أو عقدة Create Camera Info. إذا كانت فارغة، يتم تأطير splat تلقائيًا من زاوية ٣/٤ افتراضية."
|
||||
},
|
||||
"frames": {
|
||||
"name": "الإطارات",
|
||||
"tooltip": "-1، 0، 1 = صورة ثابتة واحدة؛ >1 = دوران، تدور الكاميرا دورة كاملة ٣٦٠ درجة (يعمل مع أي camera_info). القيمة السالبة تدور بالعكس."
|
||||
},
|
||||
"headlight_shading": {
|
||||
"name": "تظليل ضوء أمامي",
|
||||
"tooltip": "تظليل منتشر من ضوء عند الكاميرا (ضوء أمامي)، باستخدام اتجاهات splat: يظلم الأسطح التي تدور بعيدًا عن الرؤية لإظهار الشكل/الانحناء. ٠ = لون مسطح، ١ = أقوى تظليل."
|
||||
},
|
||||
"height": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"opacity_threshold": {
|
||||
"name": "عتبة الشفافية",
|
||||
"tooltip": "استبعاد gaussians ذات الشفافية الأقل من هذه القيمة (يزيل العناصر الطافية الخفيفة)."
|
||||
},
|
||||
"render_style": {
|
||||
"name": "نمط العرض",
|
||||
"tooltip": "ما الذي تعرضه الصورة: اللون، طين (تظليل بلون محايد)، العمق (القريب = فاتح)، العادي (خريطة عادية OpenGL)."
|
||||
},
|
||||
"sharpen": {
|
||||
"name": "حدة",
|
||||
"tooltip": "زيادة وضوح splats المتداخلة: ١.٠ = دمج فيزيائي صحيح؛ القيم الأعلى تجعل كل بكسل أقرب إلى splat المسيطر (الأقرب) لنسيج أوضح، دون تصغير splats أو فتح فجوات. غير فيزيائي فوق ١."
|
||||
},
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
},
|
||||
"splat_scale": {
|
||||
"name": "مقياس splat",
|
||||
"tooltip": "معامل ضرب على مساحة splat المتوقعة (أقل = نقاط أوضح، أعلى = سطح أنعم/أكمل)."
|
||||
},
|
||||
"width": {
|
||||
"name": "العرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "صورة",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "mask",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenormCFG": {
|
||||
"display_name": "إعادة تهيئة CFG",
|
||||
"inputs": {
|
||||
@@ -16946,68 +16685,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplatToFile3D": {
|
||||
"description": "تسلسل gaussian splat إلى كائن File3D لعقدتي Save / Preview 3D. يدعم عنصرًا واحدًا فقط لكل مجموعة.",
|
||||
"display_name": "إنشاء ملف ثلاثي الأبعاد (من Splat)",
|
||||
"inputs": {
|
||||
"format": {
|
||||
"name": "الصيغة",
|
||||
"tooltip": "ply: Gaussian Splat ثلاثي الأبعاد قياسي مع توافقيات كروية كاملة. ksplat: mkkellogg SplatBuffer (المستوى ٠، غير مضغوط)، لون أساسي فقط spz: Niantic مضغوط gzip (~١٠ مرات أصغر)، لون أساسي فقط"
|
||||
},
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "نموذج ثلاثي الأبعاد",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplatToMesh": {
|
||||
"description": "استخراج شبكة ملونة من gaussian splat.",
|
||||
"display_name": "استخراج شبكة من Splat",
|
||||
"inputs": {
|
||||
"color_sharpen": {
|
||||
"name": "حدة اللون",
|
||||
"tooltip": "زيادة وضوح نسيج الرؤوس: 1.0 = دمج فيزيائي صحيح؛ القيم الأعلى تميل لون كل voxel نحو gaussian المهيمن بدلاً من متوسط الجيران (يزيل التمويه من النسيج). اللون فقط - الهندسة لا تتغير."
|
||||
},
|
||||
"kernel": {
|
||||
"name": "النواة",
|
||||
"tooltip": "أقصى نصف عرض splat بوحدة voxels. كل gaussian يتم تحويله إلى صورة ضمن نافذة بحجم 3-سيغما الخاصة به، ويتم تحديد الحد الأعلى هنا - surfels الصغيرة تبقى منخفضة التكلفة، والكبيرة لا يتم اقتطاعها. زد القيمة إذا تركت splats المتفرقة فراغات."
|
||||
},
|
||||
"level": {
|
||||
"name": "المستوى",
|
||||
"tooltip": "مستوى iso-surface. يتم اختياره تلقائياً بواسطة Otsu؛ هذا يغيره (1.0 = تلقائي، أقل = سطح أكثر سماكة/اتصالاً، أعلى = سطح أرفع/أضيق)."
|
||||
},
|
||||
"min_component": {
|
||||
"name": "أصغر مكون",
|
||||
"tooltip": "إسقاط المكونات المتصلة الأصغر من هذا العدد من الرؤوس (0 = احتفظ بالجميع). يزيل الكتل المنفصلة والقشرة الداخلية للجدار المزدوج."
|
||||
},
|
||||
"min_opacity": {
|
||||
"name": "أقل شفافية",
|
||||
"tooltip": "تجاهل gaussians الأضعف من هذه القيمة قبل إنشاء الشبكة."
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة",
|
||||
"tooltip": "دقة شبكة الكثافة على المحور الأطول. قيمة أعلى = سطح أدق، المزيد من VRAM/الوقت (تزداد مع resolution^3)."
|
||||
},
|
||||
"smooth": {
|
||||
"name": "التنعيم",
|
||||
"tooltip": "عدد تكرارات تنعيم الشبكة (Taubin). ينعم السطح دون تقليصه (يحافظ على الحجم)، على عكس تمويه الكثافة. 0 = سطح خام."
|
||||
},
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "شبكة",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitAudioChannels": {
|
||||
"description": "يفصل الصوت إلى القناتين اليسرى واليمنى.",
|
||||
"display_name": "فصل قنوات الصوت",
|
||||
@@ -18706,48 +18383,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TransformSplat": {
|
||||
"description": "تحريك، تدوير، وتغيير حجم gaussian splat. تغيير الحجم غير المتساوي يعيد تشكيل كل splat فردي أيضاً، وهي عملية أبطأ.",
|
||||
"display_name": "تحويل Splat",
|
||||
"inputs": {
|
||||
"rotate_x": {
|
||||
"name": "تدوير_x"
|
||||
},
|
||||
"rotate_y": {
|
||||
"name": "تدوير_y"
|
||||
},
|
||||
"rotate_z": {
|
||||
"name": "تدوير_z"
|
||||
},
|
||||
"scale_x": {
|
||||
"name": "تغيير_الحجم_x"
|
||||
},
|
||||
"scale_y": {
|
||||
"name": "تغيير_الحجم_y"
|
||||
},
|
||||
"scale_z": {
|
||||
"name": "تغيير_الحجم_z"
|
||||
},
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
},
|
||||
"translate_x": {
|
||||
"name": "نقل_x"
|
||||
},
|
||||
"translate_y": {
|
||||
"name": "نقل_y"
|
||||
},
|
||||
"translate_z": {
|
||||
"name": "نقل_z"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrimAudioDuration": {
|
||||
"description": "قص موتر الصوت إلى النطاق الزمني المختار.",
|
||||
"display_name": "قص مدة الصوت",
|
||||
@@ -19227,101 +18862,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoSplatConditioning": {
|
||||
"description": "ترميز الصورة باستخدام DINOv3 وFlux2 VAE إلى تهيئة إيجابية/سلبية لـ TripoSplat، وإنشاء هدف الضوضاء ذو الحجم الثابت (latent + camera) لـ KSampler",
|
||||
"display_name": "تهيئة TripoSplat",
|
||||
"inputs": {
|
||||
"clip_vision": {
|
||||
"name": "clip_vision",
|
||||
"tooltip": "مشفر الصور DINOv3 ViT-H/16+"
|
||||
},
|
||||
"image": {
|
||||
"name": "صورة"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae",
|
||||
"tooltip": "Flux2 VAE"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "إيجابي",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "سلبي",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "هدف الضوضاء ذو الحجم الثابت (latent + camera)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoSplatPreprocessImage": {
|
||||
"description": "قص مركز كل صورة إلى لوحة مربعة على خلفية سوداء مع إضافة حواف.",
|
||||
"display_name": "معالجة صورة TripoSplat",
|
||||
"inputs": {
|
||||
"erode_radius": {
|
||||
"name": "نصف قطر التآكل",
|
||||
"tooltip": "تآكل قناة ألفا بهذا العدد من البكسلات قبل القص (لتجنب تسرب الحواف)."
|
||||
},
|
||||
"image": {
|
||||
"name": "صورة"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
},
|
||||
"size": {
|
||||
"name": "الحجم",
|
||||
"tooltip": "حجم الصورة المربعة. تم تدريب النموذج على 1024؛ الأحجام الأخرى تعمل ولكنها خارج التوزيع."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "صورة",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoSplatSamplingPreview": {
|
||||
"description": "تعديل نموذج TripoSplat لعقدة Ksampler القياسية لعرض معاينة مباشرة لفك ترميز gaussian splat في كل خطوة.",
|
||||
"display_name": "معاينة أخذ عينات TripoSplat",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "النموذج"
|
||||
},
|
||||
"num_gaussians": {
|
||||
"name": "عدد gaussians",
|
||||
"tooltip": "عدد gaussians المطلوب إنتاجها للمعاينة (يتم تقريبه إلى مضاعفات 32)."
|
||||
},
|
||||
"octree_level": {
|
||||
"name": "مستوى octree",
|
||||
"tooltip": "عمق octree لفك ترميز المعاينة (أقل = أرخص/أكثر خشونة)."
|
||||
},
|
||||
"pitch": {
|
||||
"name": "زاوية الميل",
|
||||
"tooltip": "زاوية ميل الكاميرا للمعاينة بالدرجات."
|
||||
},
|
||||
"point_size": {
|
||||
"name": "حجم النقطة",
|
||||
"tooltip": "أقصى نصف قطر splat بالبكسل. يتم تحديد حجم كل gaussian من مقياسه ويقتصر هنا؛ أقل = أدق/أكثر حدة، أعلى = أكثر سماكة."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae",
|
||||
"tooltip": "TripoSplat VAE decoder"
|
||||
},
|
||||
"yaw": {
|
||||
"name": "زاوية الانحراف",
|
||||
"tooltip": "زاوية انحراف الكاميرا للمعاينة بالدرجات."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoTextToModelNode": {
|
||||
"display_name": "Tripo: النص إلى نموذج",
|
||||
"inputs": {
|
||||
@@ -19653,36 +19193,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VAEDecodeTripoSplat": {
|
||||
"description": "فك ترميز TripoSplat latent المأخوذ إلى gaussian splat ثلاثي الأبعاد. عدل عدد gaussians لتغيير الكثافة.",
|
||||
"display_name": "فك ترميز TripoSplat",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"num_gaussians": {
|
||||
"name": "عدد gaussians",
|
||||
"tooltip": "عدد gaussians المطلوب إنتاجها (يتم تقريبه إلى مضاعفات 32). 262144 يطابق كثافة نقاط octree؛ القيم الأعلى تعيد أخذ عينات من نفس النقاط (أكثر كثافة، لكن بدون تفاصيل جديدة) وتكلف المزيد من VRAM/الوقت."
|
||||
},
|
||||
"samples": {
|
||||
"name": "عينات"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "تستخدم لتوليد نقاط octree بشكل محدد (RNG عالمي) لفك ترميز حتمي."
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae",
|
||||
"tooltip": "TripoSplat VAE decoder"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VAEEncode": {
|
||||
"display_name": "ترميز VAE",
|
||||
"inputs": {
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"name": "محرك PLY",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "اختر المحرك لتحميل ملفات PLY. \"threejs\" يستخدم محمل Three.js PLY الأصلي (الأفضل لملفات الشبكة). \"fastply\" يستخدم محملًا محسنًا لملفات PLY السحابية النقطية بنسق ASCII. \"sparkjs\" يستخدم Spark.js لملفات PLY الخاصة بتقنية Gaussian Splatting ثلاثية الأبعاد."
|
||||
|
||||
@@ -1519,8 +1519,7 @@
|
||||
"Error System": "Error System",
|
||||
"Other": "Other",
|
||||
"Secrets": "Secrets",
|
||||
"Node Library": "Node Library",
|
||||
"PointCloud": "Point Cloud"
|
||||
"Node Library": "Node Library"
|
||||
},
|
||||
"serverConfigItems": {
|
||||
"listen": {
|
||||
@@ -1696,19 +1695,18 @@
|
||||
"cond single": "cond single",
|
||||
"controlnet": "controlnet",
|
||||
"inpaint": "inpaint",
|
||||
"3d": "3d",
|
||||
"scheduling": "scheduling",
|
||||
"create": "create",
|
||||
"deprecated": "deprecated",
|
||||
"detection": "detection",
|
||||
"debug": "debug",
|
||||
"ElevenLabs": "ElevenLabs",
|
||||
"3d": "3d",
|
||||
"ltxv": "ltxv",
|
||||
"qwen": "qwen",
|
||||
"sd3": "sd3",
|
||||
"unet": "unet",
|
||||
"sigmas": "sigmas",
|
||||
"splat": "splat",
|
||||
"BFL": "BFL",
|
||||
"Gemini": "Gemini",
|
||||
"gligen": "gligen",
|
||||
@@ -1795,11 +1793,7 @@
|
||||
"FILE_3D_FBX": "FILE_3D_FBX",
|
||||
"FILE_3D_GLB": "FILE_3D_GLB",
|
||||
"FILE_3D_GLTF": "FILE_3D_GLTF",
|
||||
"FILE_3D_KSPLAT": "FILE_3D_KSPLAT",
|
||||
"FILE_3D_OBJ": "FILE_3D_OBJ",
|
||||
"FILE_3D_PLY": "FILE_3D_PLY",
|
||||
"FILE_3D_SPLAT": "FILE_3D_SPLAT",
|
||||
"FILE_3D_SPZ": "FILE_3D_SPZ",
|
||||
"FILE_3D_STL": "FILE_3D_STL",
|
||||
"FILE_3D_USDZ": "FILE_3D_USDZ",
|
||||
"FLOAT": "FLOAT",
|
||||
@@ -1850,7 +1844,6 @@
|
||||
"SAM3_TRACK_DATA": "SAM3_TRACK_DATA",
|
||||
"SAMPLER": "SAMPLER",
|
||||
"SIGMAS": "SIGMAS",
|
||||
"SPLAT": "SPLAT",
|
||||
"STRING": "STRING",
|
||||
"STYLE_MODEL": "STYLE_MODEL",
|
||||
"SVG": "SVG",
|
||||
@@ -1963,7 +1956,6 @@
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"retainViewOnReload": "Lock camera view across model reloads",
|
||||
"showGrid": "Show Grid",
|
||||
"backgroundColor": "Background Color",
|
||||
"lightIntensity": "Light Intensity",
|
||||
@@ -1978,7 +1970,6 @@
|
||||
"materialMode": "Material Mode",
|
||||
"showSkeleton": "Show Skeleton",
|
||||
"fitToViewer": "Fit to Viewer",
|
||||
"centerCameraOnModel": "Center Camera on Model",
|
||||
"scene": "Scene",
|
||||
"model": "Model",
|
||||
"camera": "Camera",
|
||||
|
||||
@@ -2523,57 +2523,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateCameraInfo": {
|
||||
"display_name": "Create Camera Info",
|
||||
"description": "Build a camera_infoMode 'orbit' aims with yaw/pitch/distance around the target; 'look_at' places the camera at world position. Coordinates are the viewer's world space (right-handed,Y-up).",
|
||||
"inputs": {
|
||||
"mode": {
|
||||
"name": "mode",
|
||||
"tooltip": "How to define the camera: orbit angles, an explicit position, or a position + quaternion."
|
||||
},
|
||||
"target_x": {
|
||||
"name": "target_x",
|
||||
"tooltip": "Look-at point (orbit pivot / aim). In orbit mode, move it to pan/translate the whole camera. Ignored in quaternion mode. Defaults to the origin."
|
||||
},
|
||||
"target_y": {
|
||||
"name": "target_y"
|
||||
},
|
||||
"target_z": {
|
||||
"name": "target_z"
|
||||
},
|
||||
"roll": {
|
||||
"name": "roll",
|
||||
"tooltip": "Camera roll about the view axis, degrees."
|
||||
},
|
||||
"fov": {
|
||||
"name": "fov",
|
||||
"tooltip": "Vertical field of view in degrees."
|
||||
},
|
||||
"zoom": {
|
||||
"name": "zoom",
|
||||
"tooltip": "Digital zoom (focal-length multiplier). >1 zooms in without moving the camera."
|
||||
},
|
||||
"camera_type": {
|
||||
"name": "camera_type",
|
||||
"tooltip": "Projection used by Render Splat: perspective (foreshortening) or orthographic (parallel)."
|
||||
},
|
||||
"mode_distance": {
|
||||
"name": "distance"
|
||||
},
|
||||
"mode_pitch": {
|
||||
"name": "pitch"
|
||||
},
|
||||
"mode_yaw": {
|
||||
"name": "yaw"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"CreateHookKeyframe": {
|
||||
"display_name": "Create Hook Keyframe",
|
||||
"inputs": {
|
||||
@@ -3747,22 +3696,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"File3DToSplat": {
|
||||
"display_name": "Get Splat",
|
||||
"description": "Parse a splat File3D into a gaussian splat. Inverse of Create 3D File (from Splat). Supported format: PLY, SPLAT, KSPLAT, SPZ. PLY carries full spherical harmonics, the other formats are base color only. Format is auto-detected from the file contents.",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "A gaussian splat 3D file"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FlipSigmas": {
|
||||
"display_name": "FlipSigmas",
|
||||
"inputs": {
|
||||
@@ -3912,28 +3845,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxEraseNode": {
|
||||
"display_name": "Flux Erase Image",
|
||||
"description": "Removes the masked object from an image and reconstructs the background. Paint the mask over what you want to erase.",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask",
|
||||
"tooltip": "White areas are removed; black areas are preserved."
|
||||
},
|
||||
"dilate_pixels": {
|
||||
"name": "dilate_pixels",
|
||||
"tooltip": "Expands the mask boundaries to ensure clean coverage of the object's edges."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxGuidance": {
|
||||
"display_name": "FluxGuidance",
|
||||
"inputs": {
|
||||
@@ -4211,36 +4122,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FluxVTONode": {
|
||||
"display_name": "Flux Virtual Try-On",
|
||||
"description": "Virtual try-on: dresses the person in the provided garment.",
|
||||
"inputs": {
|
||||
"person": {
|
||||
"name": "person",
|
||||
"tooltip": "Image of the person to dress."
|
||||
},
|
||||
"garment": {
|
||||
"name": "garment",
|
||||
"tooltip": "Image of the garment to apply."
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Optional natural-language styling instruction (e.g. how the garment should fit)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "The random seed used for creating the noise."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"FrameInterpolate": {
|
||||
"display_name": "Frame Interpolate",
|
||||
"inputs": {
|
||||
@@ -4727,25 +4608,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GetSplatCount": {
|
||||
"display_name": "Get Splat Count",
|
||||
"description": "Returns the number of splats summed across the batch.",
|
||||
"inputs": {
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "count",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GetVideoComponents": {
|
||||
"display_name": "Get Video Components",
|
||||
"description": "Extracts all components from a video: frames, audio, and framerate.",
|
||||
@@ -5053,8 +4915,7 @@
|
||||
"description": "Generate video from a prompt or an image",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "grok-imagine-video-1.5 currently always requires an input image."
|
||||
"name": "model"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
@@ -5077,8 +4938,7 @@
|
||||
"tooltip": "Seed to determine if node should re-run; actual results are nondeterministic regardless of seed."
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "Optional starting image for grok-imagine-video. Required for grok-imagine-video-1.5."
|
||||
"name": "image"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
@@ -9802,21 +9662,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"MergeSplat": {
|
||||
"display_name": "Merge Splats",
|
||||
"description": "Concatenate any number of gaussian splats into one. Unioning several decodes of the same latent at different seeds densifies the surface, this can improve surface quality when meshing.",
|
||||
"inputs": {
|
||||
"splats": {
|
||||
"name": "splats"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"MergeTextLists": {
|
||||
"display_name": "Merge Text Lists (DEPRECATED)",
|
||||
"inputs": {
|
||||
@@ -13582,52 +13427,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Preview3DAdvanced": {
|
||||
"display_name": "Preview 3D (Advanced)",
|
||||
"inputs": {
|
||||
"model_file": {
|
||||
"name": "model_file",
|
||||
"tooltip": "3D model file from an upstream 3D node."
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"camera_info": {
|
||||
"name": "camera_info"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "model_3d_info"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_file",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "width",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "height",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewAny": {
|
||||
"display_name": "Preview as Text",
|
||||
"inputs": {
|
||||
@@ -14588,66 +14387,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenderSplat": {
|
||||
"display_name": "Render Splat",
|
||||
"description": "Render a gaussian splat as an image with an anisotropic EWA rasterizer (oriented elliptical splats, antialiased, depth-sorted front-to-back). The camera comes from a camera_info input (Load / Preview 3D, or a Create Camera Info node); leave it empty to auto-frame the splat. Set frames greater than 1 for a turntable batch of images to feed a Video node.",
|
||||
"inputs": {
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"frames": {
|
||||
"name": "frames",
|
||||
"tooltip": "-1, 0, 1 = single still image; >1 = turntable, the camera orbits over a full 360 turn (works with any camera_info). Negative value orbits the other way."
|
||||
},
|
||||
"splat_scale": {
|
||||
"name": "splat_scale",
|
||||
"tooltip": "Multiplier on each splat's projected footprint (lower = crisper points, higher = softer/fuller surface)."
|
||||
},
|
||||
"sharpen": {
|
||||
"name": "sharpen",
|
||||
"tooltip": "Sharpen overlapping splats: 1.0 = physically-correct blend; higher biases each pixel toward its dominant (nearest) splat for crisper texture, without shrinking splats or opening gaps. Non-physical above 1."
|
||||
},
|
||||
"headlight_shading": {
|
||||
"name": "headlight_shading",
|
||||
"tooltip": "Diffuse shading from a light at the camera (headlight), using the splat surfel normals: darkens surfaces that turn away from view to reveal form/curvature. 0 = flat albedo, 1 = strongest shading."
|
||||
},
|
||||
"opacity_threshold": {
|
||||
"name": "opacity_threshold",
|
||||
"tooltip": "Cull gaussians with opacity below this (removes faint floaters)."
|
||||
},
|
||||
"render_style": {
|
||||
"name": "render_style",
|
||||
"tooltip": "What the image output shows: color, clay (neutral-albedo shaded), depth (near=bright), normal (OpenGL normal map)."
|
||||
},
|
||||
"background": {
|
||||
"name": "background"
|
||||
},
|
||||
"bg_image": {
|
||||
"name": "bg_image",
|
||||
"tooltip": "Optional background plate composited behind the splat (overrides the solid background colour). Resized to the render size; a batch is used per frame, a single image for all. color/clay only."
|
||||
},
|
||||
"camera_info": {
|
||||
"name": "camera_info",
|
||||
"tooltip": "Camera to render from - a Load3D / Preview3D camera or a Create Camera Info node. If empty, the splat is auto-framed from a default 3/4 view."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "image",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "mask",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"RenormCFG": {
|
||||
"display_name": "RenormCFG",
|
||||
"inputs": {
|
||||
@@ -16825,68 +16564,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplatToFile3D": {
|
||||
"display_name": "Create 3D File (from Splat)",
|
||||
"description": "Serialize a gaussian splat to a File3D object for Save / Preview 3D nodes. Supports one item per batch only.",
|
||||
"inputs": {
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"tooltip": "ply: standard 3D Gaussian Splat with full spherical harmonics. ksplat: mkkellogg SplatBuffer (level 0, uncompressed), base color only spz: Niantic gzip-compressed (~10x smaller), base color only "
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_3d",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplatToMesh": {
|
||||
"display_name": "Extract Mesh from Splat",
|
||||
"description": "Extract a coloured mesh from a gaussian splat.",
|
||||
"inputs": {
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution",
|
||||
"tooltip": "Density-grid resolution along the longest axis. Higher = finer surface, more VRAM/time (grows with resolution^3)."
|
||||
},
|
||||
"kernel": {
|
||||
"name": "kernel",
|
||||
"tooltip": "Max splat half-width in voxels. Each gaussian is rasterized over a window sized to its own 3-sigma, capped here - small surfels stay cheap, large ones aren't truncated. Raise if sparse splats leave gaps."
|
||||
},
|
||||
"smooth": {
|
||||
"name": "smooth",
|
||||
"tooltip": "Taubin mesh-smoothing iterations. Smooths the surface without shrinking it (volume-preserving), unlike blurring the density. 0 = raw surface."
|
||||
},
|
||||
"level": {
|
||||
"name": "level",
|
||||
"tooltip": "Iso-surface level. Auto-picked by Otsu; this biases it (1.0 = auto, lower = fatter/more-connected surface, higher = thinner/tighter)."
|
||||
},
|
||||
"min_component": {
|
||||
"name": "min_component",
|
||||
"tooltip": "Drop connected components smaller than this many vertices (0 = keep all). Removes detached floater blobs and the inner shell of the double wall."
|
||||
},
|
||||
"min_opacity": {
|
||||
"name": "min_opacity",
|
||||
"tooltip": "Ignore gaussians fainter than this before meshing."
|
||||
},
|
||||
"color_sharpen": {
|
||||
"name": "color_sharpen",
|
||||
"tooltip": "Crisp up the vertex texture: 1.0 = physically-correct blend; higher biases each voxel's colour toward its dominant gaussian instead of averaging neighbours (de-smears the texture). Colour only - geometry is unchanged."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "mesh",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"SplitAudioChannels": {
|
||||
"display_name": "Split Audio Channels",
|
||||
"description": "Separates the audio into left and right channels.",
|
||||
@@ -18706,48 +18383,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TransformSplat": {
|
||||
"display_name": "Transform Splat",
|
||||
"description": "Translate, rotate, and scale a gaussian splat. Non-uniform scale also reshapes every individual splat, slower process.",
|
||||
"inputs": {
|
||||
"splat": {
|
||||
"name": "splat"
|
||||
},
|
||||
"translate_x": {
|
||||
"name": "translate_x"
|
||||
},
|
||||
"translate_y": {
|
||||
"name": "translate_y"
|
||||
},
|
||||
"translate_z": {
|
||||
"name": "translate_z"
|
||||
},
|
||||
"rotate_x": {
|
||||
"name": "rotate_x"
|
||||
},
|
||||
"rotate_y": {
|
||||
"name": "rotate_y"
|
||||
},
|
||||
"rotate_z": {
|
||||
"name": "rotate_z"
|
||||
},
|
||||
"scale_x": {
|
||||
"name": "scale_x"
|
||||
},
|
||||
"scale_y": {
|
||||
"name": "scale_y"
|
||||
},
|
||||
"scale_z": {
|
||||
"name": "scale_z"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrimAudioDuration": {
|
||||
"display_name": "Trim Audio Duration",
|
||||
"description": "Trim audio tensor into chosen time range.",
|
||||
@@ -19227,101 +18862,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoSplatConditioning": {
|
||||
"display_name": "TripoSplat Conditioning",
|
||||
"description": "Encode the image with DINOv3 and the Flux2 VAE into TripoSplat positive/negative conditioning, and create the fixed size noise target (latent + camera) for the KSampler",
|
||||
"inputs": {
|
||||
"clip_vision": {
|
||||
"name": "clip_vision",
|
||||
"tooltip": "DINOv3 ViT-H/16+ image encoder"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae",
|
||||
"tooltip": "Flux2 VAE"
|
||||
},
|
||||
"image": {
|
||||
"name": "image"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "positive",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "negative",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "latent",
|
||||
"tooltip": "The fixed size noise target (latent +camera)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoSplatPreprocessImage": {
|
||||
"display_name": "TripoSplat Preprocess Image",
|
||||
"description": "Crop center each image to a square canvas on a black background and add padding.",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"mask": {
|
||||
"name": "mask"
|
||||
},
|
||||
"erode_radius": {
|
||||
"name": "erode_radius",
|
||||
"tooltip": "Erode the alpha matte by this pixel radius before cropping (avoids border bleed)."
|
||||
},
|
||||
"size": {
|
||||
"name": "size",
|
||||
"tooltip": "Square image size. The model is trained at 1024; other sizes run but are off-distribution."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoSplatSamplingPreview": {
|
||||
"display_name": "TripoSplat Sampling Preview",
|
||||
"description": "Patch the TripoSplat model for the standard Ksampler node to show a live decoded gaussian splat preview at each step.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae",
|
||||
"tooltip": "TripoSplat VAE decoder"
|
||||
},
|
||||
"octree_level": {
|
||||
"name": "octree_level",
|
||||
"tooltip": "Octree depth for the preview decode (lower = cheaper/coarser)."
|
||||
},
|
||||
"num_gaussians": {
|
||||
"name": "num_gaussians",
|
||||
"tooltip": "Number of gaussians to produce for the preview (rounded to a multiple of 32)."
|
||||
},
|
||||
"yaw": {
|
||||
"name": "yaw",
|
||||
"tooltip": "Preview camera yaw in degrees."
|
||||
},
|
||||
"pitch": {
|
||||
"name": "pitch",
|
||||
"tooltip": "Preview camera pitch in degrees."
|
||||
},
|
||||
"point_size": {
|
||||
"name": "point_size",
|
||||
"tooltip": "Maximum splat radius in pixels. Each gaussian is sized from its scale and capped here; lower = finer/pointier, higher = chunkier."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TripoTextToModelNode": {
|
||||
"display_name": "Tripo: Text to Model",
|
||||
"inputs": {
|
||||
@@ -19678,36 +19218,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VAEDecodeTripoSplat": {
|
||||
"display_name": "TripoSplat Decode",
|
||||
"description": "Decode the sampled TripoSplat latent into a 3D gaussian splat. Modify the number of gaussians to vary the density.",
|
||||
"inputs": {
|
||||
"samples": {
|
||||
"name": "samples"
|
||||
},
|
||||
"vae": {
|
||||
"name": "vae",
|
||||
"tooltip": "TripoSplat VAE decoder"
|
||||
},
|
||||
"num_gaussians": {
|
||||
"name": "num_gaussians",
|
||||
"tooltip": "Number of gaussians to produce (rounded to a multiple of 32). 262144 matches the octree's point density; higher oversamples the same points (denser, but no new detail) and costs proportionally more VRAM/time."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seeds the octree point sampler (global RNG) for deterministic decodes."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "splat",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"VAEEncode": {
|
||||
"display_name": "VAE Encode",
|
||||
"inputs": {
|
||||
|
||||
@@ -196,11 +196,12 @@
|
||||
"tooltip": "Sets the minimum allowable light intensity value for 3D scenes. This defines the lower brightness limit that can be set when adjusting lighting in any 3D widget."
|
||||
},
|
||||
"Comfy_Load3D_PLYEngine": {
|
||||
"name": "Point Cloud Engine",
|
||||
"tooltip": "Select the engine for loading point cloud PLY files. \"threejs\" uses the native Three.js PLYLoader (handles binary + ASCII, mesh-capable). \"fastply\" uses an optimized parser for ASCII PLY files. 3D Gaussian Splat PLYs are detected automatically and always rendered via sparkjs regardless of this setting.",
|
||||
"name": "PLY Engine",
|
||||
"tooltip": "Select the engine for loading PLY files. \"threejs\" uses the native Three.js PLYLoader (best for mesh PLY files). \"fastply\" uses an optimized loader for ASCII point cloud PLY files. \"sparkjs\" uses Spark.js for 3D Gaussian Splatting PLY files.",
|
||||
"options": {
|
||||
"threejs": "threejs",
|
||||
"fastply": "fastply"
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs"
|
||||
}
|
||||
},
|
||||
"Comfy_Load3D_ShowGrid": {
|
||||
|
||||
@@ -805,11 +805,7 @@
|
||||
"FILE_3D_FBX": "ARCHIVO_3D_FBX",
|
||||
"FILE_3D_GLB": "ARCHIVO_3D_GLB",
|
||||
"FILE_3D_GLTF": "ARCHIVO_3D_GLTF",
|
||||
"FILE_3D_KSPLAT": "FILE_3D_KSPLAT",
|
||||
"FILE_3D_OBJ": "ARCHIVO_3D_OBJ",
|
||||
"FILE_3D_PLY": "FILE_3D_PLY",
|
||||
"FILE_3D_SPLAT": "FILE_3D_SPLAT",
|
||||
"FILE_3D_SPZ": "FILE_3D_SPZ",
|
||||
"FILE_3D_STL": "ARCHIVO_3D_STL",
|
||||
"FILE_3D_USDZ": "ARCHIVO_3D_USDZ",
|
||||
"FLOAT": "FLOTANTE",
|
||||
@@ -860,7 +856,6 @@
|
||||
"SAM3_TRACK_DATA": "SAM3_TRACK_DATA",
|
||||
"SAMPLER": "MUESTREADOR",
|
||||
"SIGMAS": "SIGMAS",
|
||||
"SPLAT": "SPLAT",
|
||||
"STRING": "CADENA",
|
||||
"STYLE_MODEL": "MODELO_DE_ESTILO",
|
||||
"SVG": "SVG",
|
||||
@@ -2006,7 +2001,6 @@
|
||||
"orthographic": "Ortográfica",
|
||||
"perspective": "Perspectiva"
|
||||
},
|
||||
"centerCameraOnModel": "Centrar la cámara en el modelo",
|
||||
"clearRecording": "Borrar grabación",
|
||||
"dropToLoad": "Suelta el modelo 3D para cargar",
|
||||
"edgeThreshold": "Umbral de borde",
|
||||
@@ -2053,7 +2047,6 @@
|
||||
"reloadingModel": "Recargando modelo...",
|
||||
"removeBackgroundImage": "Eliminar imagen de fondo",
|
||||
"resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida",
|
||||
"retainViewOnReload": "Bloquear la vista de la cámara al recargar el modelo",
|
||||
"scene": "Escena",
|
||||
"showGrid": "Mostrar cuadrícula",
|
||||
"showSkeleton": "Mostrar esqueleto",
|
||||
@@ -2697,7 +2690,6 @@
|
||||
"sd3": "sd3",
|
||||
"shader": "shader",
|
||||
"sigmas": "sigmas",
|
||||
"splat": "splat",
|
||||
"stable_cascade": "stable_cascade",
|
||||
"style_model": "modelo_de_estilo",
|
||||
"supir": "supir",
|
||||
@@ -3300,7 +3292,6 @@
|
||||
"Other": "Otros",
|
||||
"PLY": "PLY",
|
||||
"PlanCredits": "Plan y créditos",
|
||||
"PointCloud": "Nube de puntos",
|
||||
"Pointer": "Puntero",
|
||||
"Queue": "Cola",
|
||||
"QueueButton": "Botón de Cola",
|
||||
|
||||