Compare commits

...

7 Commits

Author SHA1 Message Date
huang47
215550ae3d fix: keep Vitest guidance out of Playwright reviews 2026-07-10 23:10:49 -07:00
huang47
8edfe454eb ci: route reviews to repository guidance 2026-07-10 10:47:55 -07:00
Terry Jia
4ed2fe70f3 feat: accept bboxes input and add grid snapping to Create Bounding Boxes (#13376)
## Summary
- Seed/override the canvas from an upstream bboxes input
- Add dotted grid background and magnetic snap-to-grid controls
- Darken the canvas well for contrast

BE is https://github.com/Comfy-Org/ComfyUI/pull/14724

## Screenshots (if applicable)



https://github.com/user-attachments/assets/7282f4d5-7cac-46f0-9d73-d0add37f4eb9
2026-07-10 15:40:17 +00:00
Terry Jia
5da5ee5031 feat: wire up Save 3D (Advanced) node family (CORE-329) (#13330)
## Summary
Register the save-side advanced nodes in the Load3D viewer
infrastructure: Save3DAdvanced reuses the mesh advanced extension, while
SaveGaussianSplat and SavePointCloud reuse the splat/point cloud preview
extensions.

Parameterize both extension factories with a loadFolder so save nodes
load the persisted file from the output folder instead of temp, and add
the node types to the lazy-load and viewport-state sets.

BE change https://github.com/Comfy-Org/ComfyUI/pull/14701
## Screenshots (if applicable)
Save 3D (Advanced)
<img width="1328" height="939" alt="image"
src="https://github.com/user-attachments/assets/c5f3cbe0-6e57-463c-9128-67490c2fc89e"
/>

Save Splat
<img width="1296" height="1052" alt="image"
src="https://github.com/user-attachments/assets/f05bbcf8-9794-4861-9dd7-3015d38b11d9"
/>
2026-07-10 15:39:51 +00:00
Maanil Verma
ceb5ae1eba fix(cloud): keep survey footer visible on small screens (#13568)
## Summary

Keep the onboarding survey's Back/Submit footer visible on small screens
by scrolling only the question area instead of the whole survey.

## Changes

- **What**: The survey scrolled as a single block , so on short
viewports the button row slid under the template footer (Terms/Privacy).
Now the outer is bounded to its slot and the question wrapper in
`DynamicSurveyForm` scrolls internally with a responsive cap , so option
lists scroll while the footer buttons stay pinned. The step height
animation is unchanged.

## Screen Recording

https://github.com/user-attachments/assets/6d7f6d50-59b8-4dc0-b20e-8f4ca08167c6
2026-07-10 08:30:50 +05:30
Comfy Org PR Bot
9f880c78cb 1.48.1 (#13559)
Patch version increment to 1.48.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 02:16:50 +00:00
Benjamin Lu
3b2eb50f3b feat(cloud): redeem desktop login codes for web-to-desktop identity stitching (GTM-93) (#13418)
## What

Browser half of GTM-93 macOS web→desktop identity stitching: when the
desktop app opens the system browser at cloud login with
`?desktop_login_code=dlc_…`, the frontend redeems that code against the
cloud backend once a Firebase session exists — after **explicit user
approval**.

Reworked on top of the preserved-query `stripAfterCapture` capability
(#13465):

- The `DESKTOP_LOGIN` namespace opts into strip-on-capture: the code is
stashed and removed from the URL **before any navigation completes**, so
it never reaches history, `previousFullPath`, later guards, or telemetry
— the hand-rolled URL scrubbing this PR previously carried (raw-string
parser + three strip sites) is gone.
- `desktopLoginRedemption.ts` is a plain module with a single export,
`installDesktopLoginRedemption(router)`, installed once in `router.ts`'s
cloud block (replaces six per-view/composable trigger sites). Redemption
reads the code only from the stash: per-code state (approval + 2-attempt
transient budget, so a second code gets its own approval and budget),
approval dialog, `POST /api/auth/desktop-login-codes/redeem` with the
raw Firebase ID token (backend route is Firebase-JWT-only), 10s fetch
timeout.
- Triggers: `router.afterEach` (the cloud auth guard settles Firebase
init before navigations complete) plus a lazy watcher on
`authStore.currentUser` for sessions that appear without a navigation
(OAuth-resume error branch, dialog sign-in). One bounded in-page retry
(5s) guarantees an approved sign-in always ends in a success or failure
toast.
- Terminal rejections (400/403/404/409/410) drop the code with an error
toast; transient failures (401/5xx/timeout/network) retry once; budget
exhaustion now surfaces a failure toast instead of dying silently.

## Why

Windows stitches web→desktop at download time via installer stamping;
macOS DMGs can't be stamped, so we stitch at login. The browser is where
both halves meet: the existing `posthog.identify(uid)` merges the web
anon person into the Firebase uid, and the backend emits
`comfy.cloud.identity.login_attributed` (uid ↔ installation_id) at
redeem. The desktop app polls the backend and receives a one-time custom
token — no auth material posted to a desktop loopback server (the
concern that stalled #12983, which this supersedes).

## Security

- **Approval dialog before redeem** — redemption mints the desktop a
sign-in token for *your* account, so a lured click must not be enough
(device-code phishing mitigation). Cancel clears the stash and does
nothing.
- Only the opaque single-use code ever appears in a URL; the tracker
strips it on first sight, pre-navigation, and it is never logged.

## Testing

Vitest, driven through a real router (createRouter/createMemoryHistory,
no vue-router mocks) and the real preserved-query manager: capture/stash
lifecycle, approval gate (no fetch before approve; decline/dismiss
clears; per-code approval), Bearer/body shape, terminal-vs-transient
statuses, timeout abort, bounded in-page retry + failure toast on budget
exhaustion, per-code regressions (second code after
success/decline/exhaustion redeems independently), auth-watcher trigger
(session appearing without navigation), unauthenticated no-op, trigger
coalescing. Typecheck/lint/format clean.

Types are hand-written with a `TODO(@comfyorg/ingest-types)` — the
generated types land automatically once the cloud PR merges and the
type-gen workflow runs.

## Landing order

1. Cloud backend: https://github.com/Comfy-Org/cloud/pull/4736 (until it
ships, redemption never triggers — this PR is inert)
2. #13465 preserved-query strip-on-capture (base of this PR)
3. #13466 global-prompt FIFO queue (runtime dependency: the approval
confirm must settle even if another prompt is open)
4. **This PR**
5. Desktop (activates the flow):
https://github.com/Comfy-Org/Comfy-Desktop/pull/1222

GTM-93 · Supersedes #12983

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-10 02:11:00 +00:00
48 changed files with 2446 additions and 768 deletions

View File

@@ -65,12 +65,39 @@ reviews:
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
path_instructions:
- path: '**/*.ts'
instructions: |
Treat `docs/guidance/typescript.md` as required review context.
- path: '**/*.vue'
instructions: |
Treat `docs/guidance/typescript.md` and
`docs/guidance/vue-components.md` as required review context. For
changed components or views under `src/components/` or `src/views/`,
also apply `docs/guidance/design-standards.md` and assess accessibility.
- path: '**/*.stories.ts'
instructions: |
Treat `docs/guidance/storybook.md` as required review context.
- path: 'src/lib/litegraph/**'
instructions: |
Treat `docs/adr/README.md` as required review context. For widget
serialization changes, also read
`docs/WIDGET_SERIALIZATION.md`.
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`,
`docs/guidance/vitest.md`, and `docs/testing/vitest-patterns.md` as
required review context for every changed Vitest test file.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`,
`docs/guidance/vitest.md`, `docs/testing/vitest-patterns.md`, and
`docs/testing/litegraph-testing.md` as required review context for
every changed LiteGraph Vitest test file.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`,
and `docs/guidance/playwright.md` as required review context for every
changed Playwright test file. For
`browser_tests/`, also read `browser_tests/README.md` and
`browser_tests/AGENTS.md`, and apply
`.agents/checks/playwright-e2e.md`.

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.48.0",
"version": "1.48.1",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -4,37 +4,67 @@
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
<div class="flex flex-col">
<div
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
>
<Button
variant="textonly"
size="unset"
:aria-pressed="grid"
:class="
cn(
actionBtnClass,
grid && 'bg-component-node-widget-background-selected'
)
"
@click="grid = !grid"
>
<i class="icon-[lucide--grid-3x3] size-4" />
<span>{{ $t('boundingBoxes.grid') }}</span>
</Button>
<Button
variant="textonly"
size="unset"
:class="cn(actionBtnClass, 'ml-auto')"
@click="clearAll"
>
<i class="icon-[lucide--undo-2] size-4" />
<span>{{ $t('boundingBoxes.clearAll') }}</span>
</Button>
</div>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
</div>
<div
@@ -122,16 +152,6 @@
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
@@ -147,6 +167,9 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { NodeId } from '@/types/nodeId'
const actionBtnClass =
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
const { nodeId } = defineProps<{ nodeId: NodeId }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
@@ -172,7 +195,8 @@ const {
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,

View File

@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
await userEvent.click(screen.getByRole('button', { name: '+' }))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
@@ -44,18 +44,14 @@ describe('PaletteSwatchRow', () => {
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
it('opens the color picker when a swatch is clicked', async () => {
const { container } = renderRow(['#ff0000'])
const swatch = container.querySelector('[data-index="0"]')!
await userEvent.click(swatch)
expect(swatch.getAttribute('data-state')).toBe('open')
})
it('starts a drag on pointer down without emitting', async () => {

View File

@@ -1,17 +1,25 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
<ColorPicker
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
:key="i"
:model-value="hex"
:alpha="false"
@update:model-value="(value) => updateAt(i, value)"
>
<template #trigger>
<button
type="button"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
</template>
</ColorPicker>
<button
v-if="modelValue.length < max"
type="button"
@@ -21,12 +29,6 @@
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
@@ -34,6 +36,7 @@
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
@@ -41,8 +44,9 @@ const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
modelValue,
container
})
</script>

View File

@@ -14,20 +14,27 @@ import { cn } from '@comfyorg/tailwind-utils'
import ColorPickerPanel from './ColorPickerPanel.vue'
defineProps<{
const { alpha = true } = defineProps<{
class?: string
disabled?: boolean
alpha?: boolean
}>()
const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
function readHsva(hex: string): HSVA {
const next = hexToHsva(hex || '#000000')
if (!alpha) next.a = 100
return next
}
const hsva = ref<HSVA>(readHsva(modelValue.value))
const displayMode = ref<'hex' | 'rgba'>('hex')
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
hsva.value = hexToHsva(newVal || '#000000')
hsva.value = readHsva(newVal)
}
})
@@ -67,49 +74,51 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
<slot name="trigger">
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</slot>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
@@ -123,6 +132,7 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
:alpha
/>
</PopoverContent>
</PopoverPortal>

View File

@@ -13,6 +13,8 @@ import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const { alpha = true } = defineProps<{ alpha?: boolean }>()
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
@@ -37,6 +39,7 @@ const { t } = useI18n()
/>
<ColorPickerSlider v-model="hsva.h" type="hue" />
<ColorPickerSlider
v-if="alpha"
v-model="hsva.a"
type="alpha"
:hue="hsva.h"
@@ -72,7 +75,7 @@ const { t } = useI18n()
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
</template>
<span class="shrink-0 border-l border-border-subtle pl-1"
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
>
</div>

View File

@@ -156,7 +156,7 @@ describe('fromBoundingBoxes', () => {
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
@@ -167,10 +167,31 @@ describe('fromBoundingBoxes', () => {
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#fff']
palette: ['#ffffff']
})
})
it('normalizes palette entries and drops invalid colors', () => {
const boxes: BoundingBox[] = [
{
x: 0,
y: 0,
width: 10,
height: 10,
metadata: {
type: 'obj',
text: '',
desc: '',
palette: ['#FF0000', '#abc', 'red', '', 123] as unknown as string[]
}
}
]
expect(fromBoundingBoxes(boxes, 100, 100)[0].palette).toEqual([
'#ff0000',
'#aabbcc'
])
})
it('fills defaults when metadata is missing or partial', () => {
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({

View File

@@ -202,6 +202,22 @@ function isBoundingBox(b: unknown): b is BoundingBox {
)
}
function normalizeHexColor(color: unknown): string | null {
if (typeof color !== 'string') return null
const hex = color.trim().toLowerCase()
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex)
if (short) {
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
}
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null
}
function normalizePalette(palette: unknown): string[] {
return Array.isArray(palette)
? palette.map(normalizeHexColor).filter((c): c is string => c !== null)
: []
}
export function fromBoundingBoxes(
boxes: readonly BoundingBox[],
width: number,
@@ -219,9 +235,7 @@ export function fromBoundingBoxes(
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
palette: normalizePalette(meta.palette)
}
})
}

View File

@@ -8,14 +8,32 @@ import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
const { appState, outputState } = vi.hoisted(() => ({
appState: { node: null as unknown },
outputState: {
outputs: undefined as unknown,
nodeOutputs: null as { value: Record<string, unknown> } | null
}
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
vi.mock('@/stores/nodeOutputStore', async () => {
const { ref } = await import('vue')
const nodeOutputs = ref<Record<string, unknown>>({})
outputState.nodeOutputs = nodeOutputs
return {
useNodeOutputStore: () => ({
nodeOutputs,
nodePreviewImages: ref({}),
getNodeImageUrls: () => undefined,
getNodeOutputs: () => outputState.outputs
})
}
})
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
@@ -27,6 +45,9 @@ const ctx = {
save: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
arc: () => {},
fill: () => {},
rect: () => {},
clip: () => {},
font: '',
@@ -58,17 +79,32 @@ function makeCanvas(): HTMLCanvasElement {
return el
}
function makeNode() {
interface MockNode {
widgets: { name: string; value: unknown }[]
findInputSlot: (name: string) => number
getInputNode: () => null
isInputConnected?: () => boolean
}
function makeNode(): MockNode {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
{ name: 'height', value: 512 },
{ name: 'last_incoming', value: [] }
],
findInputSlot: () => -1,
getInputNode: () => null
}
}
const lastIncomingOf = (node: MockNode) =>
node.widgets.find((w) => w.name === 'last_incoming')!.value
const setLastIncomingOf = (node: MockNode, value: BoundingBox[]) => {
node.widgets.find((w) => w.name === 'last_incoming')!.value = value
}
const pe = (
clientX: number,
clientY: number,
@@ -96,6 +132,8 @@ interface Captured extends Api {
modelValue: Ref<BoundingBox[]>
}
const modelBoxes = (c: Captured) => c.modelValue.value
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
@@ -128,9 +166,19 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
...over
})
function makeConnectedNode(): MockNode {
return {
...makeNode(),
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
isInputConnected: () => true
}
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
outputState.outputs = undefined
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
@@ -168,8 +216,8 @@ describe('useBoundingBoxes drawing', () => {
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
@@ -177,7 +225,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
@@ -185,7 +233,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(modelBoxes(c)).toHaveLength(1)
})
})
@@ -194,7 +242,7 @@ describe('useBoundingBoxes region editing', () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(c.modelValue.value[0].metadata.type).toBe('text')
expect(modelBoxes(c)[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
@@ -205,14 +253,18 @@ describe('useBoundingBoxes region editing', () => {
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
})
it('clears all regions', async () => {
it('clears all regions and invalidates the applied upstream input', async () => {
const node = makeNode()
setLastIncomingOf(node, [box()])
appState.node = node
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
expect(lastIncomingOf(node)).toEqual([])
})
})
@@ -226,7 +278,7 @@ describe('useBoundingBoxes inline editor', () => {
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
@@ -239,6 +291,168 @@ describe('useBoundingBoxes inline editor', () => {
})
})
describe('useBoundingBoxes incoming bboxes input', () => {
it('adopts cached outputs on mount without overwriting existing edits', () => {
const node = makeConnectedNode()
appState.node = node
const incoming = [box({ x: 0, width: 100 })]
outputState.outputs = { input_bboxes: incoming }
const c = setup([box({ x: 200, width: 300 })])
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(300)
expect(lastIncomingOf(node)).toEqual(incoming)
})
it('does not re-apply an already applied output after a remount', async () => {
const node = makeConnectedNode()
const incoming = [box({ x: 0, width: 100 })]
setLastIncomingOf(node, incoming)
appState.node = node
outputState.outputs = { input_bboxes: incoming }
const c = setup([box({ x: 200, width: 300 })])
outputState.nodeOutputs!.value = { updated: true }
await flush()
expect(modelBoxes(c)[0].width).toBe(300)
})
it('ignores incoming output when the input is not connected', () => {
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
const c = setup([])
expect(modelBoxes(c)).toHaveLength(0)
})
it('repopulates from the next run after clearing the canvas', async () => {
appState.node = makeConnectedNode()
const c = setup([])
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
c.clearAll()
await flush()
expect(modelBoxes(c)).toHaveLength(0)
outputState.nodeOutputs!.value = { n: 2 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(100)
})
it('does not apply output updates while the input is disconnected', async () => {
let connected = true
appState.node = {
...makeConnectedNode(),
isInputConnected: () => connected
}
const c = setup([])
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
c.clearAll()
await flush()
connected = false
outputState.nodeOutputs!.value = { n: 2 }
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
it('does not apply incoming boxes while the user is drawing', async () => {
appState.node = makeConnectedNode()
const c = setup([])
c.grid.value = false
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(50, 50))
outputState.outputs = {
input_bboxes: [box({ x: 0, width: 100, height: 100 })]
}
outputState.nodeOutputs!.value = { n: 1 }
await flush()
c.onDocPointerUp(pe(50, 50))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(205)
})
it('applies incoming boxes when outputs stream in after mount', async () => {
const node = makeConnectedNode()
appState.node = node
const c = setup([])
expect(modelBoxes(c)).toHaveLength(0)
const incoming = [box({ x: 0, width: 100 })]
outputState.outputs = { input_bboxes: incoming }
outputState.nodeOutputs!.value = { updated: true }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(100)
expect(lastIncomingOf(node)).toEqual(incoming)
})
it('re-seeds the canvas over user edits when the upstream value changes', async () => {
const node = makeConnectedNode()
setLastIncomingOf(node, [box({ x: 0, width: 100 })])
appState.node = node
const c = setup([box({ x: 200, width: 300 })])
const changed = [box({ x: 64, width: 128 })]
outputState.outputs = { input_bboxes: changed }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)[0].width).toBe(128)
expect(lastIncomingOf(node)).toEqual(changed)
})
})
describe('useBoundingBoxes grid snapping', () => {
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].x).toBe(64)
expect(modelBoxes(c)[0].width).toBe(256)
})
it('does not snap when grid is disabled', async () => {
const c = setup()
c.grid.value = false
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(55, 55))
c.onDocPointerUp(pe(55, 55))
await flush()
expect(modelBoxes(c)[0].width).toBe(230)
})
it('keeps the anchored edge fixed when resizing a single edge', async () => {
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
c.onPointerDown(pe(60, 30))
c.onCanvasPointerMove(pe(80, 30))
c.onDocPointerUp(pe(80, 30))
await flush()
expect(modelBoxes(c)[0].x).toBe(51)
})
it('removes a box that a resize collapses to zero size', async () => {
const c = setup([box({ x: 64, y: 64, width: 128, height: 128 })])
c.onPointerDown(pe(37, 25))
c.onCanvasPointerMove(pe(14, 25))
c.onDocPointerUp(pe(14, 25))
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])

View File

@@ -1,4 +1,5 @@
import { useElementSize } from '@vueuse/core'
import { cloneDeep, isEqual } from 'es-toolkit'
import { storeToRefs } from 'pinia'
import type { Ref, ShallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
@@ -15,6 +16,7 @@ import type {
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
@@ -25,6 +27,10 @@ const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
const GRID_PX = 32
const MAX_GRID_CELLS = 64
const DOT_ALPHA = 0.18
const DOT_RADIUS = 1
interface InlineEditorState {
value: string
@@ -57,6 +63,7 @@ export function useBoundingBoxes(
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const grid = ref(true)
const { width: containerWidth } = useElementSize(canvasContainer)
@@ -96,6 +103,89 @@ export function useBoundingBoxes(
return Math.max(0, Math.min(1, n))
}
function gridSpec() {
const axisFraction = (size: number) =>
Math.max(GRID_PX, Math.ceil(size / MAX_GRID_CELLS)) / size
return {
fx: axisFraction(widthValue.value),
fy: axisFraction(heightValue.value)
}
}
function snapFraction(value: number, step: number) {
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
}
function snapRegion(region: Region, mode: HitMode): Region {
if (!grid.value) return region
const { fx, fy } = gridSpec()
if (mode === 'move') {
return {
...region,
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
}
}
const snapLeft =
mode === 'draw' ||
mode === 'resize-l' ||
mode === 'resize-tl' ||
mode === 'resize-bl'
const snapRight =
mode === 'draw' ||
mode === 'resize-r' ||
mode === 'resize-tr' ||
mode === 'resize-br'
const snapTop =
mode === 'draw' ||
mode === 'resize-t' ||
mode === 'resize-tl' ||
mode === 'resize-tr'
const snapBottom =
mode === 'draw' ||
mode === 'resize-b' ||
mode === 'resize-bl' ||
mode === 'resize-br'
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
const x2 = snapRight
? snapFraction(region.x + region.w, fx)
: region.x + region.w
const y2 = snapBottom
? snapFraction(region.y + region.h, fy)
: region.y + region.h
return {
...region,
x: x1,
y: y1,
w: Math.max(0, x2 - x1),
h: Math.max(0, y2 - y1)
}
}
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
const el = canvasEl.value
if (!el) return
const { fx, fy } = gridSpec()
if (fx <= 0 || fy <= 0) return
const cols = Math.round(1 / fx)
const rows = Math.round(1 / fy)
ctx.save()
ctx.globalAlpha = DOT_ALPHA
ctx.fillStyle = getComputedStyle(el).color
ctx.beginPath()
for (let i = 0; i <= cols; i++) {
const cx = Math.min(1, i * fx) * W
for (let j = 0; j <= rows; j++) {
const cy = Math.min(1, j * fy) * H
ctx.moveTo(cx + DOT_RADIUS, cy)
ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2)
}
}
ctx.fill()
ctx.restore()
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
@@ -146,6 +236,8 @@ export function useBoundingBoxes(
ctx.fillRect(0, 0, W, H)
}
if (grid.value) drawDots(ctx, W, H)
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
@@ -366,7 +458,7 @@ export function useBoundingBoxes(
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
requestDraw()
}
@@ -375,7 +467,7 @@ export function useBoundingBoxes(
drawing.value = false
canvasEl.value?.releasePointerCapture?.(e.pointerId)
const b = state.value.regions[activeIndex.value]
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
if (b && (b.w < 0.005 || b.h < 0.005)) {
removeRegion(activeIndex.value)
}
syncState()
@@ -510,6 +602,7 @@ export function useBoundingBoxes(
function clearAll() {
state.value.regions = []
activeIndex.value = -1
setLastIncoming([])
syncState()
}
@@ -530,6 +623,23 @@ export function useBoundingBoxes(
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
watch(
litegraphNode,
(node) => {
const props = node?.properties as { bboxGrid?: unknown } | undefined
if (props && typeof props.bboxGrid === 'boolean')
grid.value = props.bboxGrid
},
{ immediate: true }
)
watch(grid, (enabled) => {
const props = litegraphNode.value?.properties as
| Record<string, unknown>
| undefined
if (props) props.bboxGrid = enabled
requestDraw()
})
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
@@ -580,10 +690,63 @@ export function useBoundingBoxes(
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
function lastIncomingWidget() {
return litegraphNode.value?.widgets?.find((w) => w.name === 'last_incoming')
}
function lastIncomingValue(): BoundingBox[] {
const value = lastIncomingWidget()?.value
return Array.isArray(value) ? (value as BoundingBox[]) : []
}
function setLastIncoming(boxes: BoundingBox[]) {
const widget = lastIncomingWidget()
if (!widget) return
const next = cloneDeep(boxes)
widget.value = next
widget.callback?.(next)
}
function applyIncomingBoxes(apply = true) {
if (drawing.value) return
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('bboxes')
if (slot < 0 || !node.isInputConnected(slot)) return
const outputs = nodeOutputStore.getNodeOutputs(node) as
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
| undefined
const incoming = outputs?.input_bboxes
if (!incoming?.length) return
const applied = lastIncomingValue()
if (isEqual(incoming, applied)) return
if (!apply) {
if (!applied.length && state.value.regions.length)
setLastIncoming(incoming)
return
}
state.value.regions = fromBoundingBoxes(
incoming,
widthValue.value,
heightValue.value
)
activeIndex.value = state.value.regions.length ? 0 : -1
setLastIncoming(incoming)
syncState()
}
watch(
() => nodeOutputStore.nodeOutputs,
() => {
updateBgImage()
applyIncomingBoxes()
},
{ deep: true }
)
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
applyIncomingBoxes(false)
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
@@ -608,6 +771,7 @@ export function useBoundingBoxes(
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
}
}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
@@ -13,17 +13,12 @@ afterEach(() => {
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
return { modelValue, container, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
@@ -37,31 +32,17 @@ describe('usePaletteSwatchRow', () => {
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
it('updates the color at an index', () => {
const { modelValue, updateAt } = setup(['#a', '#b'])
updateAt(1, '#123456')
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
it('ignores an update that does not change the color', () => {
const { modelValue, updateAt } = setup(['#a'])
const before = modelValue.value
updateAt(0, '#a')
expect(modelValue.value).toBe(before)
})
it('reorders via drag when the pointer crosses another swatch', () => {

View File

@@ -5,30 +5,16 @@ import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
container
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
function updateAt(i: number, value: string) {
if (modelValue.value[i] === value) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
next[i] = value
modelValue.value = next
}
@@ -105,8 +91,7 @@ export function usePaletteSwatchRow({
})
return {
openPicker,
onPickerInput,
updateAt,
remove,
addColor,
onPointerDown

View File

@@ -77,6 +77,14 @@ vi.mock('pinia', async (importOriginal) => {
}
})
const { settingGetMock } = vi.hoisted(() => ({
settingGetMock: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: settingGetMock })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
@@ -95,6 +103,9 @@ describe('useLoad3d', () => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
settingGetMock.mockImplementation((key: string) =>
key === 'Comfy.Load3D.BackgroundColor' ? '282828' : undefined
)
mockNode = createMockLGraphNode({
properties: {
@@ -356,6 +367,20 @@ describe('useLoad3d', () => {
expect(composable.isPreview.value).toBe(true)
})
it('should set preview mode for save-viewer nodes despite width/height widgets', async () => {
Object.defineProperty(mockNode, 'constructor', {
value: { comfyClass: 'Save3DAdvanced' },
configurable: true
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.isPreview.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
@@ -383,7 +408,37 @@ describe('useLoad3d', () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
expect(composable.sceneConfig.value.backgroundColor).toBe('#282828')
})
it('defaults background color from the Comfy.Load3D.BackgroundColor setting', () => {
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
reactive({ appScalePercentage: 100 }) as unknown as ReturnType<
typeof useCanvasStore
>
)
settingGetMock.mockImplementation((key: string) =>
key === 'Comfy.Load3D.BackgroundColor' ? '123456' : undefined
)
const composable = useLoad3d(mockNode)
expect(composable.sceneConfig.value.backgroundColor).toBe('#123456')
})
it('attaches event listeners before running queued ready callbacks', async () => {
const composable = useLoad3d(mockNode)
let listenersAttachedWhenCallbackRan = false
composable.waitForLoad3d(() => {
listenersAttachedWhenCallbackRan =
vi.mocked(mockLoad3d.addEventListener!).mock.calls.length > 0
})
await composable.initializeLoad3d(document.createElement('div'))
expect(listenersAttachedWhenCallbackRan).toBe(true)
})
it('passes getZoomScale callback to createLoad3d', async () => {

View File

@@ -8,6 +8,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
import {
isAssetPreviewSupported,
persistThumbnail
@@ -118,7 +119,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const sceneConfig = ref<SceneConfig>({
showGrid: true,
backgroundColor: '#000000',
backgroundColor: getActivePinia()
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
: '#282828',
backgroundImage: '',
backgroundRenderMode: 'tiled'
})
@@ -192,6 +195,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (
isLoad3dResultViewerNode(node.constructor.comfyClass ?? '') ||
node.constructor.comfyClass?.startsWith('Preview') ||
!(widthWidget && heightWidget)
) {
@@ -248,6 +252,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeToLoad3dMap.set(node, load3d)
handleEvents('add')
const callbacks = pendingCallbacks.get(node)
if (callbacks && load3d) {
@@ -263,8 +269,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) invokeReadyCallback(callback, load3d)
})
}
handleEvents('add')
} catch (error) {
console.error('Error initializing Load3d:', error)
useToastStore().addAlert(

View File

@@ -4,7 +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 { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
| LightConfig
| undefined
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
if (sceneConfig) {
backgroundColor.value =

View File

@@ -32,7 +32,8 @@ function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
const widgets: MockWidget[] = [
{ name: 'width', hidden: false, options: {} },
{ name: 'height', hidden: false, options: {} },
{ name: 'other', hidden: false, options: {} }
{ name: 'other', hidden: false, options: {} },
{ name: 'last_incoming', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
@@ -73,6 +74,15 @@ describe('Comfy.CreateBoundingBoxes extension', () => {
expect(node.widgets[0].options.hidden).toBe(false)
})
it('always hides the internal last_incoming widget', () => {
for (const connected of [true, false]) {
const node = makeNode(connected)
state.extension!.nodeCreated(node)
expect(node.widgets[3].hidden).toBe(true)
expect(node.widgets[3].options.hidden).toBe(true)
}
})
it('writes visibility through the widget value store when present', () => {
state.widgetState = { options: {} }
const node = makeNode(true)

View File

@@ -3,6 +3,7 @@ import { useExtensionService } from '@/services/extensionService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const DIMENSION_WIDGETS = new Set(['width', 'height'])
const INTERNAL_WIDGETS = new Set(['last_incoming'])
useExtensionService().registerExtension({
name: 'Comfy.CreateBoundingBoxes',
@@ -15,20 +16,30 @@ useExtensionService().registerExtension({
const widgetValueStore = useWidgetValueStore()
const setWidgetHidden = (
widget: NonNullable<typeof node.widgets>[number],
hidden: boolean
) => {
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
}
const syncDimensionVisibility = () => {
const slot = node.findInputSlot('background')
const hidden = slot >= 0 && node.isInputConnected(slot)
for (const widget of node.widgets ?? []) {
if (!DIMENSION_WIDGETS.has(widget.name)) continue
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
if (DIMENSION_WIDGETS.has(widget.name)) setWidgetHidden(widget, hidden)
}
}
for (const widget of node.widgets ?? []) {
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
}
syncDimensionVisibility()
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,

View File

@@ -143,14 +143,23 @@ async function loadExtensionsFresh(): Promise<{
load3DExt: ExtCreated
preview3DExt: ExtCreated
preview3DAdvancedExt: ExtCreated
save3DAdvancedExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3d')
const extByName = (name: string): ExtCreated => {
const call = registerExtensionMock.mock.calls.find(
(c) => (c[0] as ExtCreated).name === name
)
if (!call) throw new Error(`Extension ${name} was not registered`)
return call[0] as ExtCreated
}
return {
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
load3DExt: extByName('Comfy.Load3D'),
preview3DExt: extByName('Comfy.Preview3D'),
preview3DAdvancedExt: extByName('Comfy.Preview3DAdvanced'),
save3DAdvancedExt: extByName('Comfy.Save3DAdvanced')
}
}
@@ -264,14 +273,15 @@ function setupBaseMocks() {
describe('load3d module registration', () => {
beforeEach(setupBaseMocks)
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(load3DExt.name).toBe('Comfy.Load3D')
expect(preview3DExt.name).toBe('Comfy.Preview3D')
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
})
})
@@ -711,6 +721,39 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
})
})
describe('Comfy.Save3DAdvanced.onNodeOutputsUpdated', () => {
beforeEach(setupBaseMocks)
it('restores the saved model from the output folder when opened from history', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
getNodeByLocatorIdMock.mockReturnValue(node)
save3DAdvancedExt.onNodeOutputsUpdated!({
'7': { result: ['3d\\ComfyUI_00001.glb'] }
} as never)
expect(node.properties['Last Time Model File']).toBe('3d/ComfyUI_00001.glb')
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001.glb',
expect.objectContaining({ silentOnNotFound: true })
)
})
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
getNodeByLocatorIdMock.mockReturnValue(node)
save3DAdvancedExt.onNodeOutputsUpdated!({
'7': { result: ['mesh.glb'] }
} as never)
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
})
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
beforeEach(setupBaseMocks)
@@ -1032,6 +1075,50 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
})
})
describe('Comfy.Save3DAdvanced.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
await save3DAdvancedExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('restores persisted models from the output folder, not temp', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
comfyClass: 'Save3DAdvanced',
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.glb' }
})
await save3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001_.glb',
{ silentOnNotFound: true }
)
})
it('onExecuted loads the saved file from the output folder', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
await save3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['3d/ComfyUI_00002_.glb'] })
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00002_.glb',
{ silentOnNotFound: true }
)
})
})
describe('Comfy.Load3D scene widget serializeValue caching', () => {
beforeEach(setupBaseMocks)

View File

@@ -15,8 +15,10 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
import type {
CameraConfig,
CameraState,
LoadFolder,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import {
LOAD3D_NONE_MODEL,
@@ -48,6 +50,7 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComfyExtension } from '@/types/comfy'
import { isLoad3dNode } from '@/utils/litegraphUtil'
const inputSpecLoad3D: CustomInputSpec = {
@@ -287,8 +290,11 @@ useExtensionService().registerExtension({
getCustomWidgets() {
const VIEWPORT_STATE_NODES = new Set([
'Preview3DAdvanced',
'Save3DAdvanced',
'PreviewGaussianSplat',
'PreviewPointCloud'
'PreviewPointCloud',
'SaveGaussianSplat',
'SavePointCloud'
])
return {
LOAD_3D(node) {
@@ -679,155 +685,215 @@ useExtensionService().registerExtension({
}
})
useExtensionService().registerExtension({
name: 'Comfy.Preview3DAdvanced',
function applyPreview3DAdvancedResult(
node: LGraphNode,
load3d: Load3d,
result: NonNullable<Preview3DAdvancedOutput['result']>,
loadFolder: LoadFolder,
comfyClass: string
): void {
const filePath = result[0]
if (!filePath) return
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, normalizedPath, {
silentOnNotFound: true
})
if (load3d.isSplatModel()) return []
const cameraState = result[1]
const modelTransform = result[2]?.[0]
if (!cameraState && !modelTransform) return
return createExportMenuItems(load3d)
},
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
if (modelTransform) load3d.applyModelTransform(modelTransform)
})
.catch((error) => {
console.error(
`Failed to apply input camera_info / model_3d_info from ${comfyClass}:`,
error
)
})
}
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
function createPreview3DAdvancedExtension(
comfyClass: string,
extensionName: string,
loadFolder: LoadFolder
): ComfyExtension {
return {
name: extensionName,
const [oldWidth, oldHeight] = node.size
onNodeOutputsUpdated(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
) {
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
const result = (output as Preview3DAdvancedOutput).result
if (!result?.[0]) continue
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
const node = getNodeByLocatorId(app.rootGraph, locatorId)
if (!node || node.constructor.comfyClass !== comfyClass) continue
await nextTick()
const onExecuted = node.onExecuted
useLoad3d(node).onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
if (!cameraState) return
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraState(cameraState)
load3d.forceRender()
})
.catch((error) => {
console.error(
'Failed to restore camera state for Preview3DAdvanced:',
error
useLoad3d(node).waitForLoad3d((load3d) => {
applyPreview3DAdvancedResult(
node,
load3d,
result,
loadFolder,
comfyClass
)
})
})
useLoad3d(node).waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
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) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
},
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== comfyClass) return []
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 load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
if (load3d.isSplatModel()) return []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
return createExportMenuItems(load3d)
},
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== comfyClass) return
const result = output.result
const filePath = result?.[0]
const [oldWidth, oldHeight] = node.size
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
await nextTick()
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
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(loadFolder, lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraState = result?.[1]
const modelTransform = result?.[2]?.[0]
if (cameraState || modelTransform) {
const targetGeneration = currentLoad3d.currentLoadGeneration
void currentLoad3d
.whenLoadIdle()
.then(() => {
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
return
if (cameraState) currentLoad3d.setCameraState(cameraState)
if (modelTransform)
currentLoad3d.applyModelTransform(modelTransform)
})
.catch((error) => {
console.error(
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
error
)
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
if (!cameraState) return
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraState(cameraState)
load3d.forceRender()
})
.catch((error) => {
console.error(
`Failed to restore camera state for ${comfyClass}:`,
error
)
})
})
waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
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) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
}
})
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
const result = output.result
if (!result?.[0]) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
applyPreview3DAdvancedResult(
node,
resolveLoad3d(),
result,
loadFolder,
comfyClass
)
}
})
}
}
})
}
useExtensionService().registerExtension(
createPreview3DAdvancedExtension(
'Preview3DAdvanced',
'Comfy.Preview3DAdvanced',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DAdvancedExtension(
'Save3DAdvanced',
'Comfy.Save3DAdvanced',
'output'
)
)

View File

@@ -14,6 +14,7 @@ export type MaterialMode =
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'
export type LoadFolder = 'temp' | 'output'
interface CameraQuaternion {
x: number

View File

@@ -3,21 +3,24 @@
* Adding a new node type that uses the viewer = one line change here.
*/
const LOAD3D_PREVIEW_NODES = new Set([
const LOAD3D_RESULT_VIEWER_NODES = new Set([
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud'
'PreviewPointCloud',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud'
])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
...LOAD3D_RESULT_VIEWER_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)
export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
LOAD3D_RESULT_VIEWER_NODES.has(nodeType)
export const isLoad3dNode = (nodeType: string): boolean =>
LOAD3D_ALL_NODES.has(nodeType)

View File

@@ -90,7 +90,10 @@ describe('load3dLazy', () => {
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud',
'SaveGLB'
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud'
])(
'recognizes %s as a 3D node type and triggers the lazy-load path',
async (nodeType) => {

View File

@@ -76,14 +76,24 @@ type ExtCreated = ComfyExtension & {
async function loadExtensionsFresh(): Promise<{
splatExt: ExtCreated
pointCloudExt: ExtCreated
saveSplatExt: ExtCreated
savePointCloudExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3dPreviewExtensions')
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
const extByName = (name: string): ExtCreated => {
const call = registerExtensionMock.mock.calls.find(
(c) => (c[0] as ExtCreated).name === name
)
if (!call) throw new Error(`Extension ${name} was not registered`)
return call[0] as ExtCreated
}
return {
splatExt: splatCall[0] as ExtCreated,
pointCloudExt: pointCloudCall[0] as ExtCreated
splatExt: extByName('Comfy.PreviewGaussianSplat'),
pointCloudExt: extByName('Comfy.PreviewPointCloud'),
saveSplatExt: extByName('Comfy.SaveGaussianSplat'),
savePointCloudExt: extByName('Comfy.SavePointCloud')
}
}
@@ -92,6 +102,7 @@ interface FakeLoad3d {
isSplatModel: ReturnType<typeof vi.fn>
forceRender: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
applyModelTransform: ReturnType<typeof vi.fn>
setTargetSize: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
@@ -106,6 +117,7 @@ function makeLoad3dMock(): FakeLoad3d {
isSplatModel: vi.fn(() => false),
forceRender: vi.fn(),
setCameraState: vi.fn(),
applyModelTransform: vi.fn(),
setTargetSize: vi.fn(),
getCurrentCameraType: vi.fn(() => 'perspective'),
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
@@ -151,12 +163,59 @@ function setupBaseMocks() {
describe('load3dPreviewExtensions module registration', () => {
beforeEach(setupBaseMocks)
it('registers both preview extensions on import', async () => {
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
it('registers preview and save extensions on import', async () => {
const { splatExt, pointCloudExt, saveSplatExt, savePointCloudExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
expect(saveSplatExt.name).toBe('Comfy.SaveGaussianSplat')
expect(savePointCloudExt.name).toBe('Comfy.SavePointCloud')
})
it('save extensions load the saved file from the output folder, not temp', async () => {
const { saveSplatExt, savePointCloudExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const splatNode = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
await saveSplatExt.nodeCreated(splatNode)
splatNode.onExecuted!({ result: ['3d/ComfyUI_00001_.ply'] })
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'3d/ComfyUI_00001_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
const pcNode = makePreviewNode({ comfyClass: 'SavePointCloud' })
await savePointCloudExt.nodeCreated(pcNode)
pcNode.onExecuted!({ result: ['3d/ComfyUI_00002_.ply'] })
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'3d/ComfyUI_00002_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
it('restores persisted models from the output folder on nodeCreated, not temp', async () => {
const { saveSplatExt } = await loadExtensionsFresh()
const node = makePreviewNode({
comfyClass: 'SaveGaussianSplat',
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.ply' }
})
await saveSplatExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
})
@@ -214,6 +273,44 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
expect(cameraConfig?.state).toEqual(cameraState)
})
it('applies onExecuted results to the remounted instance, not the disposed closure', async () => {
const { splatExt } = await loadExtensionsFresh()
const original = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(original)
)
const node = makePreviewNode()
await splatExt.nodeCreated(node)
const remounted = makeLoad3dMock()
nodeToLoad3dMapMock.set(node, remounted)
node.onExecuted!({
result: ['scene.ply', { position: { x: 1, y: 2, z: 3 } }]
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(remounted.forceRender).toHaveBeenCalled()
expect(original.forceRender).not.toHaveBeenCalled()
})
it('re-applies the model transform from result[2] on execute', async () => {
const { saveSplatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
const transform = { position: { x: 1, y: 2, z: 3 } }
await saveSplatExt.nodeCreated(node)
node.onExecuted!({ result: ['scene.ply', undefined, [transform]] })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
})
it('syncs width/height widgets to load3d.setTargetSize and registers callbacks', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()

View File

@@ -5,6 +5,7 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
import type {
CameraConfig,
CameraState,
LoadFolder,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type Load3d from '@/extensions/core/load3d/Load3d'
@@ -29,7 +30,9 @@ function applyResultToLoad3d(
node: LGraphNode,
load3d: Load3d,
filePath: string,
cameraState: CameraState | undefined
cameraState: CameraState | undefined,
modelTransform: Model3DInfo[number] | undefined,
loadFolder: LoadFolder
): void {
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
@@ -46,7 +49,7 @@ function applyResultToLoad3d(
}
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
config.configureForSaveMesh(loadFolder, normalizedPath, {
silentOnNotFound: true
})
@@ -54,13 +57,15 @@ function applyResultToLoad3d(
void load3d.whenLoadIdle().then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
if (modelTransform) load3d.applyModelTransform(modelTransform)
load3d.forceRender()
})
}
function createPreview3DExtension(
comfyClass: string,
extensionName: string
extensionName: string,
loadFolder: LoadFolder
): ComfyExtension {
const applyPreviewOutput = (
node: LGraphNode,
@@ -68,10 +73,18 @@ function createPreview3DExtension(
): void => {
const filePath = result[0]
const cameraState = result[1]
const modelTransform = result[2]?.[0]
if (!filePath) return
useLoad3d(node).waitForLoad3d((load3d) => {
applyResultToLoad3d(node, load3d, filePath, cameraState)
applyResultToLoad3d(
node,
load3d,
filePath,
cameraState,
modelTransform,
loadFolder
)
})
}
@@ -119,7 +132,7 @@ function createPreview3DExtension(
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
silentOnNotFound: true
})
@@ -136,6 +149,8 @@ function createPreview3DExtension(
})
waitForLoad3d((load3d) => {
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
@@ -148,10 +163,10 @@ function createPreview3DExtension(
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
load3d.setTargetSize(value, heightWidget.value as number)
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
load3d.setTargetSize(widthWidget.value as number, value)
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
@@ -199,7 +214,14 @@ function createPreview3DExtension(
return
}
applyResultToLoad3d(node, load3d, filePath, result?.[1])
applyResultToLoad3d(
node,
resolveLoad3d(),
filePath,
result?.[1],
result?.[2]?.[0],
loadFolder
)
}
})
}
@@ -207,8 +229,26 @@ function createPreview3DExtension(
}
useExtensionService().registerExtension(
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
createPreview3DExtension(
'PreviewGaussianSplat',
'Comfy.PreviewGaussianSplat',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
createPreview3DExtension(
'PreviewPointCloud',
'Comfy.PreviewPointCloud',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DExtension(
'SaveGaussianSplat',
'Comfy.SaveGaussianSplat',
'output'
)
)
useExtensionService().registerExtension(
createPreview3DExtension('SavePointCloud', 'Comfy.SavePointCloud', 'output')
)

View File

@@ -81,6 +81,9 @@ describe('Comfy.SaveImageExtraOutput', () => {
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',

View File

@@ -16,6 +16,9 @@ const saveNodeTypes = new Set([
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "يرجى إبقاء إجابتك أقل من {max} حرفًا.",
"chooseAnOption": "يرجى اختيار خيار.",
"describeAnswer": "يرجى وصف إجابتك.",
"selectAtLeastOne": "يرجى اختيار خيار واحد على الأقل."
},
"intro": "ساعدنا في تخصيص تجربتك مع ComfyUI.",
"options": {
"familiarity": {
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
"basics": "مرتاح مع الأساسيات",
"expert": "خبير (أساعد الآخرين)",
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
"experience": {
"new": "جديد على ComfyUI",
"pro": "أنا مستخدم محترف",
"some": "لدي معرفة جيدة"
},
"focus": {
"custom_nodes": "عُقد مخصصة",
"pipelines": "مسارات مؤتمتة",
"products": "منتجات للآخرين"
},
"intent": {
"3d_game": "أصول ثلاثية الأبعاد / أصول ألعاب",
"api": "نقاط نهاية API لتشغيل مسارات العمل",
"apps": "تطبيقات مبسطة من مسارات العمل",
"audio": "صوت / موسيقى",
"custom_nodes": "عُقد مخصصة",
"apps_api": "تطبيقات وواجهات برمجة التطبيقات",
"exploring": "أستكشف فقط",
"images": "صور",
"not_sure": "لست متأكداً",
"videos": "فيديوهات",
"other": "شيء آخر",
"otherPlaceholder": "ماذا تريد أن تصنع؟",
"video": "فيديو",
"workflows": "مسارات عمل أو خطوط معالجة مخصصة"
},
"source": {
"conference": ؤتمر أو فعالية",
"discord": "ديسكورد / مجتمع",
"community": جتمع أو منتدى",
"friend": "صديق أو زميل",
"github": "GitHub",
"other": "أخرى",
"otherPlaceholder": "من أين وجدتنا؟",
"search": "جوجل / بحث",
"social": "وسائل التواصل الاجتماعي"
},
"source_social": {
"discord": "ديسكورد",
"instagram": "إنستغرام",
"linkedin": "لينكدإن",
"newsletter": "النشرة البريدية أو مدونة",
"other": "أخرى",
"reddit": "ريديت",
"search": "جوجل / بحث",
"twitter": "تويتر / X",
"tiktok": "تيك توك",
"twitter": "X (تويتر)",
"youtube": "يوتيوب"
},
"usage": {
"education": "تعليمي (طالب أو معلم)",
"personal": "استخدام شخصي",
"work": "عمل"
}
},
"otherPlaceholder": "أخبرنا المزيد",
"placeholder": "نص بديل لأسئلة الاستبيان",
"steps": {
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
"intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
"source": "من أين سمعت عن ComfyUI؟",
"usage": "كيف تخطط لاستخدام ComfyUI؟"
},
"title": "استبيان السحابة"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "تعرف على السحابة",
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
"cloudSurvey_steps_experience": "ما مدى معرفتك بـ ComfyUI؟",
"cloudSurvey_steps_focus": "ماذا تبني؟",
"cloudSurvey_steps_intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
"cloudSurvey_steps_source": "من أين سمعت عن ComfyUI؟",
"cloudSurvey_steps_usage": "كيف تخطط لاستخدام ComfyUI؟",
"cloudSurvey_steps_source_social": "أي منصة؟",
"cloudWaitlist_contactLink": "هنا",
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
"color": {

View File

@@ -2170,7 +2170,8 @@
"descLabel": "description",
"textPlaceholder": "text to render (verbatim)",
"descPlaceholder": "description of this region",
"colors": "color_palette"
"colors": "color_palette",
"grid": "Grid"
},
"palette": {
"addColor": "Add a color",
@@ -2414,6 +2415,16 @@
"tooltipLearnMore": "Learn more..."
}
},
"desktopLogin": {
"confirmSummary": "Approve desktop sign-in?",
"confirmMessage": "The ComfyUI desktop app is waiting to sign in with your account. Only continue if you just started signing in from the ComfyUI desktop app.",
"successSummary": "Signed in",
"successDetail": "You can return to the ComfyUI desktop app.",
"expiredSummary": "Desktop sign-in failed",
"expiredDetail": "The sign-in request expired or was already used. Start signing in again from the ComfyUI desktop app.",
"failedSummary": "Desktop sign-in failed",
"failedDetail": "Something went wrong completing the desktop sign-in. Start signing in again from the ComfyUI desktop app."
},
"validation": {
"invalidEmail": "Invalid email address",
"required": "Required",

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Por favor, mantén tu respuesta por debajo de {max} caracteres.",
"chooseAnOption": "Por favor, elige una opción.",
"describeAnswer": "Por favor, describe tu respuesta.",
"selectAtLeastOne": "Por favor, selecciona al menos una opción."
},
"intro": "Ayúdanos a personalizar tu experiencia con ComfyUI.",
"options": {
"familiarity": {
"advanced": "Usuario avanzado (flujos de trabajo personalizados)",
"basics": "Cómodo con lo básico",
"expert": "Experto (ayudo a otros)",
"new": "Nuevo en ComfyUI (nunca lo he usado antes)",
"starting": "Recién comenzando (siguiendo tutoriales)"
"experience": {
"new": "Nuevo en ComfyUI",
"pro": "Soy usuario avanzado",
"some": "Ya tengo experiencia"
},
"focus": {
"custom_nodes": "Nodos personalizados",
"pipelines": "Pipelines automatizados",
"products": "Productos para otros"
},
"intent": {
"3d_game": "Recursos 3D / recursos para juegos",
"api": "Endpoints de API para ejecutar flujos de trabajo",
"apps": "Apps simplificadas a partir de flujos de trabajo",
"audio": "Audio / música",
"custom_nodes": "Nodos personalizados",
"apps_api": "Aplicaciones y APIs",
"exploring": "Solo explorando",
"images": "Imágenes",
"not_sure": "No estoy seguro",
"videos": "Videos",
"other": "Otra cosa",
"otherPlaceholder": "¿Qué quieres crear?",
"video": "Video",
"workflows": "Flujos de trabajo o pipelines personalizados"
},
"source": {
"conference": "Conferencia o evento",
"discord": "Discord / comunidad",
"community": "Una comunidad o foro",
"friend": "Amigo o colega",
"github": "GitHub",
"other": "Otro",
"otherPlaceholder": "¿Dónde nos encontraste?",
"search": "Google / búsqueda",
"social": "Redes sociales"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter o blog",
"other": "Otro",
"reddit": "Reddit",
"search": "Google / búsqueda",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Educación (estudiante o docente)",
"personal": "Uso personal",
"work": "Trabajo"
}
},
"otherPlaceholder": "Cuéntanos más",
"placeholder": "Marcador de posición para preguntas de la encuesta",
"steps": {
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
"intent": "¿Qué quieres crear con ComfyUI?",
"source": "¿Dónde escuchaste sobre ComfyUI?",
"usage": "¿Cómo planeas usar ComfyUI?"
},
"title": "Encuesta en la Nube"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Conoce más sobre Cloud",
"cloudStart_title": "comienza a crear en segundos",
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
"cloudSurvey_steps_experience": "¿Qué tanto conoces ComfyUI?",
"cloudSurvey_steps_focus": "¿Qué estás construyendo?",
"cloudSurvey_steps_intent": "¿Qué quieres crear con ComfyUI?",
"cloudSurvey_steps_source": "¿Dónde escuchaste sobre ComfyUI?",
"cloudSurvey_steps_usage": "¿Cómo planeas usar ComfyUI?",
"cloudSurvey_steps_source_social": "¿En qué plataforma?",
"cloudWaitlist_contactLink": "aquí",
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "لطفاً پاسخ خود را کمتر از {max} نویسه نگه دارید.",
"chooseAnOption": "لطفاً یک گزینه را انتخاب کنید.",
"describeAnswer": "لطفاً پاسخ خود را توضیح دهید.",
"selectAtLeastOne": "لطفاً حداقل یک گزینه را انتخاب کنید."
},
"intro": "به ما کمک کنید تا تجربه شما از ComfyUI را متناسب‌سازی کنیم.",
"options": {
"familiarity": {
"advanced": "کاربر پیشرفته (جریان‌کارهای سفارشی)",
"basics": "آشنایی با مبانی",
"expert": "کاربر خبره (به دیگران کمک می‌کنم)",
"new": "جدید در ComfyUI (تا کنون استفاده نکرده‌ام)",
"starting": "تازه شروع کرده‌ام (در حال دنبال کردن آموزش‌ها)"
"experience": {
"new": "جدید در ComfyUI",
"pro": "کاربر حرفه‌ای هستم",
"some": "آشنایی نسبی دارم"
},
"focus": {
"custom_nodes": "Nodeهای سفارشی",
"pipelines": "پایپ‌لاین‌های خودکار",
"products": "محصولات برای دیگران"
},
"intent": {
"3d_game": "دارایی سه‌بعدی / دارایی بازی",
"api": "API endpoint برای اجرای workflow",
"apps": "اپلیکیشن ساده‌شده از workflow",
"audio": "صدا / موسیقی",
"custom_nodes": "node سفارشی",
"apps_api": "اپلیکیشن‌ها و APIها",
"exploring": "فقط در حال بررسی",
"images": "تصویر",
"not_sure": "مطمئن نیستم",
"videos": "ویدیو",
"other": "چیز دیگری",
"otherPlaceholder": "چه چیزی می‌خواهید بسازید؟",
"video": "ویدیو",
"workflows": "workflow یا pipeline سفارشی"
},
"source": {
"conference": "کنفرانس یا رویداد",
"discord": "Discord / انجمن",
"community": "انجمن یا فروم",
"friend": "دوست یا همکار",
"github": "GitHub",
"other": "سایر",
"otherPlaceholder": "از کجا با ما آشنا شدید؟",
"search": "Google / جستجو",
"social": "رسانه‌های اجتماعی"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "خبرنامه یا وبلاگ",
"other": "سایر",
"reddit": "Reddit",
"search": "Google / جستجو",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "آموزشی (دانشجو یا مدرس)",
"personal": "استفاده شخصی",
"work": "کاری"
}
},
"otherPlaceholder": "بیشتر توضیح دهید",
"placeholder": "جای‌نگهدار سوالات نظرسنجی",
"steps": {
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
"intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
"source": "از کجا با ComfyUI آشنا شدید؟",
"usage": "برنامه شما برای استفاده از ComfyUI چیست؟"
},
"title": "نظرسنجی ابری"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "درباره Cloud بیشتر بدانید",
"cloudStart_title": "در چند ثانیه شروع به خلق کنید",
"cloudStart_wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟",
"cloudSurvey_steps_familiarity": "تا چه اندازه با ComfyUI آشنایی دارید؟",
"cloudSurvey_steps_experience": "تا چه حد با ComfyUI آشنایی دارید؟",
"cloudSurvey_steps_focus": "در حال ساخت چه چیزی هستید؟",
"cloudSurvey_steps_intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
"cloudSurvey_steps_source": "از کجا با ComfyUI آشنا شدید؟",
"cloudSurvey_steps_usage": "برنامه شما برای استفاده از ComfyUI چیست؟",
"cloudSurvey_steps_source_social": "کدام پلتفرم؟",
"cloudWaitlist_contactLink": "اینجا",
"cloudWaitlist_questionsText": "سؤالی دارید؟ با ما تماس بگیرید",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Veuillez limiter votre réponse à {max} caractères.",
"chooseAnOption": "Veuillez choisir une option.",
"describeAnswer": "Veuillez décrire votre réponse.",
"selectAtLeastOne": "Veuillez sélectionner au moins une option."
},
"intro": "Aidez-nous à personnaliser votre expérience ComfyUI.",
"options": {
"familiarity": {
"advanced": "Utilisateur avancé (workflows personnalisés)",
"basics": "À l'aise avec les bases",
"expert": "Expert (j'aide les autres)",
"new": "Nouveau sur ComfyUI (jamais utilisé auparavant)",
"starting": "Je débute (je suis des tutoriels)"
"experience": {
"new": "Nouveau sur ComfyUI",
"pro": "Utilisateur avancé",
"some": "Je me débrouille"
},
"focus": {
"custom_nodes": "Nœuds personnalisés",
"pipelines": "Pipelines automatisés",
"products": "Produits pour les autres"
},
"intent": {
"3d_game": "Assets 3D / assets de jeu",
"api": "Points de terminaison API pour exécuter des workflows",
"apps": "Applications simplifiées à partir de workflows",
"audio": "Audio / musique",
"custom_nodes": "Nœuds personnalisés",
"apps_api": "Applications et API",
"exploring": "Je découvre simplement",
"images": "Images",
"not_sure": "Pas sûr",
"videos": "Vidéos",
"other": "Autre chose",
"otherPlaceholder": "Qu'aimeriez-vous créer ?",
"video": "Vidéo",
"workflows": "Workflows ou pipelines personnalisés"
},
"source": {
"conference": "Conférence ou événement",
"discord": "Discord / communauté",
"community": "Une communauté ou un forum",
"friend": "Ami ou collègue",
"github": "GitHub",
"other": "Autre",
"otherPlaceholder": "Où nous avez-vous trouvés ?",
"search": "Google / recherche",
"social": "Réseaux sociaux"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Autre",
"reddit": "Reddit",
"search": "Google / recherche",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Éducation (étudiant ou enseignant)",
"personal": "Usage personnel",
"work": "Travail"
}
},
"otherPlaceholder": "Dites-nous en plus",
"placeholder": "Texte indicatif des questions de l'enquête",
"steps": {
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
"intent": "Que souhaitez-vous créer avec ComfyUI ?",
"source": "Où avez-vous entendu parler de ComfyUI ?",
"usage": "Comment prévoyez-vous d'utiliser ComfyUI ?"
},
"title": "Enquête Cloud"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "En savoir plus sur Cloud",
"cloudStart_title": "créez en quelques secondes",
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
"cloudSurvey_steps_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
"cloudSurvey_steps_experience": "Quel est votre niveau de connaissance de ComfyUI ?",
"cloudSurvey_steps_focus": "Qu'êtes-vous en train de créer ?",
"cloudSurvey_steps_intent": "Que souhaitez-vous créer avec ComfyUI ?",
"cloudSurvey_steps_source": "Où avez-vous entendu parler de ComfyUI ?",
"cloudSurvey_steps_usage": "Comment prévoyez-vous d'utiliser ComfyUI ?",
"cloudSurvey_steps_source_social": "Quelle plateforme ?",
"cloudWaitlist_contactLink": "ici",
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "אנא שמרו את התשובה שלכם עד {max} תווים.",
"chooseAnOption": "אנא בחר אפשרות.",
"describeAnswer": "אנא תאר את תשובתך.",
"selectAtLeastOne": "אנא בחר לפחות אפשרות אחת."
},
"intro": "עזרו לנו להתאים את חוויית ה-ComfyUI שלך.",
"options": {
"familiarity": {
"advanced": "מתקדם — בונה ועורך תהליכי עבודה",
"basics": "בינוני — מרגיש בנוח עם היסודות",
"expert": ומחה — אני עוזר לאחרים",
"new": "חדש — מעולם לא השתמשתי",
"starting": "מתחיל — עוקב אחר מדריכים"
"experience": {
"new": "חדש/ה ב-ComfyUI",
"pro": "משתמש/ת מתקדם/ת",
"some": כיר/ה את המערכת"
},
"focus": {
"custom_nodes": "צמתים מותאמים אישית",
"pipelines": "צינורות עבודה אוטומטיים",
"products": "מוצרים לאחרים"
},
"intent": {
"3d_game": "נכסי תלת-ממד / נכסי משחקים",
"api": "נקודות קצה של API להרצת תהליכי עבודה",
"apps": "יישומים מפושטים מתהליכי עבודה",
"audio": "שמע / מוזיקה",
"custom_nodes": "צמתים מותאמים",
"apps_api": "אפליקציות ו-API",
"exploring": "רק בודק/ת",
"images": "תמונות",
"not_sure": "לא בטוח",
"videos": "סרטונים",
"other": "משהו אחר",
"otherPlaceholder": "מה תרצו ליצור?",
"video": "וידאו",
"workflows": "תהליכי עבודה או צינורות (pipelines) מותאמים"
},
"source": {
"conference": "כנס או אירוע",
"discord": "Discord / קהילה",
"community": "קהילה או פורום",
"friend": "חבר או עמית",
"github": "GitHub",
"other": "אחר",
"otherPlaceholder": "היכן שמעתם עלינו?",
"search": "Google / חיפוש",
"social": "רשתות חברתיות"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "ניוזלטר או בלוג",
"other": "אחר",
"reddit": "Reddit",
"search": "Google / חיפוש",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "חינוך (סטודנט או מרצה)",
"personal": "שימוש אישי",
"work": "עבודה"
}
},
"otherPlaceholder": "ספרו לנו עוד",
"placeholder": "מציין מיקום לשאלות הסקר",
"steps": {
"familiarity": "עד כמה אתה מכיר את ComfyUI?",
"intent": "מה ברצונך ליצור עם ComfyUI?",
"source": "היכן שמעת על ComfyUI?",
"usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?"
},
"title": "סקר ענן"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "למד על הענן",
"cloudStart_title": "התחל ליצור תוך שניות",
"cloudStart_wantToRun": "מעדיף להריץ את ComfyUI מקומית?",
"cloudSurvey_steps_familiarity": "עד כמה אתה מכיר את ComfyUI?",
"cloudSurvey_steps_experience": "עד כמה אתם מכירים את ComfyUI?",
"cloudSurvey_steps_focus": "מה אתם בונים?",
"cloudSurvey_steps_intent": "מה ברצונך ליצור עם ComfyUI?",
"cloudSurvey_steps_source": "היכן שמעת על ComfyUI?",
"cloudSurvey_steps_usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?",
"cloudSurvey_steps_source_social": "באיזו פלטפורמה?",
"cloudWaitlist_contactLink": "כאן",
"cloudWaitlist_questionsText": "שאלות? צור איתנו קשר",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "回答は{max}文字以内で入力してください。",
"chooseAnOption": "オプションを選択してください。",
"describeAnswer": "回答を記述してください。",
"selectAtLeastOne": "少なくとも1つ選択してください。"
},
"intro": "ComfyUIの体験をより最適化するためにご協力ください。",
"options": {
"familiarity": {
"advanced": "上級ユーザー(カスタムワークフロー)",
"basics": "基本操作に慣れている",
"expert": "エキスパート(他者を支援)",
"new": "ComfyUI初心者使用経験なし",
"starting": "使い始め(チュートリアルをフォロー中)"
"experience": {
"new": "ComfyUIは初めて",
"pro": "上級ユーザー",
"some": "ある程度使い方が分かる"
},
"focus": {
"custom_nodes": "カスタムノード",
"pipelines": "自動パイプライン",
"products": "他者向けプロダクト"
},
"intent": {
"3d_game": "3Dアセットゲームアセット",
"api": "ワークフロー実行用APIエンドポイント",
"apps": "ワークフローから簡易アプリ作成",
"audio": "音声/音楽",
"custom_nodes": "カスタムノード",
"apps_api": "アプリ・API",
"exploring": "探索中",
"images": "画像",
"not_sure": "まだ分からない",
"videos": "動画",
"other": "その他",
"otherPlaceholder": "何を作りたいですか?",
"video": "動画",
"workflows": "カスタムワークフローやパイプライン"
},
"source": {
"conference": "カンファレンスやイベント",
"discord": "Discordコミュニティ",
"community": "コミュニティ・フォーラム",
"friend": "友人または同僚",
"github": "GitHub",
"other": "その他",
"otherPlaceholder": "どこで私たちを知りましたか?",
"search": "Google検索",
"social": "ソーシャルメディア"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "ニュースレターやブログ",
"other": "その他",
"reddit": "Reddit",
"search": "Google検索",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "XTwitter",
"youtube": "YouTube"
},
"usage": {
"education": "教育(学生または教育者)",
"personal": "個人利用",
"work": "仕事"
}
},
"otherPlaceholder": "詳細をお聞かせください",
"placeholder": "アンケート質問のプレースホルダー",
"steps": {
"familiarity": "ComfyUIの使用経験はどの程度ですか",
"intent": "ComfyUIで何を作成したいですか",
"source": "ComfyUIをどこで知りましたか",
"usage": "ComfyUIをどのように利用する予定ですか"
},
"title": "クラウドアンケート"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "クラウドについて学ぶ",
"cloudStart_title": "数秒で作成を開始",
"cloudStart_wantToRun": "代わりにローカルでComfyUIを実行したいですか",
"cloudSurvey_steps_familiarity": "ComfyUIにどの程度精通していますか",
"cloudSurvey_steps_experience": "ComfyUIの知識レベルは",
"cloudSurvey_steps_focus": "何を作成していますか?",
"cloudSurvey_steps_intent": "ComfyUIで何を作成したいですか",
"cloudSurvey_steps_source": "ComfyUIをどこで知りましたか",
"cloudSurvey_steps_usage": "ComfyUIをどのように利用する予定ですか?",
"cloudSurvey_steps_source_social": "どのプラットフォームですか?",
"cloudWaitlist_contactLink": "こちら",
"cloudWaitlist_questionsText": "質問がありますか?お問い合わせください",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "답변은 {max}자 이내로 작성해 주세요.",
"chooseAnOption": "옵션을 선택해 주세요.",
"describeAnswer": "답변을 설명해 주세요.",
"selectAtLeastOne": "최소 한 가지 옵션을 선택해 주세요."
},
"intro": "ComfyUI 경험을 맞춤화할 수 있도록 도와주세요.",
"options": {
"familiarity": {
"advanced": "고급 사용자 (커스텀 워크플로우 사용)",
"basics": "기본 기능에 익숙함",
"expert": "전문가 (다른 사용자 도움)",
"new": "ComfyUI 처음 사용 (이전에 사용한 적 없음)",
"starting": "막 시작한 단계 (튜토리얼 따라하는 중)"
"experience": {
"new": "ComfyUI가 처음이에요",
"pro": "전문 사용자입니다",
"some": "기본적인 사용법을 알아요"
},
"focus": {
"custom_nodes": "커스텀 노드",
"pipelines": "자동화 파이프라인",
"products": "타인을 위한 제품"
},
"intent": {
"3d_game": "3D 에셋 / 게임 에셋",
"api": "워크플로우 실행용 API 엔드포인트",
"apps": "워크플로우 기반 간소화 앱",
"audio": "오디오 / 음악",
"custom_nodes": "커스텀 노드",
"apps_api": "앱 및 API",
"exploring": "그냥 둘러보는 중",
"images": "이미지",
"not_sure": "잘 모르겠음",
"videos": "비디오",
"other": "기타",
"otherPlaceholder": "무엇을 만들고 싶으신가요?",
"video": "비디오",
"workflows": "맞춤형 워크플로우 또는 파이프라인"
},
"source": {
"conference": "컨퍼런스 또는 이벤트",
"discord": "Discord / 커뮤니티",
"community": "커뮤니티 또는 포럼",
"friend": "친구 또는 동료",
"github": "GitHub",
"other": "기타",
"otherPlaceholder": "어디서 저희를 알게 되셨나요?",
"search": "Google / 검색",
"social": "소셜 미디어"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "뉴스레터 또는 블로그",
"other": "기타",
"reddit": "Reddit",
"search": "Google / 검색",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "교육용(학생 또는 교육자)",
"personal": "개인용",
"work": "업무용"
}
},
"otherPlaceholder": "자세히 알려주세요",
"placeholder": "설문 질문 자리표시자",
"steps": {
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
"intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
"source": "ComfyUI를 어디에서 알게 되셨나요?",
"usage": "ComfyUI를 어떻게 사용하실 계획인가요?"
},
"title": "클라우드 설문"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "클라우드 알아보기",
"cloudStart_title": "몇 초 만에 제작 시작",
"cloudStart_wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?",
"cloudSurvey_steps_familiarity": "ComfyUI 얼마나 익숙하신가요?",
"cloudSurvey_steps_experience": "ComfyUI 얼마나 잘 알고 계신가요?",
"cloudSurvey_steps_focus": "무엇을 만들고 계신가요?",
"cloudSurvey_steps_intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
"cloudSurvey_steps_source": "ComfyUI를 어디에서 알게 되셨나요?",
"cloudSurvey_steps_usage": "ComfyUI를 어떻게 사용하실 계획인가요?",
"cloudSurvey_steps_source_social": "어떤 플랫폼에서 알게 되셨나요?",
"cloudWaitlist_contactLink": "여기",
"cloudWaitlist_questionsText": "질문이 있으신가요? 문의하기",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Por favor, mantenha sua resposta com menos de {max} caracteres.",
"chooseAnOption": "Por favor, escolha uma opção.",
"describeAnswer": "Por favor, descreva sua resposta.",
"selectAtLeastOne": "Por favor, selecione pelo menos uma opção."
},
"intro": "Ajude-nos a personalizar sua experiência no ComfyUI.",
"options": {
"familiarity": {
"advanced": "Usuário avançado (fluxos de trabalho personalizados)",
"basics": "Confortável com o básico",
"expert": "Especialista (ajuda outras pessoas)",
"new": "Novo no ComfyUI (nunca usei antes)",
"starting": "Começando agora (seguindo tutoriais)"
"experience": {
"new": "Novo no ComfyUI",
"pro": "Sou um usuário avançado",
"some": "Já conheço um pouco"
},
"focus": {
"custom_nodes": "Nós personalizados",
"pipelines": "Pipelines automatizados",
"products": "Produtos para outros"
},
"intent": {
"3d_game": "Assets 3D / assets para jogos",
"api": "Endpoints de API para executar workflows",
"apps": "Apps simplificados a partir de workflows",
"audio": "Áudio / música",
"custom_nodes": "Nodes personalizados",
"apps_api": "Apps e APIs",
"exploring": "Só explorando",
"images": "Imagens",
"not_sure": "Não tenho certeza",
"videos": "Vídeos",
"other": "Outra coisa",
"otherPlaceholder": "O que você quer criar?",
"video": "Vídeo",
"workflows": "Workflows ou pipelines personalizados"
},
"source": {
"conference": "Conferência ou evento",
"discord": "Discord / comunidade",
"community": "Uma comunidade ou fórum",
"friend": "Amigo ou colega",
"github": "GitHub",
"other": "Outro",
"otherPlaceholder": "Onde você nos encontrou?",
"search": "Google / busca",
"social": "Mídias sociais"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Newsletter ou blog",
"other": "Outro",
"reddit": "Reddit",
"search": "Google / busca",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Educação (estudante ou educador)",
"personal": "Uso pessoal",
"work": "Trabalho"
}
},
"otherPlaceholder": "Conte-nos mais",
"placeholder": "Espaço reservado para perguntas da pesquisa",
"steps": {
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
"intent": "O que você deseja criar com o ComfyUI?",
"source": "Onde você ouviu falar do ComfyUI?",
"usage": "Como você pretende usar o ComfyUI?"
},
"title": "Pesquisa da Nuvem"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Saiba mais sobre a Nuvem",
"cloudStart_title": "comece a criar em segundos",
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
"cloudSurvey_steps_experience": "Qual o seu nível de conhecimento do ComfyUI?",
"cloudSurvey_steps_focus": "O que você está construindo?",
"cloudSurvey_steps_intent": "O que você deseja criar com o ComfyUI?",
"cloudSurvey_steps_source": "Onde você ouviu falar do ComfyUI?",
"cloudSurvey_steps_usage": "Como você pretende usar o ComfyUI?",
"cloudSurvey_steps_source_social": "Em qual plataforma?",
"cloudWaitlist_contactLink": "aqui",
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Пожалуйста, сократите ваш ответ до {max} символов.",
"chooseAnOption": "Пожалуйста, выберите вариант.",
"describeAnswer": "Пожалуйста, опишите ваш ответ.",
"selectAtLeastOne": "Пожалуйста, выберите хотя бы один вариант."
},
"intro": "Помогите нам адаптировать ваш опыт работы с ComfyUI.",
"options": {
"familiarity": {
"advanced": "Продвинутый пользователь (пользовательские рабочие процессы)",
"basics": "Уверенно владею основами",
"expert": "Эксперт (помогаю другим)",
"new": "Новичок в ComfyUI (никогда не использовал)",
"starting": "Только начинаю (следую руководствам)"
"experience": {
"new": "Впервые в ComfyUI",
"pro": "Я опытный пользователь",
"some": "Я немного знаком(а)"
},
"focus": {
"custom_nodes": "Пользовательские узлы",
"pipelines": "Автоматизированные пайплайны",
"products": "Продукты для других"
},
"intent": {
"3d_game": "3D-ассеты / игровые ассеты",
"api": "API endpoints для запуска workflow",
"apps": "Упрощённые приложения из workflow",
"audio": "Аудио / музыка",
"custom_nodes": "Пользовательские node",
"apps_api": "Приложения и API",
"exploring": "Просто изучаю",
"images": "Изображения",
"not_sure": "Не уверен",
"videos": "Видео",
"other": "Другое",
"otherPlaceholder": "Что вы хотите создать?",
"video": "Видео",
"workflows": "Пользовательские workflow или pipeline"
},
"source": {
"conference": "Конференция или мероприятие",
"discord": "Discord / сообщество",
"community": "Сообщество или форум",
"friend": "Друг или коллега",
"github": "GitHub",
"other": "Другое",
"otherPlaceholder": "Где вы о нас узнали?",
"search": "Google / поиск",
"social": "Социальные сети"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Новостная рассылка или блог",
"other": "Другое",
"reddit": "Reddit",
"search": "Google / поиск",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Образование (студент или преподаватель)",
"personal": "Личное использование",
"work": "Работа"
}
},
"otherPlaceholder": "Расскажите подробнее",
"placeholder": "Вопросы для опроса",
"steps": {
"familiarity": "Насколько вы знакомы с ComfyUI?",
"intent": "Что вы хотите создавать с помощью ComfyUI?",
"source": "Где вы узнали о ComfyUI?",
"usage": "Как вы планируете использовать ComfyUI?"
},
"title": "Облачный опрос"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Узнать о Cloud",
"cloudStart_title": "начать создавать за секунды",
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
"cloudSurvey_steps_familiarity": "Насколько вы знакомы с ComfyUI?",
"cloudSurvey_steps_experience": "Насколько хорошо вы знаете ComfyUI?",
"cloudSurvey_steps_focus": "Что вы создаёте?",
"cloudSurvey_steps_intent": "Что вы хотите создавать с помощью ComfyUI?",
"cloudSurvey_steps_source": "Где вы узнали о ComfyUI?",
"cloudSurvey_steps_usage": "Как вы планируете использовать ComfyUI?",
"cloudSurvey_steps_source_social": "На какой платформе?",
"cloudWaitlist_contactLink": "здесь",
"cloudWaitlist_questionsText": "Есть вопросы? Свяжитесь с нами",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "Lütfen cevabınızı {max} karakterin altında tutun.",
"chooseAnOption": "Lütfen bir seçenek seçin.",
"describeAnswer": "Lütfen cevabınızııklayın.",
"selectAtLeastOne": "Lütfen en az bir seçenek seçin."
},
"intro": "ComfyUI deneyiminizi size özel hale getirmemize yardımcı olun.",
"options": {
"familiarity": {
"advanced": "İleri seviye kullanıcı (özel iş akışları)",
"basics": "Temel bilgilerde rahatım",
"expert": "Uzman (başkalarına yardım ediyorum)",
"new": "ComfyUI'a yeni (daha önce hiç kullanmadım)",
"starting": "Yeni başlıyorum (eğitimleri takip ediyorum)"
"experience": {
"new": "ComfyUI'ye yeni",
"pro": "Güçlü bir kullanıcıyım",
"some": "Biraz biliyorum"
},
"focus": {
"custom_nodes": "Özel node'lar",
"pipelines": "Otomatikleştirilmiş pipeline'lar",
"products": "Başkaları için ürünler"
},
"intent": {
"3d_game": "3D varlıklar / oyun varlıkları",
"api": "İş akışlarını çalıştırmak için API uç noktaları",
"apps": "İş akışlarından basitleştirilmiş uygulamalar",
"audio": "Ses / müzik",
"custom_nodes": "Özel node'lar",
"apps_api": "Uygulamalar ve API'ler",
"exploring": "Sadece keşfediyorum",
"images": "Görseller",
"not_sure": "Emin değilim",
"videos": "Videolar",
"other": "Başka bir şey",
"otherPlaceholder": "Ne yapmak istiyorsunuz?",
"video": "Video",
"workflows": "Özel iş akışları veya boru hatları"
},
"source": {
"conference": "Konferans veya etkinlik",
"discord": "Discord / topluluk",
"community": "Bir topluluk veya forum",
"friend": "Arkadaş veya iş arkadaşı",
"github": "GitHub",
"other": "Diğer",
"otherPlaceholder": "Bizi nereden buldunuz?",
"search": "Google / arama",
"social": "Sosyal medya"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "Bülten veya blog",
"other": "Diğer",
"reddit": "Reddit",
"search": "Google / arama",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X (Twitter)",
"youtube": "YouTube"
},
"usage": {
"education": "Eğitim (öğrenci veya eğitmen)",
"personal": "Kişisel kullanım",
"work": "İş"
}
},
"otherPlaceholder": "Daha fazla bilgi verin",
"placeholder": "Anket soruları yer tutucusu",
"steps": {
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
"intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
"source": "ComfyUI'yi nereden duydunuz?",
"usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?"
},
"title": "Bulut Anketi"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "Cloud hakkında bilgi edinin",
"cloudStart_title": "saniyeler içinde oluşturmaya başlayın",
"cloudStart_wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?",
"cloudSurvey_steps_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
"cloudSurvey_steps_experience": "ComfyUI'yi ne kadar iyi biliyorsunuz?",
"cloudSurvey_steps_focus": "Ne inşa ediyorsunuz?",
"cloudSurvey_steps_intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
"cloudSurvey_steps_source": "ComfyUI'yi nereden duydunuz?",
"cloudSurvey_steps_usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?",
"cloudSurvey_steps_source_social": "Hangi platform?",
"cloudWaitlist_contactLink": "burada",
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "請將您的回答控制在 {max} 個字以內。",
"chooseAnOption": "請選擇一個選項。",
"describeAnswer": "請描述您的答案。",
"selectAtLeastOne": "請至少選擇一個選項。"
},
"intro": "協助我們為您量身打造 ComfyUI 體驗。",
"options": {
"familiarity": {
"advanced": "進階使用者(自訂工作流程)",
"basics": "熟悉基礎操作",
"expert": "專家(協助他人)",
"new": "ComfyUI 新手(從未使用過)",
"starting": "剛開始(正在跟隨教學)"
"experience": {
"new": "ComfyUI 新手",
"pro": "我是進階使用者",
"some": "我已經熟悉操作"
},
"focus": {
"custom_nodes": "自訂節點",
"pipelines": "自動化流程",
"products": "為他人打造產品"
},
"intent": {
"3d_game": "3D 素材/遊戲素材",
"api": "執行工作流程的 API 端點",
"apps": "由工作流程簡化的應用程式",
"audio": "音訊/音樂",
"custom_nodes": "自訂節點",
"apps_api": "應用程式與 API",
"exploring": "只是探索",
"images": "圖像",
"not_sure": "尚未確定",
"videos": "影片",
"other": "其他",
"otherPlaceholder": "您想製作什麼?",
"video": "影片",
"workflows": "自訂工作流程或管線"
},
"source": {
"conference": "研討會或活動",
"discord": "Discord社群",
"community": "社群或論壇",
"friend": "朋友或同事",
"github": "GitHub",
"other": "其他",
"otherPlaceholder": "您是在哪裡發現我們的?",
"search": "Google搜尋引擎",
"social": "社群媒體"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "電子報或部落格",
"other": "其他",
"reddit": "Reddit",
"search": "Google搜尋引擎",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "XTwitter",
"youtube": "YouTube"
},
"usage": {
"education": "教育用途(學生或教育者)",
"personal": "個人用途",
"work": "工作用途"
}
},
"otherPlaceholder": "請告訴我們更多",
"placeholder": "問卷問題佔位符",
"steps": {
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
"intent": "您想用 ComfyUI 創作什麼?",
"source": "您是從哪裡得知 ComfyUI 的?",
"usage": "您打算如何使用 ComfyUI"
},
"title": "雲端問卷"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "了解雲端服務",
"cloudStart_title": "數秒內開始創作",
"cloudStart_wantToRun": "想要在本機運行 ComfyUI",
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何",
"cloudSurvey_steps_experience": "您對 ComfyUI 的熟悉程度?",
"cloudSurvey_steps_focus": "您正在製作什麼?",
"cloudSurvey_steps_intent": "您想用 ComfyUI 創作什麼?",
"cloudSurvey_steps_source": "您是從哪裡得知 ComfyUI 的?",
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI",
"cloudSurvey_steps_source_social": "哪個平台",
"cloudWaitlist_contactLink": "此處",
"cloudWaitlist_questionsText": "有問題?聯絡我們",
"color": {

View File

@@ -515,57 +515,52 @@
},
"survey": {
"errors": {
"answerTooLong": "请将您的回答控制在 {max} 个字符以内。",
"chooseAnOption": "请选择一个选项。",
"describeAnswer": "请描述您的答案。",
"selectAtLeastOne": "请至少选择一个选项。"
},
"intro": "帮助我们为您定制 ComfyUI 体验。",
"options": {
"familiarity": {
"advanced": "高级用户(自定义工作流)",
"basics": "熟练掌握基础知识",
"expert": "专家(帮助他人)",
"new": "ComfyUI 新手(从未使用过)",
"starting": "刚刚开始(正在学习教程)"
"experience": {
"new": "ComfyUI 新手",
"pro": "我是高级用户",
"some": "我已经熟悉操作"
},
"focus": {
"custom_nodes": "自定义节点",
"pipelines": "自动化流程",
"products": "为他人制作产品"
},
"intent": {
"3d_game": "3D 资产 / 游戏资产",
"api": "运行工作流的 API 端点",
"apps": "基于工作流的简化应用",
"audio": "音频 / 音乐",
"custom_nodes": "自定义节点",
"apps_api": "应用和 API",
"exploring": "只是探索一下",
"images": "图像",
"not_sure": "不确定",
"videos": "视频",
"other": "其他",
"otherPlaceholder": "你想做什么?",
"video": "视频",
"workflows": "自定义工作流或流程"
},
"source": {
"conference": "会议或活动",
"discord": "Discord / 社区",
"community": "社区或论坛",
"friend": "朋友或同事",
"github": "GitHub",
"other": "其他",
"otherPlaceholder": "你是从哪里了解到我们的?",
"search": "Google / 搜索",
"social": "社交媒体"
},
"source_social": {
"discord": "Discord",
"instagram": "Instagram",
"linkedin": "LinkedIn",
"newsletter": "新闻通讯或博客",
"other": "其他",
"reddit": "Reddit",
"search": "Google / 搜索",
"twitter": "Twitter / X",
"tiktok": "TikTok",
"twitter": "X推特",
"youtube": "YouTube"
},
"usage": {
"education": "教育(学生或教师)",
"personal": "个人使用",
"work": "工作"
}
},
"otherPlaceholder": "请告诉我们更多",
"placeholder": "调查问题占位符",
"steps": {
"familiarity": "你对 ComfyUI 有多熟悉?",
"intent": "您希望用 ComfyUI 创作什么?",
"source": "您是从哪里了解到 ComfyUI 的?",
"usage": "您打算如何使用 ComfyUI"
},
"title": "云调研"
}
},
@@ -578,10 +573,11 @@
"cloudStart_learnAboutButton": "了解云服务",
"cloudStart_title": "几秒钟内开始创作",
"cloudStart_wantToRun": "想在本地运行 ComfyUI 吗?",
"cloudSurvey_steps_familiarity": "你对 ComfyUI 有多熟悉",
"cloudSurvey_steps_experience": "你对 ComfyUI 有多了解",
"cloudSurvey_steps_focus": "你正在构建什么?",
"cloudSurvey_steps_intent": "您希望用 ComfyUI 创作什么?",
"cloudSurvey_steps_source": "您是从哪里了解到 ComfyUI 的?",
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI",
"cloudSurvey_steps_source_social": "你是在哪个平台上看到的",
"cloudWaitlist_contactLink": "这里",
"cloudWaitlist_questionsText": "有问题?联系我们",
"color": {

View File

@@ -1,7 +1,5 @@
<template>
<div
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
>
<div class="dark-theme flex max-h-full w-full max-w-md flex-col px-4 sm:px-6">
<h1
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
>

View File

@@ -0,0 +1,618 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
/**
* Every test drives a real in-memory router and the real preserved-query
* manager: the tracker strips the code from the URL at capture time, so the
* stash is the only carrier, and redemption fires from router.afterEach, an
* auth watcher, and a delayed retry after a transient failure.
*
* The fake clock (installed for every test) keeps those retry timers from
* leaking into later tests: afterEach discards them with vi.useRealTimers().
*/
const mockConfirm = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm
})
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd
})
}))
interface MockAuthStore {
currentUser: {
uid: string
getIdToken: (forceRefresh?: boolean) => Promise<string>
} | null
getIdToken: () => Promise<string>
}
const mockUserGetIdToken = vi.hoisted(() => vi.fn())
const mockStoreGetIdToken = vi.hoisted(() => vi.fn())
// Reactive so the module's watcher on currentUser fires without a navigation.
// The mock factory is cached across vi.resetModules(), so it reads a holder
// refilled per test; watchers leaked by earlier module generations stay
// subscribed to earlier stores and remain dormant.
const authStoreHolder = vi.hoisted(() => ({
store: null as MockAuthStore | null
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => authStoreHolder.store
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `/api${path}`
}
}))
const VALID_CODE = `dlc_${'A'.repeat(43)}`
const SECOND_CODE = `dlc_${'B'.repeat(43)}`
const REDEEM_URL = '/api/auth/desktop-login-codes/redeem'
const NAMESPACE = 'desktop_login'
const STORAGE_KEY = 'Comfy.PreservedQuery.desktop_login'
const RETRY_DELAY_MS = 5_000
const mockFetch = vi.fn()
let mockAuthStore: MockAuthStore
function okResponse() {
return new Response(JSON.stringify({ status: 'redeemed' }), { status: 200 })
}
function expectedFetchOptions(code: string) {
return {
method: 'POST',
headers: {
Authorization: 'Bearer firebase-id-token',
'Content-Type': 'application/json'
},
body: JSON.stringify({ code }),
signal: expect.any(AbortSignal)
}
}
// The triggers fire-and-forget the redemption; a zero-length advance of the
// fake clock yields the event loop so the whole mocked promise chain settles.
async function flushRedemption() {
await vi.advanceTimersByTimeAsync(0)
}
// vi.resetModules() also resets the preserved-query manager's in-memory map,
// so the manager must be imported alongside the module under test.
async function setup() {
const { installDesktopLoginRedemption } =
await import('./desktopLoginRedemption')
const { capturePreservedQuery, getPreservedQueryParam } =
await import('@/platform/navigation/preservedQueryManager')
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
installDesktopLoginRedemption(router)
let navigationCount = 0
const trigger = async () => {
await router.push(`/trigger-${navigationCount++}`)
await flushRedemption()
}
return {
router,
trigger,
seedStash: (code: string) =>
capturePreservedQuery(NAMESPACE, { desktop_login_code: code }, [
'desktop_login_code'
]),
stashedCode: () => getPreservedQueryParam(NAMESPACE, 'desktop_login_code')
}
}
describe('installDesktopLoginRedemption', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.useFakeTimers()
sessionStorage.clear()
vi.stubGlobal('fetch', mockFetch)
vi.spyOn(console, 'warn').mockImplementation(() => {})
mockFetch.mockReset()
mockConfirm.mockResolvedValue(true)
mockUserGetIdToken.mockResolvedValue('firebase-id-token')
mockAuthStore = reactive({
currentUser: {
uid: 'user-1',
getIdToken: mockUserGetIdToken
},
getIdToken: mockStoreGetIdToken
})
authStoreHolder.store = mockAuthStore
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('does nothing on navigation when no code is stashed', async () => {
const { trigger } = await setup()
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('redeems a stashed code once on navigation with the Firebase bearer token after approval', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockConfirm).toHaveBeenCalledWith({
title: 'desktopLogin.confirmSummary',
message: 'desktopLogin.confirmMessage'
})
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'success',
summary: 'desktopLogin.successSummary',
detail: 'desktopLogin.successDetail',
life: 4000
})
})
it('does not fetch before the user approves the confirmation dialog', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
expect(mockFetch).not.toHaveBeenCalled()
approve(true)
await flushRedemption()
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it.for([
['declines', false],
['dismisses', null]
] as const)(
'clears the stash without a request or toast when the user %s the dialog',
async ([_label, confirmResult]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockResolvedValue(confirmResult)
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).not.toHaveBeenCalled()
// Declining is final for that code: re-capturing it never re-prompts.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
}
)
it('asks for approval at most once per code across transient retries', async () => {
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('redeems a code hydrated lazily from sessionStorage', async () => {
const { trigger } = await setup()
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({ desktop_login_code: VALID_CODE })
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
})
it('does not redeem or prompt again after a successful redemption', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
// A later navigation re-captures the already-redeemed code.
seedStash(VALID_CODE)
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBeUndefined()
})
it.for([400, 403, 404, 409, 410])(
'clears the stash, shows an error toast, and never retries on %s',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.expiredSummary',
detail: 'desktopLogin.expiredDetail',
life: 6000
})
}
)
it.for([401, 500])(
'keeps the stash on %s for the scheduled retry without a toast',
async (status) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
}
)
it('retries once by itself, then clears the stash and shows an error toast when the budget is spent', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'desktopLogin.failedSummary',
detail: 'desktopLogin.failedDetail',
life: 6000
})
})
it('forces a token refresh on the retry after a 401', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(okResponse())
await trigger()
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(false)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(true)
expect(stashedCode()).toBeUndefined()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('passes a timeout signal and treats an aborted request as transient', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockRejectedValue(
new DOMException('The operation timed out.', 'TimeoutError')
)
await trigger()
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('treats an id token failure as transient without a toast', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockUserGetIdToken.mockRejectedValue(new Error('firebase unavailable'))
await trigger()
expect(mockFetch).not.toHaveBeenCalled()
// authStore.getIdToken surfaces failures through a modal error dialog,
// which this background flow must never trigger.
expect(mockStoreGetIdToken).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('clears the stash without a dialog or request for a malformed code', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash('not-a-desktop-login-code')
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBeUndefined()
})
it('contains an unexpected internal error instead of rejecting', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { trigger, seedStash } = await setup()
seedStash(VALID_CODE)
mockConfirm.mockRejectedValue(new Error('dialog exploded'))
await expect(trigger()).resolves.toBeUndefined()
expect(consoleError).toHaveBeenCalledWith(
'[DesktopLoginRedemption] Redemption failed:',
expect.any(Error)
)
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('keeps the stash while unauthenticated and redeems via the auth watcher once a session appears', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockAuthStore.currentUser = null
mockFetch.mockResolvedValue(okResponse())
// The first completed navigation installs the watcher; without a session
// nothing redeems and the stash is kept.
await trigger()
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockFetch).not.toHaveBeenCalled()
expect(stashedCode()).toBe(VALID_CODE)
// A session appearing without any further navigation redeems via the
// watcher.
mockAuthStore.currentUser = {
uid: 'user-1',
getIdToken: mockUserGetIdToken
}
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expectedFetchOptions(VALID_CODE)
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeded', () => mockFetch.mockResolvedValueOnce(okResponse())],
['was declined', () => mockConfirm.mockResolvedValueOnce(false)]
] as const)(
'gives a second code its own dialog and request after the first code %s',
async ([_label, arrangeFirstOutcome]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
arrangeFirstOutcome()
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('gives a second code a fresh attempt budget after the first code exhausted its own', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
await trigger()
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(stashedCode()).toBeUndefined()
seedStash(SECOND_CODE)
await trigger()
expect(mockFetch).toHaveBeenCalledTimes(3)
expect(stashedCode()).toBe(SECOND_CODE)
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
expect(mockFetch).toHaveBeenCalledTimes(4)
expect(stashedCode()).toBeUndefined()
})
it('re-asks for approval when the account changes after approval and redeems with the new account token', async () => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 500 }))
.mockResolvedValueOnce(okResponse())
// user-1 approves; the redeem fails transiently, keeping the code stashed.
await trigger()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(stashedCode()).toBe(VALID_CODE)
// The session changes to user-2 before the retry: user-1's approval must
// not authorize redeeming with user-2's token.
mockAuthStore.currentUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it('re-prompts and redeems under the new account when the session changes while the approval dialog is open', async () => {
const { seedStash, stashedCode, trigger } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await trigger()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
// The session swaps to user-2 while user-1's dialog is open: the stale
// approval must not redeem, and the raced auth trigger is replayed to
// re-prompt under user-2 without another navigation.
const secondUser = {
uid: 'user-2',
getIdToken: vi.fn().mockResolvedValue('second-user-token')
}
mockAuthStore.currentUser = secondUser
await flushRedemption()
approve(true)
await flushRedemption()
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
expect(mockFetch).toHaveBeenCalledWith(
REDEEM_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer second-user-token'
})
})
)
expect(stashedCode()).toBeUndefined()
})
it.for([
['succeeds', () => okResponse()],
['fails terminally', () => new Response(null, { status: 404 })]
] as const)(
'processes a newer code stashed mid-flight after the older redemption %s',
async ([_label, firstResponse]) => {
const { trigger, seedStash, stashedCode } = await setup()
seedStash(VALID_CODE)
let resolveFirstFetch!: (response: Response) => void
mockFetch.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveFirstFetch = resolve
})
)
await trigger()
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
// A second code arrives while the first redemption is in flight; it
// must survive the first's settlement and be processed right after.
seedStash(SECOND_CODE)
mockFetch.mockResolvedValue(okResponse())
resolveFirstFetch(firstResponse())
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
expect(mockConfirm).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenLastCalledWith(
REDEEM_URL,
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
)
expect(stashedCode()).toBeUndefined()
}
)
it('coalesces concurrent triggers into one dialog and one request', async () => {
const { router, seedStash } = await setup()
seedStash(VALID_CODE)
let approve!: (value: boolean) => void
mockConfirm.mockReturnValue(
new Promise<boolean>((resolve) => {
approve = resolve
})
)
mockFetch.mockResolvedValue(okResponse())
await router.push('/burst-1')
await router.push('/burst-2')
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
approve(true)
await flushRedemption()
expect(mockConfirm).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,263 @@
import { watch } from 'vue'
import type { Router } from 'vue-router'
import { t } from '@/i18n'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useAuthStore } from '@/stores/authStore'
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN
const DESKTOP_LOGIN_CODE_KEY = 'desktop_login_code'
// The backend issues "dlc_" + 43 base64url chars; bounds are loose so the
// backend stays the authority on exact code length.
const DESKTOP_LOGIN_CODE_PATTERN = /^dlc_[A-Za-z0-9_-]{20,256}$/
// Statuses that mean the desktop app must start a fresh sign-in, so the code
// is dropped. 401 stays transient: the session may still be settling.
const TERMINAL_REDEEM_STATUSES = new Set([400, 403, 404, 409, 410])
// One delayed in-page retry, so an approved sign-in always reaches a success
// or failure toast without ever looping within a page load.
const MAX_REDEEM_ATTEMPTS = 2
const RETRY_DELAY_MS = 5_000
// Abort the redeem request if the backend hangs; treated as transient.
const REDEEM_TIMEOUT_MS = 10_000
interface CodeRedemptionState {
attempts: number
approvedUserUid: string | null
settled: boolean
forceTokenRefresh: boolean
}
// Keyed by code so a different code arriving later gets its own approval and
// attempt budget, while retries of the same code reuse both.
const codeStates = new Map<string, CodeRedemptionState>()
// Coalesces concurrent triggers into one drain; a trigger arriving mid-drain
// (e.g. the auth watcher firing while the dialog is open) is replayed as one
// more pass instead of being dropped.
let draining = false
let retriggerRequested = false
let authWatcherInstalled = false
function getCodeState(code: string): CodeRedemptionState {
const existing = codeStates.get(code)
if (existing) return existing
const fresh = {
attempts: 0,
approvedUserUid: null,
settled: false,
forceTokenRefresh: false
}
codeStates.set(code, fresh)
return fresh
}
// A newer code can be stashed while an older one is mid-redemption; settling
// the older one must not wipe it.
function clearStashIfHolds(code: string): void {
if (getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY) === code) {
clearPreservedQuery(NAMESPACE)
}
}
function settle(code: string, state: CodeRedemptionState): void {
state.settled = true
clearStashIfHolds(code)
}
function handleTransientFailure(
code: string,
state: CodeRedemptionState,
reason: string
): void {
console.warn(`[DesktopLoginRedemption] Redeem request failed: ${reason}`)
if (state.attempts < MAX_REDEEM_ATTEMPTS) {
// attempts only increments, so this branch runs at most once per code
// and cannot stack retry timers.
setTimeout(() => {
void redeemPendingDesktopLoginCode()
}, RETRY_DELAY_MS)
return
}
// Budget spent: drop the code and tell the user instead of failing silently.
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.failedSummary'),
detail: t('desktopLogin.failedDetail'),
life: 6000
})
}
// Explicit approval defeats device-code phishing: a lured click on a leaked
// link must not bind the victim's session to an attacker's desktop app.
// Approval is per code *and* account.
async function confirmRedemption(
state: CodeRedemptionState,
uid: string
): Promise<boolean> {
if (state.approvedUserUid === uid) return true
const confirmed = await useDialogService().confirm({
title: t('desktopLogin.confirmSummary'),
message: t('desktopLogin.confirmMessage')
})
if (confirmed !== true) return false
state.approvedUserUid = uid
return true
}
async function redeemCode(code: string): Promise<void> {
const state = getCodeState(code)
if (state.settled) {
// A later navigation can re-capture an already-settled code; drop it.
clearStashIfHolds(code)
return
}
// No session yet (e.g. code captured on the login page): keep the stash and
// let a post-login trigger redeem it.
const user = useAuthStore().currentUser
if (!user) return
if (!(await confirmRedemption(state, user.uid))) {
// Declined/dismissed: drop the code without an error.
settle(code, state)
return
}
// Approval binds the code to one account: if the session changed while the
// dialog was open, keep the code stashed and let the (replayed) auth-change
// trigger re-prompt under the now-current account.
const approvedUser = useAuthStore().currentUser
if (!approvedUser || approvedUser.uid !== state.approvedUserUid) return
state.attempts++
// Token comes straight from the Firebase user: authStore.getIdToken()
// surfaces failures through a modal dialog this background flow must avoid.
let idToken: string
try {
idToken = await approvedUser.getIdToken(state.forceTokenRefresh)
} catch {
handleTransientFailure(code, state, 'could not get id token')
return
}
let response: Response
try {
response = await fetch(api.apiURL('/auth/desktop-login-codes/redeem'), {
method: 'POST',
headers: {
Authorization: `Bearer ${idToken}`,
'Content-Type': 'application/json'
},
// TODO(@comfyorg/ingest-types): type the payload with the generated
// request type once the desktop-login-codes openapi addition propagates.
body: JSON.stringify({ code }),
signal: AbortSignal.timeout(REDEEM_TIMEOUT_MS)
})
} catch (error) {
handleTransientFailure(
code,
state,
error instanceof Error && error.name === 'TimeoutError'
? 'request timed out'
: 'network error'
)
return
}
if (response.ok) {
settle(code, state)
useToastStore().add({
severity: 'success',
summary: t('desktopLogin.successSummary'),
detail: t('desktopLogin.successDetail'),
life: 4000
})
return
}
if (TERMINAL_REDEEM_STATUSES.has(response.status)) {
settle(code, state)
useToastStore().add({
severity: 'error',
summary: t('desktopLogin.expiredSummary'),
detail: t('desktopLogin.expiredDetail'),
life: 6000
})
return
}
// A 401 usually means a stale cached id token; mint a fresh one on retry.
if (response.status === 401) state.forceTokenRefresh = true
handleTransientFailure(code, state, `status ${response.status}`)
}
async function redeemPendingDesktopLoginCode(): Promise<void> {
// Never rejects: the triggers fire-and-forget this.
if (draining) {
retriggerRequested = true
return
}
draining = true
try {
do {
retriggerRequested = false
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
if (!code) continue
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
clearPreservedQuery(NAMESPACE)
continue
}
await redeemCode(code)
if (code !== getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY))
retriggerRequested = true
} while (retriggerRequested)
} catch (error) {
console.error('[DesktopLoginRedemption] Redemption failed:', error)
} finally {
draining = false
}
}
function installAuthWatcherOnce(): void {
if (authWatcherInstalled) return
authWatcherInstalled = true
// A session can appear without a navigation (e.g. dialog-based sign-in).
// Installed lazily because pinia is not active when router.ts evaluates.
watch(
() => useAuthStore().currentUser,
() => {
void redeemPendingDesktopLoginCode()
}
)
}
/**
* Redeems desktop login codes (`?desktop_login_code=dlc_...`).
*
* The desktop app opens the browser with an opaque one-time code and polls
* the cloud backend; redeeming the code from a signed-in browser session,
* with the user's approval, releases a one-time custom token to that poll
* and signs the desktop app in. The preserved-query tracker (configured in
* router.ts) strips the code from the URL at capture time, so the stash is
* the only place it lives.
*/
export function installDesktopLoginRedemption(router: Router): void {
router.afterEach(() => {
installAuthWatcherOnce()
void redeemPendingDesktopLoginCode()
})
}

View File

@@ -13,7 +13,7 @@
</div>
<div
class="overflow-hidden transition-[height] duration-300 ease-out"
class="max-h-[45vh] overflow-y-auto transition-[height] duration-300 ease-out sm:max-h-[55vh]"
:style="animatedHeightStyle"
>
<div ref="questionContent" class="relative">

View File

@@ -5,5 +5,6 @@ export const PRESERVED_QUERY_NAMESPACES = {
SHARE_AUTH: 'share_auth',
CREATE_WORKSPACE: 'create_workspace',
OAUTH: 'oauth',
PRICING: 'pricing'
PRICING: 'pricing',
DESKTOP_LOGIN: 'desktop_login'
} as const

View File

@@ -5,7 +5,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useBoundingBoxesWidget } from './useBoundingBoxesWidget'
const widgetOptions = { serialize: true, canvasOnly: false }
const widgetOptions = { serialize: true, canvasOnly: false, hideInPanel: true }
function mockNode() {
return { addWidget: vi.fn(() => ({})) } as unknown as LGraphNode & {

View File

@@ -17,7 +17,8 @@ export const useBoundingBoxesWidget = (): ComfyWidgetConstructorV2 => {
})) ?? []
return node.addWidget('boundingboxes', spec.name, defaultValue, null, {
serialize: true,
canvasOnly: false
canvasOnly: false,
hideInPanel: true
}) as IBaseWidget
}
}

View File

@@ -16,6 +16,7 @@ import { useUserStore } from '@/stores/userStore'
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { captureOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
import { installDesktopLoginRedemption } from '@/platform/cloud/onboarding/desktopLoginRedemption'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { preserveLoggedOutShareAuthAttribution } from '@/platform/workflow/sharing/utils/shareAuthAttribution'
@@ -118,6 +119,11 @@ installPreservedQueryTracker(router, [
{
namespace: PRESERVED_QUERY_NAMESPACES.PRICING,
keys: ['pricing']
},
{
namespace: PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN,
keys: ['desktop_login_code'],
stripAfterCapture: true
}
])
@@ -249,6 +255,8 @@ if (isCloud) {
// User is logged in and accessing protected route
return next()
})
installDesktopLoginRedemption(router)
}
export default router