mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-05 12:44:23 +00:00
Compare commits
16 Commits
comfy-api-
...
nav/postho
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
645472b2bd | ||
|
|
9a86b33c77 | ||
|
|
faac0347aa | ||
|
|
71f4b28207 | ||
|
|
fef35e7dda | ||
|
|
e4d481f893 | ||
|
|
da836cb681 | ||
|
|
a549bd0123 | ||
|
|
593586bbeb | ||
|
|
7df62ca75e | ||
|
|
f4653b7365 | ||
|
|
d99f08e4ea | ||
|
|
e16a0bfe82 | ||
|
|
3a8ddfb6f1 | ||
|
|
5a53df8d79 | ||
|
|
c957913c71 |
111
CODEOWNERS
111
CODEOWNERS
@@ -1,95 +1,60 @@
|
||||
# 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
|
||||
|
||||
# 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
|
||||
/src/composables/node/useNodePricing.ts @jojodecayz @bigcat88 @comfy_frontend_devs
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/platform/workflow/templates/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
|
||||
/src/components/templates/ @christian-byrne @comfyui-wiki @comfy_frontend_devs
|
||||
|
||||
# Mask Editor
|
||||
/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
|
||||
/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
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
/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
|
||||
|
||||
# Image Compare
|
||||
/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
|
||||
/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
|
||||
|
||||
# Painter
|
||||
/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
|
||||
/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
|
||||
|
||||
# GLSL
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne @comfy_frontend_devs
|
||||
|
||||
# 3D
|
||||
/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
|
||||
/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
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
/src/workbench/extensions/manager/ @christian-byrne @ltdrdata @comfy_frontend_devs
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
/src/platform/assets/mappings/ @deepme987 @comfy_frontend_devs
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 50 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 67 KiB |
@@ -2,7 +2,6 @@
|
||||
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'
|
||||
@@ -116,8 +115,6 @@ const plans: PricingPlan[] = [
|
||||
|
||||
const standardPlans = plans.filter((p) => !p.isEnterprise)
|
||||
const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
|
||||
const activePlanIndex = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -134,28 +131,7 @@ const activePlanIndex = ref(0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
@@ -273,13 +249,9 @@ const activePlanIndex = ref(0)
|
||||
</PricingTierCard>
|
||||
</div>
|
||||
|
||||
<!-- 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')"
|
||||
>
|
||||
<!-- 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">
|
||||
<!-- Main info card -->
|
||||
<div class="bg-transparency-white-t4 rounded-3xl p-6">
|
||||
<!-- Label + badge -->
|
||||
|
||||
@@ -11,14 +11,12 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<template>
|
||||
<section
|
||||
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)]"
|
||||
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"
|
||||
>
|
||||
<!-- Illustration (stacks above on mobile, left on lg) -->
|
||||
<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)]"
|
||||
>
|
||||
<div class="pointer-events-none mx-auto w-full flex-1 md:-translate-x-20">
|
||||
<svg
|
||||
class="block size-full overflow-visible"
|
||||
class="mx-auto block size-full max-w-lg overflow-visible md:ml-auto md:scale-125"
|
||||
viewBox="50 50 900 900"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
@@ -378,9 +376,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<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"
|
||||
>
|
||||
<div class="relative z-10 lg:flex-1">
|
||||
<ProductHeroBadge text="CLOUD" />
|
||||
|
||||
<h1
|
||||
@@ -390,7 +386,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
|
||||
class="text-primary-comfy-canvas mt-6 max-w-lg 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-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)]"
|
||||
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)]"
|
||||
>
|
||||
<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-lg font-semibold lg:text-sm lg:font-normal"
|
||||
class="text-primary-comfy-canvas relative z-10 text-sm font-semibold lg:text-sm lg:font-normal"
|
||||
>
|
||||
<span class="whitespace-nowrap">
|
||||
{{ t('download.cloud.prefix', locale) }}
|
||||
|
||||
55
apps/website/src/scripts/posthog.test.ts
Normal file
55
apps/website/src/scripts/posthog.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// @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,5 +1,7 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -18,7 +20,9 @@ export function initPostHog() {
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only'
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
export class UserSelectPage {
|
||||
class UserSelectPage {
|
||||
public readonly selectionUrl: string
|
||||
public readonly container: Locator
|
||||
public readonly newUserInput: Locator
|
||||
|
||||
@@ -18,7 +18,7 @@ class ShortcutsTab {
|
||||
}
|
||||
}
|
||||
|
||||
export class LogsTab {
|
||||
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'
|
||||
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
class ComfyNodeSearchFilterSelectionPanel {
|
||||
readonly root: Locator
|
||||
readonly header: Locator
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import type { Position } from '@e2e/fixtures/types'
|
||||
|
||||
const { searchBoxV2 } = TestIds
|
||||
|
||||
export type { RootCategoryId }
|
||||
|
||||
export class ComfyNodeSearchBoxV2 {
|
||||
readonly dialog: Locator
|
||||
readonly input: Locator
|
||||
|
||||
@@ -2,8 +2,3 @@ export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
@@ -86,46 +86,6 @@ 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',
|
||||
@@ -136,26 +96,6 @@ 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',
|
||||
@@ -166,31 +106,6 @@ 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',
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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,11 +2,6 @@ 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'>
|
||||
@@ -31,33 +26,3 @@ 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'
|
||||
|
||||
export interface MutationRecord {
|
||||
interface MutationRecord {
|
||||
endpoint: string
|
||||
method: string
|
||||
url: string
|
||||
@@ -23,7 +23,7 @@ interface PaginationOptions {
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
export interface AssetConfig {
|
||||
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 }
|
||||
}
|
||||
|
||||
export type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
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.
|
||||
*/
|
||||
export type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
type MediaKindFixture = 'images' | 'video' | 'audio' | '3D'
|
||||
|
||||
const DEFAULT_EXTENSION: Record<MediaKindFixture, string> = {
|
||||
images: 'png',
|
||||
@@ -134,16 +134,6 @@ 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']
|
||||
|
||||
export type HelpMenuItemKey =
|
||||
type HelpMenuItemKey =
|
||||
| 'feedback'
|
||||
| 'help'
|
||||
| 'docs'
|
||||
@@ -17,7 +17,7 @@ export type HelpMenuItemKey =
|
||||
| 'update-comfyui'
|
||||
| 'more'
|
||||
|
||||
export class HelpCenterHelper {
|
||||
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
|
||||
|
||||
export type BrushSliderLabel = 'thickness'
|
||||
type BrushSliderLabel = 'thickness'
|
||||
|
||||
export class MaskEditorHelper {
|
||||
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\/([^?]+)/
|
||||
|
||||
export interface MockModelMetadata {
|
||||
interface MockModelMetadata {
|
||||
'modelspec.title'?: string
|
||||
'modelspec.author'?: string
|
||||
'modelspec.architecture'?: string
|
||||
@@ -18,14 +18,11 @@ export interface MockModelMetadata {
|
||||
'modelspec.tags'?: string
|
||||
}
|
||||
|
||||
export function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
return names.map((name) => ({ name, folders: [] }))
|
||||
}
|
||||
|
||||
export function createMockModelFiles(
|
||||
filenames: string[],
|
||||
pathIndex = 0
|
||||
): ModelFile[] {
|
||||
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'
|
||||
}
|
||||
|
||||
export class PublishApiHelper {
|
||||
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'
|
||||
|
||||
export class SubgraphBreadcrumbHelper {
|
||||
class SubgraphBreadcrumbHelper {
|
||||
readonly panel: SubgraphBreadcrumbPanel
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
|
||||
@@ -4,33 +4,9 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
}
|
||||
@@ -39,7 +15,7 @@ function emptyConfig(): TemplateConfig {
|
||||
return { templates: [], index: null }
|
||||
}
|
||||
|
||||
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
|
||||
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
|
||||
return templates.map((t) => structuredClone(t))
|
||||
@@ -62,46 +38,6 @@ 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({
|
||||
}
|
||||
}
|
||||
|
||||
export class JobsRouteMocker {
|
||||
class JobsRouteMocker {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobsHistory(
|
||||
|
||||
@@ -303,12 +303,3 @@ 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
|
||||
|
||||
export type SharedWorkflowRequestEvent =
|
||||
type SharedWorkflowRequestEvent =
|
||||
| 'import'
|
||||
| 'input-assets-including-public-before-import'
|
||||
| 'input-assets-including-public-after-import'
|
||||
|
||||
@@ -3,9 +3,7 @@ import type { Page } from '@playwright/test'
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
|
||||
export type { CanvasRect }
|
||||
|
||||
export interface MeasureResult {
|
||||
interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ class NodeSlotReference {
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeWidgetReference {
|
||||
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'
|
||||
|
||||
export interface PerfReport {
|
||||
interface PerfReport {
|
||||
timestamp: string
|
||||
gitSha: string
|
||||
branch: string
|
||||
|
||||
@@ -20,9 +20,7 @@ function previewExposureToEntry(
|
||||
return [exposure.sourceNodeId, exposure.sourcePreviewName]
|
||||
}
|
||||
|
||||
export function isPromotedWidgetSource(
|
||||
value: unknown
|
||||
): value is PromotedWidgetSource {
|
||||
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
@@ -33,7 +31,7 @@ export function isPromotedWidgetSource(
|
||||
)
|
||||
}
|
||||
|
||||
export function isNodeProperty(value: unknown): value is NodeProperty {
|
||||
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'
|
||||
|
||||
export interface SlotMeasurement {
|
||||
interface SlotMeasurement {
|
||||
key: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
export interface NodeSlotData {
|
||||
interface NodeSlotData {
|
||||
nodeId: string
|
||||
nodeW: number
|
||||
nodeH: number
|
||||
|
||||
68
browser_tests/tests/dialogs/openSharedWorkflowDialog.spec.ts
Normal file
68
browser_tests/tests/dialogs/openSharedWorkflowDialog.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@
|
||||
<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: ['**/*.@(spec|test).?(c|m)[jt]s?(x)', 'browser_tests/**/*.ts']
|
||||
entry: ['browser_tests/**/*.@(spec|test).?(c|m)[jt]s?(x)']
|
||||
},
|
||||
tags: [
|
||||
'-knipIgnoreUnusedButUsedByCustomNodes',
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "catalog:",
|
||||
"@iconify/tailwind4": "catalog:",
|
||||
"@iconify/tools": "catalog:",
|
||||
"@iconify/utils": "catalog:",
|
||||
"tailwindcss-primeui": "catalog:",
|
||||
"tw-animate-css": "catalog:"
|
||||
|
||||
55
packages/design-system/src/css/comfyIconSet.js
Normal file
55
packages/design-system/src/css/comfyIconSet.js
Normal file
@@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
23
packages/design-system/src/css/iconifyDynamicPlugin.js
Normal file
23
packages/design-system/src/css/iconifyDynamicPlugin.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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,11 +1,13 @@
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
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'
|
||||
import { loadIconSet } from '@iconify/tailwind4/lib/helpers/loader.js'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
import { COMFY_ICON_PREFIX, loadComfyIconSet } from './comfyIconSet.js'
|
||||
|
||||
/**
|
||||
* Tailwind 4 plugin that provides lucide icon variants with configurable
|
||||
* stroke-width via class prefix.
|
||||
* Tailwind 4 plugin that provides icon variants with configurable
|
||||
* stroke-width via class prefix. Supports lucide and comfy icon sets.
|
||||
*
|
||||
* Usage in CSS:
|
||||
* @plugin "./lucideStrokePlugin.js";
|
||||
@@ -13,25 +15,40 @@ import { matchIconName } from '@iconify/utils/lib/icon/name'
|
||||
* 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-[lucide--settings]" /> <!-- stroke-width: 2.5 -->
|
||||
* <i class="icon-s2.5-[comfy--workflow]" /> <!-- stroke-width: 2.5 -->
|
||||
*
|
||||
* The default class remains stroke-width: 2.
|
||||
* The plain `icon-[...]` class keeps each icon's native stroke-width.
|
||||
*/
|
||||
|
||||
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 Error(`Invalid icon name: "${icon}"`)
|
||||
throw new InvalidIconProbeError(`Invalid icon name: "${icon}"`)
|
||||
}
|
||||
const [prefix, name] = nameParts
|
||||
if (!(prefix.match(matchIconName) && name.match(matchIconName))) {
|
||||
throw new Error(`Invalid icon name: "${icon}"`)
|
||||
if (!SUPPORTED_PREFIXES.has(prefix)) {
|
||||
throw new InvalidIconProbeError(`Unsupported icon prefix: "${prefix}"`)
|
||||
}
|
||||
const iconSet = loadIconSet(prefix)
|
||||
if (!(prefix.match(matchIconName) && name.match(matchIconName))) {
|
||||
throw new InvalidIconProbeError(`Invalid icon name: "${icon}"`)
|
||||
}
|
||||
const iconSet = resolveIconSet(prefix)
|
||||
if (!iconSet) {
|
||||
throw new Error(
|
||||
`Cannot load icon set for "${prefix}". Install "@iconify-json/${prefix}" as dev dependency?`
|
||||
@@ -40,7 +57,7 @@ function getDynamicCSSRulesWithStroke(icon, strokeWidth) {
|
||||
const generated = getIconsCSSData(iconSet, [name], {
|
||||
iconSelector: '.icon',
|
||||
customise: (content) =>
|
||||
content.replaceAll('stroke-width="2"', `stroke-width="${strokeWidth}"`)
|
||||
content.replace(STROKE_WIDTH_ATTR_RE, `stroke-width="${strokeWidth}"`)
|
||||
})
|
||||
if (generated.css.length !== 1) {
|
||||
throw new Error(`Cannot find "${icon}". Bad icon name?`)
|
||||
@@ -62,8 +79,8 @@ export default plugin(({ matchComponents }) => {
|
||||
try {
|
||||
return getDynamicCSSRulesWithStroke(icon, sw)
|
||||
} catch (err) {
|
||||
console.warn(err.message)
|
||||
return {}
|
||||
if (err instanceof InvalidIconProbeError) return {}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,10 +8,7 @@
|
||||
|
||||
@plugin 'tailwindcss-primeui';
|
||||
|
||||
@plugin "@iconify/tailwind4" {
|
||||
scale: 1.2;
|
||||
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
|
||||
}
|
||||
@plugin "./iconifyDynamicPlugin.js";
|
||||
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
"./networkUtil": "./src/networkUtil.ts",
|
||||
"./piiUtil": "./src/piiUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
58
packages/shared-frontend-utils/src/piiUtil.test.ts
Normal file
58
packages/shared-frontend-utils/src/piiUtil.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
35
packages/shared-frontend-utils/src/piiUtil.ts
Normal file
35
packages/shared-frontend-utils/src/piiUtil.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
2016
pnpm-lock.yaml
generated
2016
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,16 @@ publicHoistPattern:
|
||||
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@astrojs/check': ^0.9.8
|
||||
'@astrojs/sitemap': ^3.7.1
|
||||
'@astrojs/vue': ^5.0.0
|
||||
'@astrojs/check': ^0.9.9
|
||||
'@astrojs/sitemap': ^3.7.3
|
||||
'@astrojs/vue': ^6.0.1
|
||||
'@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
|
||||
@@ -65,7 +66,7 @@ catalog:
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
astro: ^5.10.0
|
||||
astro: ^6.4.2
|
||||
axios: ^1.15.2
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 15 KiB |
@@ -44,11 +44,14 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="canFitToViewer"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex flex-col rounded-lg bg-backdrop/30">
|
||||
<div
|
||||
v-if="canFitToViewer || canCenterCameraOnModel"
|
||||
class="flex flex-col rounded-lg bg-backdrop/30"
|
||||
>
|
||||
<Button
|
||||
v-if="canFitToViewer"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.fitToViewer'),
|
||||
showDelay: 300
|
||||
@@ -61,25 +64,29 @@
|
||||
>
|
||||
<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>
|
||||
|
||||
<div
|
||||
v-if="enable3DViewer && node"
|
||||
class="pointer-events-auto absolute top-24 right-2 z-20"
|
||||
>
|
||||
<ViewerControls :node="node as LGraphNode" />
|
||||
</div>
|
||||
<ViewerControls
|
||||
v-if="enable3DViewer && node"
|
||||
:node="node as LGraphNode"
|
||||
/>
|
||||
|
||||
<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"
|
||||
@@ -142,6 +149,7 @@ const {
|
||||
isRecording,
|
||||
isPreview,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
@@ -175,6 +183,7 @@ const {
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ export const useAuthActions = () => {
|
||||
}
|
||||
|
||||
await authStore.logout()
|
||||
useTelemetry()?.trackLogout()
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('auth.signOut.success'),
|
||||
|
||||
@@ -9,6 +9,10 @@ export type AppMode =
|
||||
| 'builder:outputs'
|
||||
| 'builder:arrange'
|
||||
|
||||
export function modeIsAppMode(mode: AppMode): boolean {
|
||||
return mode === 'app' || mode === 'builder:arrange'
|
||||
}
|
||||
|
||||
const enableAppBuilder = ref(true)
|
||||
|
||||
export function useAppMode() {
|
||||
@@ -29,9 +33,7 @@ export function useAppMode() {
|
||||
() => isSelectInputsMode.value || isSelectOutputsMode.value
|
||||
)
|
||||
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
|
||||
const isAppMode = computed(
|
||||
() => mode.value === 'app' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isAppMode = computed(() => modeIsAppMode(mode.value))
|
||||
const isGraphMode = computed(
|
||||
() => mode.value === 'graph' || isSelectMode.value
|
||||
)
|
||||
|
||||
@@ -132,6 +132,7 @@ 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)
|
||||
@@ -853,6 +854,7 @@ 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
|
||||
@@ -971,6 +973,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
syncSceneModels()
|
||||
}
|
||||
|
||||
const handleCenterCameraOnModel = () => {
|
||||
load3d?.centerCameraOnModel()
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
if (load3d) {
|
||||
load3d.resetGizmoTransform()
|
||||
@@ -1011,6 +1017,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isSplatModel,
|
||||
isPlyModel,
|
||||
canFitToViewer,
|
||||
canCenterCameraOnModel,
|
||||
canUseGizmo,
|
||||
canUseLighting,
|
||||
canExport,
|
||||
@@ -1046,6 +1053,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -368,7 +369,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
| LightConfig
|
||||
| undefined
|
||||
|
||||
isPreview.value = node.type === 'Preview3D'
|
||||
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
|
||||
|
||||
if (sceneConfig) {
|
||||
backgroundColor.value =
|
||||
|
||||
@@ -164,6 +164,7 @@ 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 },
|
||||
|
||||
@@ -219,13 +219,15 @@ useExtensionService().registerExtension({
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Load3D.PLYEngine',
|
||||
category: ['3D', 'PLY', 'PLY Engine'],
|
||||
name: 'PLY Engine',
|
||||
category: ['3D', 'PointCloud', 'Point Cloud Engine'],
|
||||
name: 'Point Cloud 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.',
|
||||
'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.',
|
||||
type: 'combo',
|
||||
options: ['threejs', 'fastply', 'sparkjs'],
|
||||
options: ['threejs', 'fastply'],
|
||||
defaultValue: 'threejs',
|
||||
migrateDeprecatedValue: (value) =>
|
||||
value === 'sparkjs' ? 'threejs' : value,
|
||||
experimental: true
|
||||
}
|
||||
],
|
||||
@@ -267,40 +269,44 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
|
||||
if (node.constructor.comfyClass === 'Load3D') {
|
||||
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
|
||||
|
||||
node.properties['Resource Folder'] = ''
|
||||
node.properties['Resource Folder'] = ''
|
||||
|
||||
fileInput.onchange = async () => {
|
||||
await handleModelUpload(fileInput.files!, node)
|
||||
}
|
||||
|
||||
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
|
||||
fileInput.click()
|
||||
})
|
||||
|
||||
const resourcesInput = createFileInput('*', true)
|
||||
|
||||
resourcesInput.onchange = async () => {
|
||||
await handleResourcesUpload(resourcesInput.files!, node)
|
||||
resourcesInput.value = ''
|
||||
}
|
||||
|
||||
node.addWidget(
|
||||
'button',
|
||||
'upload extra resources',
|
||||
'uploadExtraResources',
|
||||
() => {
|
||||
resourcesInput.click()
|
||||
fileInput.onchange = async () => {
|
||||
await handleModelUpload(fileInput.files!, node)
|
||||
}
|
||||
)
|
||||
|
||||
node.addWidget('button', 'clear', 'clear', () => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
modelWidget.value = LOAD3D_NONE_MODEL
|
||||
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
|
||||
fileInput.click()
|
||||
})
|
||||
|
||||
const resourcesInput = createFileInput('*', true)
|
||||
|
||||
resourcesInput.onchange = async () => {
|
||||
await handleResourcesUpload(resourcesInput.files!, node)
|
||||
resourcesInput.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
node.addWidget(
|
||||
'button',
|
||||
'upload extra resources',
|
||||
'uploadExtraResources',
|
||||
() => {
|
||||
resourcesInput.click()
|
||||
}
|
||||
)
|
||||
|
||||
node.addWidget('button', 'clear', 'clear', () => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model_file'
|
||||
)
|
||||
if (modelWidget) {
|
||||
modelWidget.value = LOAD3D_NONE_MODEL
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node: node,
|
||||
|
||||
@@ -161,11 +161,23 @@ 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
|
||||
|
||||
@@ -626,7 +638,7 @@ class Load3d {
|
||||
}
|
||||
|
||||
getCurrentModelCapabilities(): ModelAdapterCapabilities {
|
||||
return this.adapterRef.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES
|
||||
return this.adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES
|
||||
}
|
||||
|
||||
clearModel(): void {
|
||||
@@ -924,6 +936,22 @@ 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,7 +7,11 @@ import type {
|
||||
ModelManagerInterface
|
||||
} from './interfaces'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import type { ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
|
||||
function makeEventManagerStub() {
|
||||
return {
|
||||
@@ -28,6 +32,12 @@ 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(),
|
||||
@@ -41,14 +51,21 @@ function makeModelManagerStub(): ModelManagerStub {
|
||||
}
|
||||
}
|
||||
|
||||
const { meshLoad, splatLoad, pointCloudLoad, getPLYEngineMock, addAlert } =
|
||||
vi.hoisted(() => ({
|
||||
meshLoad: vi.fn(),
|
||||
splatLoad: vi.fn(),
|
||||
pointCloudLoad: vi.fn(),
|
||||
getPLYEngineMock: vi.fn<() => string>(),
|
||||
addAlert: vi.fn()
|
||||
}))
|
||||
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()
|
||||
}))
|
||||
|
||||
vi.mock('./MeshModelAdapter', () => ({
|
||||
MeshModelAdapter: class {
|
||||
@@ -65,19 +82,35 @@ 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'] as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] 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
|
||||
}))
|
||||
@@ -87,7 +120,10 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
}))
|
||||
|
||||
type LoaderManagerInternals = {
|
||||
pickAdapter(extension: string): ModelAdapter | null
|
||||
pickAdapter(
|
||||
extension: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelAdapter | null>
|
||||
}
|
||||
|
||||
function makeLoaderManager() {
|
||||
@@ -98,21 +134,21 @@ function makeLoaderManager() {
|
||||
eventManager
|
||||
)
|
||||
const internals = lm as unknown as LoaderManagerInternals
|
||||
return {
|
||||
lm,
|
||||
modelManager,
|
||||
eventManager,
|
||||
pick: internals.pickAdapter.bind(lm)
|
||||
}
|
||||
const pick = (ext: string) =>
|
||||
internals.pickAdapter.call(lm, ext, () =>
|
||||
fetchModelDataMock()
|
||||
) as Promise<ModelAdapter | null>
|
||||
return { lm, modelManager, eventManager, pick }
|
||||
}
|
||||
|
||||
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', () => {
|
||||
@@ -123,7 +159,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('exposes the picked adapter after a successful load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
@@ -132,7 +168,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('resets to null at the start of a new load', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
@@ -144,7 +180,7 @@ describe('LoaderManager', () => {
|
||||
it('stays null when the adapter rejects (does not publish stale adapter)', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
expect(lm.getCurrentAdapter()?.kind).toBe('mesh')
|
||||
|
||||
@@ -195,7 +231,10 @@ describe('LoaderManager', () => {
|
||||
}
|
||||
|
||||
let adapterDuringClear: ModelAdapter | null | undefined
|
||||
const adapterRef = { current: oldAdapter as ModelAdapter | null }
|
||||
const adapterRef = {
|
||||
current: oldAdapter as ModelAdapter | null,
|
||||
capabilities: oldAdapter.capabilities as ModelAdapterCapabilities | null
|
||||
}
|
||||
const lm = new LoaderManager(
|
||||
modelManager,
|
||||
eventManager,
|
||||
@@ -223,8 +262,8 @@ describe('LoaderManager', () => {
|
||||
const slowSplatLoad = new Promise<THREE.Object3D>((resolve) => {
|
||||
resolveSplatLoad = resolve
|
||||
})
|
||||
splatLoad.mockReturnValueOnce(slowSplatLoad)
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
splatLoad.mockReturnValueOnce(slowSplatLoad.then(loadResult))
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
const aPromise = lm.loadModel('api/view?filename=a.splat')
|
||||
|
||||
@@ -243,42 +282,36 @@ describe('LoaderManager', () => {
|
||||
describe('pickAdapter', () => {
|
||||
it.for(['stl', 'fbx', 'obj', 'gltf', 'glb'])(
|
||||
'routes %s to the mesh adapter',
|
||||
(ext) => {
|
||||
async (ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick(ext)?.kind).toBe('mesh')
|
||||
expect((await pick(ext))?.kind).toBe('mesh')
|
||||
}
|
||||
)
|
||||
|
||||
it.for(['spz', 'splat', 'ksplat'])(
|
||||
'routes %s to the splat adapter',
|
||||
(ext) => {
|
||||
async (ext) => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick(ext)?.kind).toBe('splat')
|
||||
expect((await pick(ext))?.kind).toBe('splat')
|
||||
}
|
||||
)
|
||||
|
||||
it('routes .ply to the point-cloud adapter for the default three engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('three')
|
||||
it('routes .ply to the splat adapter when the bytes look like 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(true)
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
expect((await pick('ply'))?.kind).toBe('splat')
|
||||
})
|
||||
|
||||
it('routes .ply to the point-cloud adapter for the fastply engine', () => {
|
||||
getPLYEngineMock.mockReturnValue('fastply')
|
||||
it('falls back to the point-cloud adapter for .ply that is not 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('pointCloud')
|
||||
expect((await pick('ply'))?.kind).toBe('pointCloud')
|
||||
})
|
||||
|
||||
it('routes .ply to the splat adapter when the engine setting is sparkjs', () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
it('returns null for unknown extensions', async () => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('ply')?.kind).toBe('splat')
|
||||
})
|
||||
|
||||
it('returns null for unknown extensions', () => {
|
||||
const { pick } = makeLoaderManager()
|
||||
expect(pick('xyz')).toBeNull()
|
||||
expect(pick('')).toBeNull()
|
||||
expect(await pick('xyz')).toBeNull()
|
||||
expect(await pick('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -348,7 +381,7 @@ describe('LoaderManager', () => {
|
||||
it('passes setupModel the object returned by the adapter', async () => {
|
||||
const { lm, modelManager } = makeLoaderManager()
|
||||
const loaded = new THREE.Object3D()
|
||||
meshLoad.mockResolvedValueOnce(loaded)
|
||||
meshLoad.mockResolvedValueOnce(loadResult(loaded))
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
@@ -366,7 +399,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('emits modelLoadingEnd when the load completes', async () => {
|
||||
const { lm, eventManager } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
@@ -378,7 +411,7 @@ describe('LoaderManager', () => {
|
||||
|
||||
it('forwards a decoded path and filename to the adapter', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel(
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=cube.glb'
|
||||
@@ -390,32 +423,105 @@ describe('LoaderManager', () => {
|
||||
registerOriginalMaterial: expect.any(Function)
|
||||
}),
|
||||
'api/view?type=output&subfolder=nested%2Fdir&filename=',
|
||||
'cube.glb'
|
||||
'cube.glb',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the path to type=input when no type param is given', async () => {
|
||||
const { lm } = makeLoaderManager()
|
||||
meshLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
meshLoad.mockResolvedValueOnce(loadResult(new THREE.Object3D()))
|
||||
|
||||
await lm.loadModel('api/view?filename=cube.glb')
|
||||
|
||||
expect(meshLoad).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'api/view?type=input&subfolder=&filename=',
|
||||
'cube.glb'
|
||||
'cube.glb',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('routes .ply through the splat adapter when the engine setting is sparkjs', async () => {
|
||||
getPLYEngineMock.mockReturnValue('sparkjs')
|
||||
it('routes .ply to the point-cloud adapter when the header does not look like 3DGS', async () => {
|
||||
isGaussianSplatPLYMock.mockResolvedValue(false)
|
||||
const { lm } = makeLoaderManager()
|
||||
splatLoad.mockResolvedValueOnce(new THREE.Object3D())
|
||||
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()))
|
||||
|
||||
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 () => {
|
||||
@@ -498,8 +604,8 @@ describe('LoaderManager', () => {
|
||||
secondModel.name = 'second'
|
||||
|
||||
meshLoad
|
||||
.mockImplementationOnce(() => firstLoad)
|
||||
.mockResolvedValueOnce(secondModel)
|
||||
.mockImplementationOnce(() => firstLoad.then(loadResult))
|
||||
.mockResolvedValueOnce(loadResult(secondModel))
|
||||
|
||||
const firstPromise = lm.loadModel('api/view?filename=first.glb')
|
||||
const secondPromise = lm.loadModel('api/view?filename=second.glb')
|
||||
|
||||
@@ -4,9 +4,14 @@ import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
import { MeshModelAdapter } from './MeshModelAdapter'
|
||||
import { createAdapterRef } from './ModelAdapter'
|
||||
import type { AdapterRef, ModelAdapter, ModelLoadContext } from './ModelAdapter'
|
||||
import { PointCloudModelAdapter, getPLYEngine } from './PointCloudModelAdapter'
|
||||
import { createAdapterRef, fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
AdapterRef,
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
} from './ModelAdapter'
|
||||
import { PointCloudModelAdapter } from './PointCloudModelAdapter'
|
||||
import { SplatModelAdapter } from './SplatModelAdapter'
|
||||
import type {
|
||||
EventManagerInterface,
|
||||
@@ -36,14 +41,16 @@ function isNotFoundError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default adapter set: mesh + pointCloud + splat. Each adapter declares the
|
||||
* file extensions it owns; LoaderManager picks one by extension.
|
||||
* 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.
|
||||
*/
|
||||
function defaultAdapters(): ModelAdapter[] {
|
||||
return [
|
||||
new MeshModelAdapter(),
|
||||
new PointCloudModelAdapter(),
|
||||
new SplatModelAdapter()
|
||||
new SplatModelAdapter(),
|
||||
new PointCloudModelAdapter()
|
||||
]
|
||||
}
|
||||
|
||||
@@ -86,6 +93,7 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
|
||||
this.modelManager.clearModel()
|
||||
this.adapterRef.current = null
|
||||
this.adapterRef.capabilities = null
|
||||
|
||||
this.modelManager.originalURL = url
|
||||
|
||||
@@ -122,7 +130,8 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
// can't clobber adapterRef.current that a newer load already
|
||||
// wrote (or cleared).
|
||||
this.adapterRef.current = result.adapter
|
||||
await this.modelManager.setupModel(result.model)
|
||||
this.adapterRef.capabilities = result.capabilities
|
||||
await this.modelManager.setupModel(result.object)
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('modelLoadingEnd', null)
|
||||
@@ -137,19 +146,18 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private pickAdapter(extension: string): ModelAdapter | null {
|
||||
const match = this.adapters.find((adapter) =>
|
||||
adapter.extensions.includes(extension)
|
||||
private async pickAdapter(
|
||||
extension: string,
|
||||
fetchBytes: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelAdapter | null> {
|
||||
const candidates = this.adapters.filter((a) =>
|
||||
a.extensions.includes(extension)
|
||||
)
|
||||
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
|
||||
for (const adapter of candidates) {
|
||||
if (!adapter.matches) return adapter
|
||||
if (await adapter.matches(extension, fetchBytes)) return adapter
|
||||
}
|
||||
return match
|
||||
return null
|
||||
}
|
||||
|
||||
private createLoadContext(): ModelLoadContext {
|
||||
@@ -170,7 +178,11 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
private async loadModelInternal(
|
||||
url: string,
|
||||
fileExtension: string
|
||||
): Promise<{ model: THREE.Object3D; adapter: ModelAdapter } | null> {
|
||||
): Promise<{
|
||||
object: THREE.Object3D
|
||||
adapter: ModelAdapter
|
||||
capabilities: ModelAdapterCapabilities
|
||||
} | null> {
|
||||
const params = new URLSearchParams(url.split('?')[1])
|
||||
const filename = params.get('filename')
|
||||
|
||||
@@ -188,10 +200,24 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
encodeURIComponent(subfolder) +
|
||||
'&filename='
|
||||
|
||||
const adapter = this.pickAdapter(fileExtension)
|
||||
let bytesPromise: Promise<ArrayBuffer> | null = null
|
||||
const fetchBytes = () => (bytesPromise ??= fetchModelData(path, filename))
|
||||
|
||||
const adapter = await this.pickAdapter(fileExtension, fetchBytes)
|
||||
if (!adapter) return null
|
||||
|
||||
const model = await adapter.load(this.createLoadContext(), path, filename)
|
||||
return model ? { model, adapter } : null
|
||||
const loadResult = await adapter.load(
|
||||
this.createLoadContext(),
|
||||
path,
|
||||
filename,
|
||||
fetchBytes
|
||||
)
|
||||
return loadResult
|
||||
? {
|
||||
object: loadResult.object,
|
||||
capabilities: loadResult.capabilities,
|
||||
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).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.children[0]).toBeInstanceOf(THREE.Mesh)
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.object.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).toBe(fbxModel)
|
||||
expect(result!.object).toBe(fbxModel)
|
||||
})
|
||||
|
||||
it('disables frustum culling on SkinnedMesh children', async () => {
|
||||
@@ -224,7 +224,7 @@ describe('MeshModelAdapter', () => {
|
||||
'cube.obj'
|
||||
)
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result!.object).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).toBe(scene)
|
||||
expect(result!.object).toBe(scene)
|
||||
})
|
||||
|
||||
it('also handles .gltf filenames', async () => {
|
||||
|
||||
@@ -11,7 +11,8 @@ import OBJLoader2WorkerUrl from 'wwobjloader2/bundle/worker/module?url'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
ModelLoadContext,
|
||||
ModelLoadResult
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class MeshModelAdapter implements ModelAdapter {
|
||||
@@ -45,20 +46,18 @@ export class MeshModelAdapter implements ModelAdapter {
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
): Promise<ModelLoadResult | null> {
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
private async loadSTL(
|
||||
|
||||
@@ -65,24 +65,59 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export type AdapterRef = { current: ModelAdapter | null }
|
||||
export type ModelLoadResult = {
|
||||
object: THREE.Object3D
|
||||
capabilities: ModelAdapterCapabilities
|
||||
}
|
||||
|
||||
export const createAdapterRef = (): AdapterRef => ({ current: 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 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
|
||||
): Promise<THREE.Object3D | null>
|
||||
filename: string,
|
||||
fetchBytes?: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelLoadResult | 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,31 +15,40 @@ 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 = vi.fn(() => makePLYGeometry(false))
|
||||
parse = plyLoaderParse
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./loader/FastPLYLoader', () => ({
|
||||
FastPLYLoader: class {
|
||||
parse = vi.fn(() => makePLYGeometry(false))
|
||||
parse = fastPlyLoaderParse
|
||||
}
|
||||
}))
|
||||
|
||||
function makePLYGeometry(withColors: boolean): THREE.BufferGeometry {
|
||||
function makePLYGeometry(opts: {
|
||||
withColors?: boolean
|
||||
withFaces?: 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 (withColors) {
|
||||
if (opts.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
|
||||
}
|
||||
|
||||
@@ -96,8 +105,8 @@ describe('PointCloudModelAdapter', () => {
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.object.children[0]
|
||||
expect(child).toBeInstanceOf(THREE.Mesh)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -108,9 +117,57 @@ describe('PointCloudModelAdapter', () => {
|
||||
|
||||
const result = await adapter.load(ctx, '/api/view?', 'cloud.ply')
|
||||
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.children[0]
|
||||
expect(result!.object).toBeInstanceOf(THREE.Group)
|
||||
const child = result!.object.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,27 +8,30 @@ import { fetchModelData } from './ModelAdapter'
|
||||
import type {
|
||||
ModelAdapter,
|
||||
ModelAdapterCapabilities,
|
||||
ModelLoadContext
|
||||
ModelLoadContext,
|
||||
ModelLoadResult
|
||||
} from './ModelAdapter'
|
||||
import type { MaterialMode } from './interfaces'
|
||||
import { FastPLYLoader } from './loader/FastPLYLoader'
|
||||
|
||||
export function getPLYEngine(): string {
|
||||
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: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: true,
|
||||
gizmoTransform: false,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
readonly capabilities = POINT_CLOUD_CAPABILITIES
|
||||
|
||||
private readonly plyLoader = new PLYLoader()
|
||||
private readonly fastPlyLoader = new FastPLYLoader()
|
||||
@@ -36,9 +39,10 @@ export class PointCloudModelAdapter implements ModelAdapter {
|
||||
async load(
|
||||
ctx: ModelLoadContext,
|
||||
path: string,
|
||||
filename: string
|
||||
): Promise<THREE.Object3D | null> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
filename: string,
|
||||
fetchBytes?: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelLoadResult | null> {
|
||||
const arrayBuffer = await (fetchBytes?.() ?? fetchModelData(path, filename))
|
||||
const isASCII = isPLYAsciiFormat(arrayBuffer)
|
||||
|
||||
const plyGeometry =
|
||||
@@ -50,12 +54,18 @@ export class PointCloudModelAdapter implements ModelAdapter {
|
||||
plyGeometry.computeVertexNormals()
|
||||
|
||||
const hasVertexColors = plyGeometry.attributes.color !== undefined
|
||||
const hasFaces = (plyGeometry.index?.count ?? 0) > 0
|
||||
|
||||
if (ctx.materialMode === 'pointCloud') {
|
||||
return buildPointsGroup(ctx, plyGeometry, hasVertexColors)
|
||||
}
|
||||
const object =
|
||||
ctx.materialMode === 'pointCloud' || !hasFaces
|
||||
? buildPointsGroup(ctx, plyGeometry, hasVertexColors)
|
||||
: buildMeshGroup(ctx, plyGeometry, hasVertexColors)
|
||||
|
||||
return buildMeshGroup(ctx, plyGeometry, hasVertexColors)
|
||||
const capabilities = hasFaces
|
||||
? POINT_CLOUD_CAPABILITIES
|
||||
: { ...POINT_CLOUD_CAPABILITIES, materialModes: ['pointCloud'] as const }
|
||||
|
||||
return { object, capabilities }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@ 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
|
||||
@@ -45,9 +56,23 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.getActiveCamera = getActiveCamera
|
||||
|
||||
// Spark 2.x requires a SparkRenderer in the scene tree to render SplatMesh
|
||||
// (Gaussian splat) instances; without it splats are silent no-ops. Kept
|
||||
// alive across model reloads by SceneModelManager.clearModel.
|
||||
this.sparkRenderer = new SparkRenderer({ renderer })
|
||||
// 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?.()
|
||||
}
|
||||
})
|
||||
this.scene.add(this.sparkRenderer)
|
||||
|
||||
this.gridHelper = new THREE.GridHelper(20, 20)
|
||||
|
||||
@@ -435,6 +435,11 @@ 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'
|
||||
@@ -456,6 +461,12 @@ 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 }) => void>(),
|
||||
ctor: vi.fn<(opts: { fileBytes: ArrayBuffer; fileName?: string }) => 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 }) {
|
||||
constructor(opts: { fileBytes: ArrayBuffer; fileName?: string }) {
|
||||
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'])
|
||||
expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat', 'ply'])
|
||||
})
|
||||
|
||||
it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
|
||||
@@ -85,12 +85,18 @@ describe('SplatModelAdapter', () => {
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
expect(splatMeshSpies.ctor).toHaveBeenCalledWith({ fileBytes: buf })
|
||||
expect(result).toBeInstanceOf(THREE.Group)
|
||||
expect(result.children).toHaveLength(1)
|
||||
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(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(result.children[0])
|
||||
expect(ctx.setOriginalModel).toHaveBeenCalledWith(
|
||||
result!.object.children[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('rotates the splat 180° around X (OpenCV → three.js convention)', async () => {
|
||||
@@ -100,7 +106,7 @@ describe('SplatModelAdapter', () => {
|
||||
'scene.splat'
|
||||
)
|
||||
|
||||
const splat = result.children[0]
|
||||
const splat = result!.object.children[0]
|
||||
expect(splat.quaternion.x).toBe(1)
|
||||
expect(splat.quaternion.y).toBe(0)
|
||||
expect(splat.quaternion.z).toBe(0)
|
||||
@@ -121,11 +127,12 @@ describe('SplatModelAdapter', () => {
|
||||
describe('computeBounds', () => {
|
||||
it('returns the SplatMesh bounding box transformed to world space', async () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
const group = await adapter.load(
|
||||
const result = await adapter.load(
|
||||
makeContext(),
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
const group = result!.object
|
||||
const splat = group.children[0]
|
||||
splat.position.set(10, 0, 0)
|
||||
|
||||
@@ -152,13 +159,13 @@ describe('SplatModelAdapter', () => {
|
||||
describe('disposeModel', () => {
|
||||
it('calls dispose on every SplatMesh in the model tree', async () => {
|
||||
const adapter = new SplatModelAdapter()
|
||||
const group = await adapter.load(
|
||||
const result = await adapter.load(
|
||||
makeContext(),
|
||||
'/api/view?',
|
||||
'scene.splat'
|
||||
)
|
||||
|
||||
adapter.disposeModel(group)
|
||||
adapter.disposeModel(result!.object)
|
||||
|
||||
expect(splatMeshSpies.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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
|
||||
ModelLoadContext,
|
||||
ModelLoadResult
|
||||
} from './ModelAdapter'
|
||||
|
||||
export class SplatModelAdapter implements ModelAdapter {
|
||||
readonly kind = 'splat' as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat'] as const
|
||||
readonly extensions = ['spz', 'splat', 'ksplat', 'ply'] as const
|
||||
readonly capabilities: ModelAdapterCapabilities = {
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
@@ -21,21 +24,33 @@ 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
|
||||
): Promise<THREE.Object3D> {
|
||||
const arrayBuffer = await fetchModelData(path, filename)
|
||||
filename: string,
|
||||
fetchBytes?: () => Promise<ArrayBuffer>
|
||||
): Promise<ModelLoadResult> {
|
||||
const arrayBuffer = await (fetchBytes?.() ?? fetchModelData(path, filename))
|
||||
|
||||
const splatMesh = new SplatMesh({ fileBytes: arrayBuffer })
|
||||
const splatMesh = new SplatMesh({
|
||||
fileBytes: arrayBuffer,
|
||||
fileName: filename
|
||||
})
|
||||
await splatMesh.initialized
|
||||
splatMesh.quaternion.set(1, 0, 0, 0)
|
||||
ctx.setOriginalModel(splatMesh)
|
||||
|
||||
const splatGroup = new THREE.Group()
|
||||
splatGroup.add(splatMesh)
|
||||
return splatGroup
|
||||
return { object: splatGroup, capabilities: this.capabilities }
|
||||
}
|
||||
|
||||
computeBounds(model: THREE.Object3D): THREE.Box3 | null {
|
||||
|
||||
@@ -125,7 +125,11 @@ vi.mock('./Load3d', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
type FakeLoaderManager = { adapterRefArg: { current: ModelAdapter | null } }
|
||||
type FakeAdapterRef = {
|
||||
current: ModelAdapter | null
|
||||
capabilities: ModelAdapterCapabilities | null
|
||||
}
|
||||
type FakeLoaderManager = { adapterRefArg: FakeAdapterRef }
|
||||
type FakeSceneModelManager = {
|
||||
getCurrentCapabilities: () => unknown
|
||||
getBoundsFromAdapter: (model: unknown) => unknown
|
||||
@@ -134,7 +138,7 @@ type FakeSceneModelManager = {
|
||||
}
|
||||
type FakeLoad3d = {
|
||||
deps: {
|
||||
adapterRef: { current: ModelAdapter | null }
|
||||
adapterRef: FakeAdapterRef
|
||||
loaderManager: FakeLoaderManager
|
||||
modelManager: FakeSceneModelManager
|
||||
}
|
||||
@@ -222,6 +226,7 @@ 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.current?.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
|
||||
() => adapterRef.capabilities ?? DEFAULT_MODEL_CAPABILITIES,
|
||||
(model) => adapterRef.current?.computeBounds?.(model) ?? null,
|
||||
(model) => adapterRef.current?.disposeModel?.(model),
|
||||
() => adapterRef.current?.defaultCameraPose?.() ?? null
|
||||
|
||||
18
src/extensions/core/load3d/nodeTypes.ts
Normal file
18
src/extensions/core/load3d/nodeTypes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
type Hook = (
|
||||
@@ -83,7 +84,13 @@ describe('load3dLazy', () => {
|
||||
expect(enabledExtensionsGetter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for(['Load3D', 'Preview3D', 'SaveGLB'])(
|
||||
it.for([
|
||||
'Load3D',
|
||||
'Preview3D',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud',
|
||||
'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'
|
||||
|
||||
const LOAD3D_NODE_TYPES = new Set(['Load3D', 'Preview3D', 'SaveGLB'])
|
||||
import { isLoad3dNode } from './load3d/nodeTypes'
|
||||
|
||||
let load3dExtensionsLoaded = false
|
||||
let load3dExtensionsLoading: Promise<ComfyExtension[]> | null = null
|
||||
@@ -34,8 +34,12 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
|
||||
|
||||
load3dExtensionsLoading = (async () => {
|
||||
const before = new Set(useExtensionStore().enabledExtensions)
|
||||
// Import both extensions - they will self-register via useExtensionService()
|
||||
await Promise.all([import('./load3d'), import('./saveMesh')])
|
||||
// Import extensions - they self-register via useExtensionService()
|
||||
await Promise.all([
|
||||
import('./load3d'),
|
||||
import('./load3dPreviewExtensions'),
|
||||
import('./saveMesh')
|
||||
])
|
||||
load3dExtensionsLoaded = true
|
||||
return useExtensionStore().enabledExtensions.filter(
|
||||
(ext) => !before.has(ext)
|
||||
@@ -45,13 +49,6 @@ 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',
|
||||
@@ -60,7 +57,7 @@ useExtensionService().registerExtension({
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
if (isLoad3dNodeType(nodeData.name)) {
|
||||
if (isLoad3dNode(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') {
|
||||
|
||||
372
src/extensions/core/load3dPreviewExtensions.test.ts
Normal file
372
src/extensions/core/load3dPreviewExtensions.test.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
212
src/extensions/core/load3dPreviewExtensions.ts
Normal file
212
src/extensions/core/load3dPreviewExtensions.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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')
|
||||
)
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "محرك PLY",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "اختر المحرك لتحميل ملفات PLY. \"threejs\" يستخدم محمل Three.js PLY الأصلي (الأفضل لملفات الشبكة). \"fastply\" يستخدم محملًا محسنًا لملفات PLY السحابية النقطية بنسق ASCII. \"sparkjs\" يستخدم Spark.js لملفات PLY الخاصة بتقنية Gaussian Splatting ثلاثية الأبعاد."
|
||||
|
||||
@@ -1971,6 +1971,7 @@
|
||||
"materialMode": "Material Mode",
|
||||
"showSkeleton": "Show Skeleton",
|
||||
"fitToViewer": "Fit to Viewer",
|
||||
"centerCameraOnModel": "Center Camera on Model",
|
||||
"scene": "Scene",
|
||||
"model": "Model",
|
||||
"camera": "Camera",
|
||||
|
||||
@@ -196,12 +196,11 @@
|
||||
"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": "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.",
|
||||
"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.",
|
||||
"options": {
|
||||
"threejs": "threejs",
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs"
|
||||
"fastply": "fastply"
|
||||
}
|
||||
},
|
||||
"Comfy_Load3D_ShowGrid": {
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "Motor PLY",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "Selecciona el motor para cargar archivos PLY. \"threejs\" utiliza el cargador nativo Three.js PLYLoader (mejor para archivos PLY de malla). \"fastply\" utiliza un cargador optimizado para archivos PLY de nube de puntos ASCII. \"sparkjs\" utiliza Spark.js para archivos PLY de Gaussian Splatting 3D."
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "موتور PLY",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "موتور بارگذاری فایلهای PLY را انتخاب کنید. «threejs» از PLYLoader بومی Three.js استفاده میکند (مناسب برای فایلهای مش PLY). «fastply» از یک بارگذار بهینهشده برای فایلهای point cloud PLY به صورت ASCII استفاده میکند. «sparkjs» از Spark.js برای فایلهای 3D Gaussian Splatting PLY استفاده میکند."
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "Moteur PLY",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "Sélectionnez le moteur pour charger les fichiers PLY. « threejs » utilise le PLYLoader natif de Three.js (idéal pour les fichiers PLY de maillage). « fastply » utilise un chargeur optimisé pour les fichiers PLY de nuages de points ASCII. « sparkjs » utilise Spark.js pour les fichiers PLY de Gaussian Splatting 3D."
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "PLYエンジン",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "PLYファイルを読み込むエンジンを選択します。「threejs」はネイティブのThree.js PLYLoaderを使用(メッシュPLYファイルに最適)。「fastply」はASCIIポイントクラウドPLYファイル用の最適化ローダーを使用。「sparkjs」は3DガウシアンスプラッティングPLYファイル用にSpark.jsを使用します。"
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "PLY 엔진",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "PLY 파일을 불러올 엔진을 선택합니다. \"threejs\"는 네이티브 Three.js PLYLoader를 사용하며(메시 PLY 파일에 적합), \"fastply\"는 ASCII 포인트 클라우드 PLY 파일에 최적화된 로더를 사용합니다. \"sparkjs\"는 3D Gaussian Splatting PLY 파일에 Spark.js를 사용합니다."
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "Engine PLY",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "Selecione a engine para carregar arquivos PLY. \"threejs\" usa o PLYLoader nativo do Three.js (melhor para arquivos PLY de malha). \"fastply\" usa um carregador otimizado para arquivos PLY de nuvem de pontos ASCII. \"sparkjs\" usa Spark.js para arquivos PLY de Gaussian Splatting 3D."
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "Движок PLY",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "Выберите движок для загрузки PLY файлов. \"threejs\" использует встроенный загрузчик Three.js PLYLoader (лучше всего подходит для файлов сетки PLY). \"fastply\" использует оптимизированный загрузчик для ASCII PLY файлов облака точек. \"sparkjs\" использует Spark.js для PLY файлов с 3D Gaussian Splatting."
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "PLY Motoru",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "PLY dosyalarını yüklemek için motoru seçin. \"threejs\" yerel Three.js PLYLoader'ı kullanır (ağ PLY dosyaları için en iyisi). \"fastply\" ASCII nokta bulutu PLY dosyaları için optimize edilmiş bir yükleyici kullanır. \"sparkjs\" ise 3D Gaussian Splatting PLY dosyaları için Spark.js kullanır."
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
"name": "PLY 引擎",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "選擇用於載入 PLY 檔案的引擎。「threejs」使用原生 Three.js PLYLoader(適合網格 PLY 檔案)。「fastply」使用優化的 ASCII 點雲 PLY 檔案載入器。「sparkjs」使用 Spark.js 處理 3D Gaussian Splatting PLY 檔案。"
|
||||
|
||||
@@ -2009,6 +2009,7 @@
|
||||
"exportRecording": "导出录制",
|
||||
"exportingModel": "正在导出模型...",
|
||||
"fitToViewer": "适应视图",
|
||||
"centerCameraOnModel": "将相机居中到模型",
|
||||
"fov": "视场",
|
||||
"gizmo": {
|
||||
"label": "控件",
|
||||
|
||||
@@ -196,13 +196,12 @@
|
||||
"tooltip": "设置3D场景允许的最小光照强度值。此值定义在任何3D组件中调整光照时可设置的最小亮度。"
|
||||
},
|
||||
"Comfy_Load3D_PLYEngine": {
|
||||
"name": "PLY引擎",
|
||||
"name": "点云引擎",
|
||||
"options": {
|
||||
"fastply": "fastply",
|
||||
"sparkjs": "sparkjs",
|
||||
"threejs": "threejs"
|
||||
},
|
||||
"tooltip": "选择用于加载PLY文件的引擎。\"threejs\" 使用原生Three.js PLYLoader(适合网格PLY文件)。\"fastply\" 用于优化的ASCII点云PLY文件加载。\"sparkjs\" 用于3D高斯斑点PLY文件的Spark.js。"
|
||||
"tooltip": "选择用于加载点云PLY文件的引擎。\"threejs\" 使用原生Three.js PLYLoader(支持二进制和ASCII,可处理网格)。\"fastply\" 使用针对ASCII PLY的优化解析器。3D高斯泼溅PLY会自动识别并始终通过sparkjs渲染,不受此设置影响。"
|
||||
},
|
||||
"Comfy_Load3D_ShowGrid": {
|
||||
"name": "初始网格可见性",
|
||||
|
||||
65
src/platform/cloud/onboarding/onboardingCloudRoutes.test.ts
Normal file
65
src/platform/cloud/onboarding/onboardingCloudRoutes.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { oauthConsentRedirect } from '@/platform/cloud/onboarding/onboardingCloudRoutes'
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
clearOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
|
||||
const VALID_REQUEST_ID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
const createSessionOrThrow = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@/platform/auth/session/useSessionCookie', () => ({
|
||||
useSessionCookie: () => ({ createSessionOrThrow })
|
||||
}))
|
||||
|
||||
describe('oauthConsentRedirect', () => {
|
||||
beforeEach(() => {
|
||||
clearOAuthRequestId()
|
||||
createSessionOrThrow.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('routes to user-check and mints no session when no OAuth flow is pending', async () => {
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(target).toEqual({ name: 'cloud-user-check' })
|
||||
expect(createSessionOrThrow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mints the Cloud session cookie before redirecting to consent when resuming OAuth', async () => {
|
||||
// Regression: an already-signed-in user (Firebase) carries no Cloud session
|
||||
// cookie, so the consent challenge fetch fails unless the cookie is minted
|
||||
// here, mirroring the post-login resume path.
|
||||
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
|
||||
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(createSessionOrThrow).toHaveBeenCalledOnce()
|
||||
expect(target).toEqual({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
})
|
||||
|
||||
it('still lands on consent when session minting fails so the view can surface the error', async () => {
|
||||
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
|
||||
createSessionOrThrow.mockRejectedValue(new Error('Unauthorized'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(target).toEqual({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Failed to establish Cloud session cookie before OAuth consent:',
|
||||
expect.any(Error)
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,14 +5,39 @@ import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
|
||||
// `oauth_request_id` capture lives in the global router.beforeEach guard
|
||||
// (src/router.ts), which runs before any per-route beforeEnter. Per-route
|
||||
// guards read it back via getOAuthRequestId().
|
||||
function oauthConsentRedirect() {
|
||||
//
|
||||
// When an already-signed-in user is bounced to login/signup mid-OAuth, we skip
|
||||
// the sign-in step and jump straight to consent. The consent challenge fetch
|
||||
// (GET /oauth/authorize) is authenticated by the Cloud *session cookie*, which
|
||||
// is a separate credential from the Firebase client login that `isLoggedIn`
|
||||
// reflects. The post-login resume path mints that cookie via
|
||||
// `createSessionOrThrow` (see useOAuthPostLoginRedirect); the already-signed-in
|
||||
// path must do the same. Without it the consent fetch is unauthenticated, the
|
||||
// backend 302s it to login, and the consent view fails with
|
||||
// "OAuth request failed. Please restart from the client app."
|
||||
export async function oauthConsentRedirect() {
|
||||
const oauthRequestId = getOAuthRequestId()
|
||||
return oauthRequestId
|
||||
? {
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
}
|
||||
: { name: 'cloud-user-check' }
|
||||
if (!oauthRequestId) return { name: 'cloud-user-check' }
|
||||
|
||||
try {
|
||||
const { useSessionCookie } =
|
||||
await import('@/platform/auth/session/useSessionCookie')
|
||||
await useSessionCookie().createSessionOrThrow()
|
||||
} catch (error) {
|
||||
// Best effort: if the cookie can't be minted (e.g. an expired Firebase
|
||||
// token), still land on the consent view so it can surface the failure and
|
||||
// prompt the user to restart from the client app, rather than silently
|
||||
// dropping the OAuth flow.
|
||||
console.warn(
|
||||
'Failed to establish Cloud session cookie before OAuth consent:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
}
|
||||
}
|
||||
|
||||
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
@@ -34,7 +59,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next(oauthConsentRedirect())
|
||||
return next(await oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
@@ -52,7 +77,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next(oauthConsentRedirect())
|
||||
return next(await oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
|
||||
@@ -70,6 +70,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
this.dispatch((provider) => provider.trackUserLoggedIn?.())
|
||||
}
|
||||
|
||||
trackLogout(): void {
|
||||
this.dispatch((provider) => provider.trackLogout?.())
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
event: 'modal_opened' | 'subscribe_clicked',
|
||||
metadata?: SubscriptionMetadata
|
||||
|
||||
@@ -7,6 +7,7 @@ const hoisted = vi.hoisted(() => {
|
||||
const mockInit = vi.fn()
|
||||
const mockIdentify = vi.fn()
|
||||
const mockPeopleSet = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockOnUserResolved = vi.fn()
|
||||
|
||||
return {
|
||||
@@ -14,13 +15,15 @@ const hoisted = vi.hoisted(() => {
|
||||
mockInit,
|
||||
mockIdentify,
|
||||
mockPeopleSet,
|
||||
mockReset,
|
||||
mockOnUserResolved,
|
||||
mockPosthog: {
|
||||
default: {
|
||||
init: mockInit,
|
||||
capture: mockCapture,
|
||||
identify: mockIdentify,
|
||||
people: { set: mockPeopleSet }
|
||||
people: { set: mockPeopleSet },
|
||||
reset: mockReset
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +59,29 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const mockAppMode = vi.hoisted(() => ({
|
||||
mode: { value: 'app' },
|
||||
isAppMode: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => mockAppMode
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({
|
||||
getExecutionContext: () => ({
|
||||
is_template: false,
|
||||
workflow_name: 'untitled',
|
||||
custom_node_count: 0,
|
||||
total_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_node_names: []
|
||||
})
|
||||
}))
|
||||
|
||||
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
|
||||
|
||||
function createProvider(
|
||||
@@ -72,6 +98,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRemoteConfig.value = null
|
||||
mockAppMode.isAppMode.value = false
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token'
|
||||
} as typeof window.__CONFIG__
|
||||
@@ -236,6 +263,42 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('calls posthog.reset(true) when logout fires after init', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackLogout()
|
||||
|
||||
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('defers reset to init when logout fires before init resolves', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackLogout()
|
||||
expect(hoisted.mockReset).not.toHaveBeenCalled()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('drops pre-logout queued events when logout fires before init', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackUserLoggedIn()
|
||||
provider.trackLogout()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_LOGGED_IN,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('page view', () => {
|
||||
it('captures page view with page_name property', async () => {
|
||||
const provider = createProvider()
|
||||
@@ -263,4 +326,91 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution tracking', () => {
|
||||
it('stamps is_app_mode and view_mode on the execution_start event', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
mockAppMode.isAppMode.value = true
|
||||
mockAppMode.mode.value = 'app'
|
||||
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.EXECUTION_START,
|
||||
expect.objectContaining({
|
||||
is_app_mode: true,
|
||||
view_mode: 'app',
|
||||
trigger_source: 'unknown'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
describe('before_send', () => {
|
||||
it('strips PII keys from event properties, $set, and $set_once', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const { before_send } = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
const event = {
|
||||
event: 'test',
|
||||
properties: {
|
||||
email: 'props@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'props_user@example.com',
|
||||
$email: 'props_posthog@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'set@example.com',
|
||||
user_email: 'set_user@example.com',
|
||||
$email: 'set_posthog@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'set_once@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = before_send(event)
|
||||
|
||||
// event.properties — all four PII keys stripped, non-PII preserved
|
||||
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')
|
||||
|
||||
// event.$set — PII stripped, non-PII preserved
|
||||
// posthog.identify(id, { email }) lands here, not in properties
|
||||
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')
|
||||
|
||||
// event.$set_once — PII stripped, non-PII preserved
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
|
||||
const remoteBefore_send = vi.fn()
|
||||
mockRemoteConfig.value = {
|
||||
posthog_config: {
|
||||
before_send: remoteBefore_send,
|
||||
person_profiles: 'always'
|
||||
}
|
||||
}
|
||||
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const initConfig = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
expect(initConfig.before_send).not.toBe(remoteBefore_send)
|
||||
expect(initConfig.person_profiles).toBe('identified_only')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
@@ -83,6 +85,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private posthog: PostHog | null = null
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private isInitialized = false
|
||||
private shouldResetOnInit = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
|
||||
@@ -114,9 +117,20 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
...serverConfig
|
||||
...serverConfig,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
|
||||
// automatically when persistence includes 'cookie' (the default).
|
||||
// Explicit override interacts badly with posthog-js#3578 where reset() fails
|
||||
// to clear localStorage on other subdomains, causing identity bleed on logout.
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
this.isInitialized = true
|
||||
if (this.shouldResetOnInit) {
|
||||
this.posthog!.reset(true)
|
||||
this.eventQueue = []
|
||||
this.shouldResetOnInit = false
|
||||
}
|
||||
this.flushEventQueue()
|
||||
|
||||
useCurrentUser().onUserResolved((user) => {
|
||||
@@ -240,6 +254,15 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
|
||||
}
|
||||
|
||||
trackLogout(): void {
|
||||
if (!this.posthog) {
|
||||
this.shouldResetOnInit = true
|
||||
this.eventQueue = []
|
||||
return
|
||||
}
|
||||
this.posthog.reset(true)
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
event: 'modal_opened' | 'subscribe_clicked',
|
||||
metadata?: SubscriptionMetadata
|
||||
@@ -425,9 +448,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = getExecutionContext()
|
||||
const { isAppMode, mode } = useAppMode()
|
||||
const eventContext: ExecutionContext = {
|
||||
...context,
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown'
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown',
|
||||
is_app_mode: isAppMode.value,
|
||||
view_mode: mode.value
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
|
||||
this.lastTriggerSource = undefined
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface ExecutionContext {
|
||||
toolkit_node_names: string[]
|
||||
toolkit_node_count: number
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
is_app_mode?: boolean
|
||||
view_mode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +109,9 @@ export interface ExecutionErrorMetadata {
|
||||
nodeId?: string
|
||||
nodeType?: string
|
||||
error?: string
|
||||
is_app_mode?: boolean
|
||||
workflow_id?: string
|
||||
view_mode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,6 +119,9 @@ export interface ExecutionErrorMetadata {
|
||||
*/
|
||||
export interface ExecutionSuccessMetadata {
|
||||
jobId: string
|
||||
is_app_mode?: boolean
|
||||
workflow_id?: string
|
||||
view_mode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,15 +159,19 @@ export interface WorkflowImportMetadata {
|
||||
| 'template'
|
||||
| 'shared_url'
|
||||
| 'unknown'
|
||||
/** Whether the imported/opened workflow is an app (extra.linearMode). */
|
||||
is_app?: boolean
|
||||
}
|
||||
|
||||
export interface EnterLinearMetadata {
|
||||
source?: string
|
||||
workflow_id?: string
|
||||
}
|
||||
|
||||
export interface WorkflowSavedMetadata {
|
||||
is_app: boolean
|
||||
is_new: boolean
|
||||
workflow_id?: string
|
||||
}
|
||||
|
||||
export interface DefaultViewSetMetadata {
|
||||
@@ -175,6 +187,8 @@ type ShareFlowStep =
|
||||
export interface ShareFlowMetadata {
|
||||
step: ShareFlowStep
|
||||
source?: 'app_mode' | 'graph_mode'
|
||||
is_app?: boolean
|
||||
workflow_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,6 +398,7 @@ export interface TelemetryProvider {
|
||||
trackSignupOpened?(): void
|
||||
trackAuth?(metadata: AuthMetadata): void
|
||||
trackUserLoggedIn?(): void
|
||||
trackLogout?(): void
|
||||
|
||||
// Subscription flow events
|
||||
trackSubscription?(
|
||||
|
||||
35
src/platform/telemetry/utils/workflowTelemetryId.test.ts
Normal file
35
src/platform/telemetry/utils/workflowTelemetryId.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
import { workflowTelemetryId } from './workflowTelemetryId'
|
||||
|
||||
function fakeWorkflow(
|
||||
activeId: string | null,
|
||||
initialId: string | null
|
||||
): ComfyWorkflow {
|
||||
return {
|
||||
activeState: activeId === null ? null : { id: activeId },
|
||||
initialState: initialId === null ? null : { id: initialId }
|
||||
} as unknown as ComfyWorkflow
|
||||
}
|
||||
|
||||
describe('workflowTelemetryId', () => {
|
||||
it('prefers the active state id', () => {
|
||||
expect(workflowTelemetryId(fakeWorkflow('active-1', 'initial-1'))).toBe(
|
||||
'active-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the initial state id when the active state has no id', () => {
|
||||
expect(workflowTelemetryId(fakeWorkflow(null, 'initial-1'))).toBe(
|
||||
'initial-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined when no id is available', () => {
|
||||
expect(workflowTelemetryId(fakeWorkflow(null, null))).toBeUndefined()
|
||||
expect(workflowTelemetryId(null)).toBeUndefined()
|
||||
expect(workflowTelemetryId(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
12
src/platform/telemetry/utils/workflowTelemetryId.ts
Normal file
12
src/platform/telemetry/utils/workflowTelemetryId.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
|
||||
/**
|
||||
* Stable id used to attribute telemetry to a workflow/app. Mirrors the id
|
||||
* execution events report (active state, falling back to initial state) so
|
||||
* created and opened events can be joined to runs of the same workflow.
|
||||
*/
|
||||
export function workflowTelemetryId(
|
||||
workflow: ComfyWorkflow | null | undefined
|
||||
): string | undefined {
|
||||
return workflow?.activeState?.id ?? workflow?.initialState?.id
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workflowTelemetryId } from '@/platform/telemetry/utils/workflowTelemetryId'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
@@ -178,6 +179,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
}
|
||||
|
||||
let savedWorkflow = workflow
|
||||
if (isSelfOverwrite) {
|
||||
workflow.changeTracker?.prepareForSave()
|
||||
// Call workflowStore.saveWorkflow directly: saveWorkflowAs emits its own is_new:true event below, so delegating to saveWorkflow() would also fire is_new:false and run prepareForSave a second time.
|
||||
@@ -199,9 +201,14 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
target.changeTracker?.prepareForSave()
|
||||
await workflowStore.saveWorkflow(target)
|
||||
savedWorkflow = target
|
||||
}
|
||||
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
|
||||
useTelemetry()?.trackWorkflowSaved({
|
||||
is_app: isApp,
|
||||
is_new: true,
|
||||
workflow_id: workflowTelemetryId(savedWorkflow)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -240,7 +247,11 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
|
||||
useTelemetry()?.trackWorkflowSaved({
|
||||
is_app: isApp,
|
||||
is_new: false,
|
||||
workflow_id: workflowTelemetryId(workflow)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -458,7 +469,10 @@ export const useWorkflowService = () => {
|
||||
|
||||
function trackIfEnteringApp(workflow: ComfyWorkflow) {
|
||||
if (!wasAppMode && workflow.initialMode === 'app') {
|
||||
useTelemetry()?.trackEnterLinear({ source: 'workflow' })
|
||||
useTelemetry()?.trackEnterLinear({
|
||||
source: 'workflow',
|
||||
workflow_id: workflowTelemetryId(workflow)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@
|
||||
<template v-else-if="sharedWorkflow">
|
||||
<main :class="cn('flex gap-8 px-8 pt-4 pb-6', !hasAssets && 'flex-col')">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-12 py-4">
|
||||
<h2 class="m-0 text-2xl font-semibold text-base-foreground">
|
||||
<h2
|
||||
class="m-0 text-2xl font-semibold wrap-anywhere text-base-foreground"
|
||||
>
|
||||
{{ workflowName }}
|
||||
</h2>
|
||||
<p
|
||||
|
||||
@@ -206,6 +206,7 @@ import { useWorkflowService } from '@/platform/workflow/core/services/workflowSe
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workflowTelemetryId } from '@/platform/telemetry/utils/workflowTelemetryId'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -437,7 +438,9 @@ const {
|
||||
acknowledged.value = false
|
||||
useTelemetry()?.trackShareFlow({
|
||||
step: 'link_created',
|
||||
source: getShareSource()
|
||||
source: getShareSource(),
|
||||
is_app: workflow.initialMode === 'app',
|
||||
workflow_id: workflowTelemetryId(workflow)
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -80,6 +80,10 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({ activeWorkflow: null })
|
||||
}))
|
||||
|
||||
describe('useTemplateUrlLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workflowTelemetryId } from '@/platform/telemetry/utils/workflowTelemetryId'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
@@ -123,7 +125,10 @@ export function useTemplateUrlLoader() {
|
||||
})
|
||||
} else if (modeParam === 'linear') {
|
||||
// Set linear mode after successful template load
|
||||
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
|
||||
useTelemetry()?.trackEnterLinear({
|
||||
source: 'template_url',
|
||||
workflow_id: workflowTelemetryId(useWorkflowStore().activeWorkflow)
|
||||
})
|
||||
canvasStore.linearMode = true
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user