Compare commits

..

27 Commits

Author SHA1 Message Date
Comfy Org PR Bot
383760e728 [backport cloud/1.45] feat(telemetry): capture desktop entry props in cloud build (#12649)
Backport of #12647 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-06-04 13:04:14 -07:00
Comfy Org PR Bot
af8f0b60f5 1.45.15 (#12651)
Patch version increment to 1.45.15

**Base branch:** `cloud/1.45`

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2026-06-04 12:49:05 -07:00
AustinMroz
434a1b1af1 [backport cloud/1.45] refactor(assets): read content hash from the canonical hash field (#12650)
Backport of #12638 to `cloud/1.45`

Co-authored-by: Matt Miller <matt@miller-media.com>
2026-06-04 12:48:47 -07:00
Comfy Org PR Bot
bd6e5e2286 [backport cloud/1.45] feat: add app:node_added telemetry event (#12641)
Backport of #12615 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 08:56:04 -07:00
Comfy Org PR Bot
6a78e0b635 [backport cloud/1.45] fix(assets): dedupe outputs by composite key to prevent media asset panel scroll-duplication (#12640)
Backport of #11716 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-04 11:31:02 +09:00
Comfy Org PR Bot
b751750f0b [backport cloud/1.45] feat(telemetry): capture Rewardful referral on checkout attribution (#12625)
Backport of #12311 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: glary-bot <glary-bot@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-03 11:59:59 -07:00
Comfy Org PR Bot
2fa47fa260 [backport cloud/1.45] feat: add missing_node_packs to app:workflow_imported telemetry (#12616)
Backport of #12613 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 18:47:50 -07:00
AustinMroz
505728cc56 [backport cloud/1.45] Feat/cloud onboarding redesign (#12610)
Backport of #12422 to `cloud/1.45`

Co-authored-by: Maanil Verma <vermaMaanil97@gmail.com>
Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-06-02 17:14:29 -07:00
Comfy Org PR Bot
880af41f34 [backport cloud/1.45] refactor(assets): read content hash via hash field, fall back to asset_hash (#12612)
Backport of #12609 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-02 16:52:54 -07:00
Terry Jia
085bef657b [backport cloud/1.45] feat: add PreviewGaussianSplat + PreviewPointCloud extensions (#12597)
Backport https://github.com/Comfy-Org/ComfyUI_frontend/pull/12545 to
cloud/1.45

Tested and Verified on local build
<img width="1886" height="1538" alt="image"
src="https://github.com/user-attachments/assets/6f5086e8-05c8-47c8-95cd-8c9bb9ae8a5a"
/>
2026-06-02 13:46:26 -07:00
Comfy Org PR Bot
f849e9be77 [backport cloud/1.45] Pr/12481 - fixed error (#12604)
Backport of #12574 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Steven Tran <94876858+stevenltran@users.noreply.github.com>
Co-authored-by: Nav Singh <nav@comfy.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-02 11:01:39 -07:00
Comfy Org PR Bot
bd48bf1bbe [backport cloud/1.45] Updated Pr 12480 - fix(telemetry): call posthog.reset(true) on logout to prevent session bleeding (#12606)
Backport of #12599 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Steven Tran <94876858+stevenltran@users.noreply.github.com>
Co-authored-by: Nav Singh <nav@comfy.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: nav <nav@mac.lan>
Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
2026-06-02 11:01:31 -07:00
Comfy Org PR Bot
75fb11785a [backport cloud/1.45] fix: dedupe Bypass context-menu items via state-aware legacy label (FE-720) (#12587)
Backport of #12500 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-02 14:53:17 +09:00
Comfy Org PR Bot
5ff0b33295 [backport cloud/1.45] Track undo state on subgraph conversion (#12585)
Backport of #12575 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-01 18:59:00 -07:00
Comfy Org PR Bot
e89778fc89 [backport cloud/1.45] Remove drag node test from interaction.spec.ts (#12589)
Backport of #12579 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-01 18:57:16 -07:00
Comfy Org PR Bot
6f3ef2ed70 [backport cloud/1.45] fix(cloud/oauth): mint session cookie when resuming consent while already signed in (#12577)
Backport of #12571 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Matt Miller <matt@miller-media.com>
2026-06-01 15:34:09 -07:00
Comfy Org PR Bot
cd216d4db8 [backport cloud/1.45] fix(telemetry): harden PostHog init — person_profiles, cookie_domain, before_send (#12573)
Backport of #12479 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miles <miles@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: nav <nav@mac.lan>
Co-authored-by: Miles Ryan <thedatalife@users.noreply.github.com>
2026-06-01 14:53:21 -07:00
Comfy Org PR Bot
bccfc41f5d [backport cloud/1.45] fix: preserve validation errors on execution start (#12548)
Backport of #12493 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:31:35 +09:00
Comfy Org PR Bot
2bf1eb6e19 [backport cloud/1.45] fix: open model library for desktop model downloads (#12552)
Backport of #12478 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:31:08 +09:00
Comfy Org PR Bot
d2ad47634a [backport cloud/1.45] Fix node tooltip metadata i18n parsing (#12556)
Backport of #12469 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-05-31 19:30:57 +09:00
Comfy Org PR Bot
daeae316b1 [backport cloud/1.45] Fix interrupted audio playback from assets panel (#12525)
Backport of #12425 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 12:39:16 -07:00
Comfy Org PR Bot
20cf8074a9 [backport cloud/1.45] Fix ghost links on IO remove slot (#12523)
Backport of #12473 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-29 10:38:30 -07:00
Comfy Org PR Bot
c87cb03024 [backport cloud/1.45] Fix restoring values to dynamic combos (#12490)
Backport of #12211 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 11:39:14 -07:00
Comfy Org PR Bot
28c4080134 [backport cloud/1.45] Fix mask editor sometimes showing wrong image (#12484)
Backport of #12413 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 00:47:08 -07:00
Comfy Org PR Bot
1a6e77e955 [backport cloud/1.45] Fix errant subscription popups with workspaces (#12476)
Backport of #12472 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-26 19:42:39 -07:00
Comfy Org PR Bot
e06d7a7b34 [backport cloud/1.45] fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12454)
Backport of #12447 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-05-25 21:52:32 -07:00
Comfy Org PR Bot
c76b7280af [backport cloud/1.45] Fix missing value control on 'Primitive Int' (#12462)
Backport of #12431 to `cloud/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-25 21:22:28 -07:00
93 changed files with 1670 additions and 1044 deletions

View File

@@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => ({
mockInit: vi.fn(),
mockCapture: vi.fn()
}))
vi.mock('posthog-js', () => ({
default: {
init: hoisted.mockInit,
capture: hoisted.mockCapture
}
}))
describe('initPostHog', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
const { initPostHog } = await import('./posthog')
initPostHog()
expect(hoisted.mockInit).toHaveBeenCalledOnce()
const initOptions = hoisted.mockInit.mock.calls[0][1]
expect(initOptions.person_profiles).toBe('identified_only')
expect(typeof initOptions.before_send).toBe('function')
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: { email: 'd@example.com', name: 'keep me' },
$set_once: { $email: 'e@example.com', plan: 'free' }
}
const result = initOptions.before_send(event)
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('$email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})

View File

@@ -1,5 +1,7 @@
import posthog from 'posthog-js'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
const POSTHOG_KEY =
import.meta.env.PUBLIC_POSTHOG_KEY ??
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
@@ -18,7 +20,9 @@ export function initPostHog() {
ui_host: POSTHOG_UI_HOST,
capture_pageview: false,
capture_pageleave: true,
person_profiles: 'identified_only'
person_profiles: 'identified_only',
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
before_send: createPostHogBeforeSend()
})
initialized = true
} catch (error) {

View File

@@ -1,10 +1,11 @@
import type { Asset } from '@comfyorg/ingest-types'
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
function createModelAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-model-001',
name: 'model.safetensors',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
function createInputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-input-001',
name: 'input.png',
asset_hash:
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
}
}
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
function createOutputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
return {
id: 'test-output-001',
name: 'output_00001.png',
asset_hash:
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],

View File

@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
in_library: false
}
const defaultInputAsset: Asset = {
const defaultInputAsset: Asset & { hash?: string } = {
id: 'default-input-asset',
name: defaultInputFileName,
asset_hash: defaultInputFileName,
hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset = {
const importedInputAsset: Asset & { hash?: string } = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

View File

@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const LOTUS_DIFFUSION_MODEL: Asset = {
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'diffusion_models'],

View File

@@ -24,10 +24,10 @@ const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const cloudOutputAsset: Asset = {
const cloudOutputAsset: Asset & { hash?: string } = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
asset_hash: outputHash,
hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
@@ -36,10 +36,10 @@ const cloudOutputAsset: Asset = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset = {
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
asset_hash: plainVideoFileName,
hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
@@ -50,10 +50,10 @@ const cloudUploadedVideoAsset: Asset = {
// The Cloud test app starts with a default LoadImage node. Keep that baseline
// input resolvable so this spec only observes the media it creates.
const cloudDefaultGraphInputAsset: Asset = {
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

14
global.d.ts vendored
View File

@@ -11,6 +11,18 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
interface RewardfulGlobal {
referral?: string
affiliate?: { id?: string; token?: string; name?: string }
campaign?: { id?: string; name?: string }
}
interface RewardfulQueueFunction {
(method: 'ready', callback: () => void): void
(...args: unknown[]): void
q?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
@@ -63,6 +75,8 @@ interface Window {
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction
Rewardful?: RewardfulGlobal
}
interface Navigator {

View File

@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
@@ -25,6 +25,7 @@
@theme {
--shadow-interface: var(--interface-panel-box-shadow);
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
--text-2xs: 0.625rem;
--text-2xs--line-height: calc(1 / 0.625);
@@ -65,6 +66,9 @@
--color-ocean-600: #2f687a;
--color-ocean-900: #253236;
--color-primary-comfy-ink: #211927;
--color-primary-comfy-canvas: #c2bfb9;
--color-danger-100: #c02323;
--color-danger-200: #d62952;

View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -7,7 +7,8 @@
"type": "module",
"exports": {
"./formatUtil": "./src/formatUtil.ts",
"./networkUtil": "./src/networkUtil.ts"
"./networkUtil": "./src/networkUtil.ts",
"./piiUtil": "./src/piiUtil.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { createPostHogBeforeSend } from './piiUtil'
describe('createPostHogBeforeSend', () => {
const beforeSend = createPostHogBeforeSend()
it('returns null for null input', () => {
expect(beforeSend(null)).toBeNull()
})
it('strips all PII keys from properties, $set, and $set_once', () => {
const event = {
properties: {
email: 'a@example.com',
prompt: 'hello',
user_email: 'b@example.com',
$email: 'c@example.com',
method: 'google'
},
$set: {
email: 'd@example.com',
user_email: 'e@example.com',
$email: 'f@example.com',
name: 'keep me'
},
$set_once: {
email: 'g@example.com',
plan: 'free'
}
}
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).not.toHaveProperty('user_email')
expect(result.$set).not.toHaveProperty('$email')
expect(result.$set).toHaveProperty('name', 'keep me')
expect(result.$set_once).not.toHaveProperty('email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('handles missing property bags gracefully', () => {
const event = { properties: { email: 'a@example.com', safe: true } }
const result = beforeSend(event)!
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).toHaveProperty('safe', true)
expect(result.$set).toBeUndefined()
expect(result.$set_once).toBeUndefined()
})
})

View File

@@ -0,0 +1,35 @@
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
function stripPiiKeys(obj?: Record<string, unknown>): void {
if (!obj) return
for (const key of PII_KEYS) {
delete obj[key]
}
}
/**
* PostHog before_send hook that strips PII from all three property bags
* an event can carry: properties, $set, and $set_once.
*
* posthog.identify(id, { email }) lands in $set, not properties, so all
* three bags must be sanitized.
*
* Ref: posthog.com/tutorials/web-redact-properties
*/
interface PostHogEventLike {
properties?: Record<string, unknown>
$set?: Record<string, unknown>
$set_once?: Record<string, unknown>
}
export function createPostHogBeforeSend() {
return function beforeSend<E extends PostHogEventLike>(
event: E | null
): E | null {
if (!event) return null
stripPiiKeys(event.properties)
stripPiiKeys(event.$set)
stripPiiKeys(event.$set_once)
return event
}
}

View File

@@ -71,6 +71,7 @@
v-if="showCameraControls"
v-model:camera-type="cameraConfig!.cameraType"
v-model:fov="cameraConfig!.fov"
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
/>
<div v-if="showLightControls" class="flex flex-col">

View File

@@ -18,10 +18,32 @@
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<Button
v-tooltip.right="{
value: $t('load3d.retainViewOnReload'),
showDelay: 300
}"
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('load3d.retainViewOnReload')"
:aria-pressed="retainViewOnReload"
@click="retainViewOnReload = !retainViewOnReload"
>
<i
:class="
cn(
'pi text-lg text-base-foreground',
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
)
"
/>
</Button>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
@@ -30,6 +52,9 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
default: false
})
const showFOVButton = computed(() => cameraType.value === 'perspective')
const switchCamera = () => {

View File

@@ -66,6 +66,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
@@ -130,10 +131,12 @@ const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
const node = litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
const node = withNodeAddSource('search_modal', () =>
litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
)
if (!node) return

View File

@@ -65,6 +65,7 @@ import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
@@ -155,8 +156,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const graphNode = useLitegraphService().addNodeOnGraph(
provider.nodeDef
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key

View File

@@ -189,6 +189,7 @@ import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import {
DEFAULT_GROUPING_ID,
@@ -321,8 +322,11 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
}
},
handleClick(e: MouseEvent) {
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
} else {
toggleNodeOnEvent(e, this)
}

View File

@@ -39,6 +39,7 @@ import NodePreview from '@/components/node/NodePreview.vue'
import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue'
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -183,8 +184,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
await nodeBookmarkStore.addBookmark(nodePath)
},
handleClick(e: MouseEvent) {
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
} else {
toggleNodeOnEvent(e, node)
}

View File

@@ -1,3 +1,4 @@
import { FirebaseError } from 'firebase/app'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -26,9 +27,20 @@ const mockDialogService = vi.hoisted(() => ({
confirm: vi.fn()
}))
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
])
vi.mock('@/i18n', () => ({
t: (key: string, values?: { workflow?: string }) =>
values?.workflow ? `${key}:${values.workflow}` : key
values?.workflow ? `${key}:${values.workflow}` : key,
st: (key: string, fallback: string) => {
const code = key.replace('auth.errors.', '')
return knownAuthErrorCodes.has(code) ? key : fallback
}
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -72,7 +84,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: vi.fn()
toastErrorHandler: mockToastErrorHandler
})
}))
@@ -193,3 +205,46 @@ describe('useAuthActions.logout', () => {
)
})
})
describe('useAuthActions.reportError', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('shows the friendly message for a known Firebase auth code', () => {
const { reportError } = useAuthActions()
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.auth/invalid-credential'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('shows the generic fallback for an unknown Firebase auth code', () => {
const { reportError } = useAuthActions()
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.generic'
})
expect(mockToastErrorHandler).not.toHaveBeenCalled()
})
it('delegates non-Firebase errors to toastErrorHandler', () => {
const { reportError } = useAuthActions()
const networkError = new TypeError('Failed to fetch')
reportError(networkError)
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
expect(mockToastStore.add).not.toHaveBeenCalled()
})
})

View File

@@ -5,7 +5,7 @@ import { ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { st, t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -47,6 +47,12 @@ export const useAuthActions = () => {
email: 'support@comfy.org'
})
})
} else if (error instanceof FirebaseError) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: st(`auth.errors.${error.code}`, t('auth.errors.generic'))
})
} else {
toastErrorHandler(error)
}

View File

@@ -1,5 +1,6 @@
import { ref, shallowRef } from 'vue'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -37,7 +38,8 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
if (!draggedNode.value) return false
const nodeDef = draggedNode.value
if (!nodeDef) return false
const canvas = useCanvasStore().canvas
if (!canvas) return false
if (!isOverCanvas(clientX, clientY)) return false
@@ -46,7 +48,9 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
clientX,
clientY
} as PointerEvent)
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
const node = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
if (node) canvas.selectItems([node])
return true
}

View File

@@ -8,6 +8,7 @@ import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/as
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
@@ -146,9 +147,11 @@ export function useJobMenu(
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
if (!node) return

View File

@@ -4,6 +4,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app as comfyApp } from '@/scripts/app'
@@ -37,7 +38,9 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
// Add an offset on y to make sure after adding the node, the cursor
// is on the node (top left corner)
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
litegraphService.addNodeOnGraph(nodeDef, { pos })
withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(nodeDef, { pos })
)
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = basePos
@@ -58,11 +61,8 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = litegraphService.addNodeOnGraph(
provider.nodeDef,
{
pos
}
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
)
targetProvider = provider
}

View File

@@ -2,6 +2,7 @@ import { computed, reactive, readonly } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
@@ -107,7 +108,8 @@ export function useFeatureFlags() {
if (override !== undefined) return override
if (!isCloud) return false
if (!isAuthenticatedConfigLoaded.value) return false
if (!isAuthenticatedConfigLoaded.value)
return cachedTeamWorkspacesEnabled.value ?? false
return (
remoteConfig.value.team_workspaces_enabled ??

View File

@@ -144,6 +144,7 @@ describe('useLoad3d', () => {
setMaterialMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setRetainViewOnReload: vi.fn(),
setLightIntensity: vi.fn(),
setCameraState: vi.fn(),
loadModel: vi.fn().mockResolvedValue(undefined),
@@ -333,20 +334,6 @@ describe('useLoad3d', () => {
expect(composable.isPreview.value).toBe(true)
})
it('should set preview mode when comfyClass starts with Preview, even with width/height widgets', async () => {
Object.defineProperty(mockNode, 'constructor', {
value: { comfyClass: 'Preview3DAdvanced' },
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')
@@ -583,17 +570,21 @@ describe('useLoad3d', () => {
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
vi.mocked(mockLoad3d.setFOV!).mockClear()
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
composable.cameraConfig.value.cameraType = 'orthographic'
composable.cameraConfig.value.fov = 90
composable.cameraConfig.value.retainViewOnReload = true
await nextTick()
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
expect(mockNode.properties['Camera Config']).toEqual({
cameraType: 'orthographic',
fov: 90,
state: null
state: null,
retainViewOnReload: true
})
})

View File

@@ -152,10 +152,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (
node.constructor.comfyClass?.startsWith('Preview') ||
!(widthWidget && heightWidget)
) {
if (!(widthWidget && heightWidget)) {
isPreview.value = true
}
@@ -487,6 +484,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Camera Config'] = newValue
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
}
},
{ deep: true }

View File

@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyExtension } from '@/types/comfy'
@@ -10,21 +9,17 @@ const {
waitForLoad3dMock,
onLoad3dReadyMock,
configureMock,
configureForSaveMeshMock,
getLoad3dMock,
toastAddAlertMock,
getNodeByLocatorIdMock,
nodeToLoad3dMap
getNodeByLocatorIdMock
} = vi.hoisted(() => ({
registerExtensionMock: vi.fn(),
waitForLoad3dMock: vi.fn(),
onLoad3dReadyMock: vi.fn(),
configureMock: vi.fn(),
configureForSaveMeshMock: vi.fn(),
getLoad3dMock: vi.fn(),
toastAddAlertMock: vi.fn(),
getNodeByLocatorIdMock: vi.fn(),
nodeToLoad3dMap: new Map<object, unknown>()
getNodeByLocatorIdMock: vi.fn()
}))
vi.mock('@/services/extensionService', () => ({
@@ -43,13 +38,12 @@ vi.mock('@/composables/useLoad3d', () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap
nodeToLoad3dMap: new Map()
}))
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
configure = configureMock
configureForSaveMesh = configureForSaveMeshMock
}
}))
@@ -122,21 +116,18 @@ type ExtCreated = ComfyExtension & {
onNodeOutputsUpdated: (
nodeOutputs: Record<string, Record<string, unknown>>
) => void
getCustomWidgets: () => Record<string, (node: LGraphNode) => unknown>
}
async function loadExtensionsFresh(): Promise<{
load3DExt: ExtCreated
preview3DExt: ExtCreated
preview3DAdvancedExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3d')
return {
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
}
}
@@ -162,22 +153,6 @@ function makePreview3DNode(
} as unknown as LGraphNode
}
function makePreview3DAdvancedNode(
overrides: Partial<{
comfyClass: string
properties: Record<string, unknown>
widgets: FakeWidget[]
}> = {}
): LGraphNode {
return {
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
size: [400, 550],
setSize: vi.fn(),
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
properties: overrides.properties ?? {}
} as unknown as LGraphNode
}
function makeLoad3DNode(
overrides: Partial<{
comfyClass: string
@@ -204,14 +179,7 @@ interface FakeLoad3d {
whenLoadIdle: () => Promise<void>
setCameraFromMatrices: ReturnType<typeof vi.fn>
setBackgroundImage: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
getModelInfo: ReturnType<typeof vi.fn>
applyModelTransform: ReturnType<typeof vi.fn>
isSplatModel: ReturnType<typeof vi.fn>
forceRender: ReturnType<typeof vi.fn>
cameraManager: { perspectiveCamera: { fov: number } }
currentLoadGeneration: number
}
@@ -220,14 +188,7 @@ function makeLoad3dMock(): FakeLoad3d {
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
setCameraFromMatrices: vi.fn(),
setBackgroundImage: vi.fn(),
setCameraState: vi.fn(),
getCameraState: vi.fn(() => ({ position: [0, 0, 5], target: [0, 0, 0] })),
getCurrentCameraType: vi.fn(() => 'perspective'),
getModelInfo: vi.fn(() => null),
applyModelTransform: vi.fn(),
isSplatModel: vi.fn(() => false),
forceRender: vi.fn(),
cameraManager: { perspectiveCamera: { fov: 35 } },
currentLoadGeneration: 0
}
}
@@ -238,7 +199,6 @@ async function flush() {
function setupBaseMocks() {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
cb(makeLoad3dMock())
})
@@ -250,14 +210,12 @@ 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 } =
await loadExtensionsFresh()
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
expect(load3DExt.name).toBe('Comfy.Load3D')
expect(preview3DExt.name).toBe('Comfy.Preview3D')
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
})
})
@@ -518,47 +476,6 @@ describe('Comfy.Load3D.nodeCreated', () => {
})
})
describe('Comfy.Load3D.getCustomWidgets LOAD_3D', () => {
beforeEach(setupBaseMocks)
it('adds upload and clear buttons when the node has a model_file widget', async () => {
const { load3DExt } = await loadExtensionsFresh()
const node = makeLoad3DNode()
const addWidget = node.addWidget as ReturnType<typeof vi.fn>
load3DExt.getCustomWidgets().LOAD_3D(node)
const buttonNames = addWidget.mock.calls
.filter(([type]) => type === 'button')
.map(([, name]) => name)
expect(buttonNames).toEqual([
'upload 3d model',
'upload extra resources',
'clear'
])
})
it('skips upload and clear buttons when the node has no model_file widget (e.g. Preview3DAdvanced)', async () => {
const { load3DExt } = await loadExtensionsFresh()
const node = makeLoad3DNode({
comfyClass: 'Preview3DAdvanced',
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 },
{ name: 'image', value: '' }
]
})
const addWidget = node.addWidget as ReturnType<typeof vi.fn>
load3DExt.getCustomWidgets().LOAD_3D(node)
const buttonCalls = addWidget.mock.calls.filter(
([type]) => type === 'button'
)
expect(buttonCalls).toEqual([])
})
})
describe('getNodeMenuItems', () => {
beforeEach(setupBaseMocks)
@@ -693,324 +610,3 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
)
})
})
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not Preview3DAdvanced', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'OtherNode' })
await preview3DAdvancedExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('does not call configureForSaveMesh on creation when no Last Time Model File is persisted', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('restores via configureForSaveMesh when Last Time Model File is persisted', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
properties: { 'Last Time Model File': 'prev/model.glb' }
})
await preview3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'prev/model.glb',
{ silentOnNotFound: true }
)
})
it('restores the saved camera state after model load when reloading the page', async () => {
const persistedCameraState = {
position: [1, 2, 3],
target: [0, 0, 0]
} as unknown as CameraState
const load3dInstance = makeLoad3dMock()
onLoad3dReadyMock.mockImplementationOnce(
(cb: (load3d: FakeLoad3d) => void) => {
cb(load3dInstance)
}
)
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
properties: {
'Last Time Model File': 'prev/model.glb',
'Camera Config': {
cameraType: 'perspective',
fov: 35,
state: persistedCameraState
}
}
})
await preview3DAdvancedExt.nodeCreated(node)
await flush()
expect(load3dInstance.setCameraState).toHaveBeenCalledWith(
persistedCameraState
)
expect(load3dInstance.forceRender).toHaveBeenCalled()
})
it('does not call setCameraState when no Camera Config state is persisted', async () => {
const load3dInstance = makeLoad3dMock()
onLoad3dReadyMock.mockImplementationOnce(
(cb: (load3d: FakeLoad3d) => void) => {
cb(load3dInstance)
}
)
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
properties: { 'Last Time Model File': 'prev/model.glb' }
})
await preview3DAdvancedExt.nodeCreated(node)
await flush()
expect(load3dInstance.setCameraState).not.toHaveBeenCalled()
})
it('attaches a camera-only serializeValue to the image widget', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
await preview3DAdvancedExt.nodeCreated(node)
expect(typeof widgets[0].serializeValue).toBe('function')
})
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
nodeToLoad3dMap.set(node, load3d)
await preview3DAdvancedExt.nodeCreated(node)
const payload = await widgets[0].serializeValue!()
expect(payload).toEqual({
image: '',
mask: '',
normal: '',
camera_info: { position: [0, 0, 5], target: [0, 0, 0] },
recording: '',
model_3d_info: []
})
})
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
const load3d = makeLoad3dMock()
const modelInfo = {
position: { x: 1, y: 2, z: 3 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 1, y: 1, z: 1 }
}
load3d.getModelInfo = vi.fn(() => modelInfo)
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
nodeToLoad3dMap.set(node, load3d)
await preview3DAdvancedExt.nodeCreated(node)
const payload = (await widgets[0].serializeValue!()) as {
model_3d_info: unknown[]
}
expect(payload.model_3d_info).toEqual([modelInfo])
})
it('onExecuted persists Last Time Model File with normalized slashes and calls configureForSaveMesh', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'sub/nested/mesh.glb',
{ silentOnNotFound: true }
)
})
it('onExecuted applies the input cameraState when one is forwarded via PreviewUI3D', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.currentLoadGeneration = 5
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const cameraState = { position: [1, 2, 3] }
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['mesh.glb', cameraState] })
await flush()
expect(load3d.setCameraState).toHaveBeenCalledWith(cameraState)
})
it('onExecuted applies the first model_3d_info entry to the viewport when present', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.currentLoadGeneration = 5
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const transform = {
position: { x: 1, y: 2, z: 3 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 2, y: 2, z: 2 }
}
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({
result: ['mesh.glb', undefined, [transform]]
})
await flush()
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
})
it('onExecuted does not call applyModelTransform when model_3d_info is empty', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({
result: ['mesh.glb', undefined, []]
})
await flush()
expect(load3d.applyModelTransform).not.toHaveBeenCalled()
})
it('onExecuted defensively skips cameraState apply when result[1] is missing', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['mesh.glb'] })
await flush()
expect(load3d.setCameraState).not.toHaveBeenCalled()
})
it('onExecuted skips cameraState apply when load3d generation changes before whenLoadIdle resolves', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
load3d.currentLoadGeneration = 5
let resolveIdle: () => void = () => {}
load3d.whenLoadIdle = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveIdle = resolve
})
)
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['mesh.glb', { position: [1, 2, 3] }] })
load3d.currentLoadGeneration = 6
resolveIdle()
await flush()
expect(load3d.setCameraState).not.toHaveBeenCalled()
})
it('onExecuted shows an error toast when no file path is returned', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode()
await preview3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: [] })
expect(toastAddAlertMock).toHaveBeenCalledWith(
'toastMessages.unableToGetModelFilePath'
)
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
})
describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
beforeEach(setupBaseMocks)
it('returns [] for non-Preview3DAdvanced nodes', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const node = {
constructor: { comfyClass: 'OtherNode' }
} as unknown as LGraphNode
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
})
it('returns [] when no load3d instance exists for the node', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
getLoad3dMock.mockReturnValue(null)
const node = {
constructor: { comfyClass: 'Preview3DAdvanced' }
} as unknown as LGraphNode
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
})
it('returns [] for splat models', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
const node = {
constructor: { comfyClass: 'Preview3DAdvanced' }
} as unknown as LGraphNode
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
})
it('returns export menu items for non-splat models', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
const node = {
constructor: { comfyClass: 'Preview3DAdvanced' }
} as unknown as LGraphNode
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([
{ content: 'Export' }
])
})
})

View File

@@ -29,9 +29,6 @@ type Matrix = number[][]
type Load3dPreviewOutput = NodeOutputWith<{
result?: [string?, CameraState?, string?, Matrix?, Matrix?]
}>
type Preview3DAdvancedOutput = NodeOutputWith<{
result?: [string?, CameraState?, Model3DInfo?]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { ComfyApp, app } from '@/scripts/app'
@@ -272,10 +269,7 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
const hasModelFileWidget = node.widgets?.some(
(w) => w.name === 'model_file'
)
if (hasModelFileWidget) {
if (node.constructor.comfyClass === 'Load3D') {
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
node.properties['Resource Folder'] = ''
@@ -657,156 +651,3 @@ useExtensionService().registerExtension({
})
}
})
useExtensionService().registerExtension({
name: 'Comfy.Preview3DAdvanced',
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()
const onExecuted = node.onExecuted
useLoad3d(node).onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('output', lastTimeModelFile as string, {
silentOnNotFound: true
})
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) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
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
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh('output', normalizedPath, {
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
)
})
}
}
})
}
})

View File

@@ -287,41 +287,6 @@ describe('GizmoManager', () => {
})
})
describe('applyModelTransform', () => {
it('sets position, quaternion, and scale on target and notifies', () => {
manager.init()
const model = new THREE.Object3D()
manager.setupForModel(model)
manager.applyModelTransform({
position: { x: 1, y: 2, z: 3 },
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.92 },
scale: { x: 2, y: 2, z: 2 }
})
expect(model.position.x).toBeCloseTo(1)
expect(model.position.y).toBeCloseTo(2)
expect(model.position.z).toBeCloseTo(3)
expect(model.quaternion.x).toBeCloseTo(0.1)
expect(model.quaternion.y).toBeCloseTo(0.2)
expect(model.quaternion.z).toBeCloseTo(0.3)
expect(model.quaternion.w).toBeCloseTo(0.92)
expect(model.scale.x).toBeCloseTo(2)
expect(onTransformChange).toHaveBeenCalledOnce()
})
it('does nothing without a target', () => {
manager.init()
expect(() =>
manager.applyModelTransform({
position: { x: 0, y: 0, z: 0 },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 1, y: 1, z: 1 }
})
).not.toThrow()
})
})
describe('getTransform', () => {
it('returns current target transform', () => {
manager.init()

View File

@@ -159,27 +159,6 @@ export class GizmoManager {
}
}
applyModelTransform(transform: Model3DTransform): void {
if (!this.targetObject) return
this.targetObject.position.set(
transform.position.x,
transform.position.y,
transform.position.z
)
this.targetObject.quaternion.set(
transform.quaternion.x,
transform.quaternion.y,
transform.quaternion.z,
transform.quaternion.w
)
this.targetObject.scale.set(
transform.scale.x,
transform.scale.y,
transform.scale.z
)
this.onTransformChange?.()
}
getInitialTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }

View File

@@ -39,7 +39,6 @@ type GizmoStub = {
setMode: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
applyTransform: ReturnType<typeof vi.fn>
applyModelTransform: ReturnType<typeof vi.fn>
getTransform: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
updateCamera: ReturnType<typeof vi.fn>
@@ -74,7 +73,6 @@ function makeGizmoStub(): GizmoStub {
setMode: vi.fn(),
reset: vi.fn(),
applyTransform: vi.fn(),
applyModelTransform: vi.fn(),
getTransform: vi.fn(() => ({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
@@ -205,19 +203,6 @@ describe('Load3d', () => {
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
})
it('applyModelTransform forwards the full position/quaternion/scale payload', () => {
const transform = {
position: { x: 1, y: 2, z: 3 },
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.4 },
scale: { x: 2, y: 2, z: 2 }
}
ctx.load3d.applyModelTransform(transform)
expect(ctx.gizmo.applyModelTransform).toHaveBeenCalledWith(transform)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
it('getGizmoTransform returns the gizmoManager transform', () => {
const transform = {
position: { x: 5, y: 6, z: 7 },
@@ -787,8 +772,8 @@ describe('Load3d', () => {
})
})
describe('camera framing across reloads', () => {
function setupLoadInternal() {
describe('retainViewOnReload', () => {
function setupLoadInternal(initialFlag: boolean) {
const getCameraState = vi.fn<() => CameraState>(() => ({
position: new THREE.Vector3(1, 2, 3),
target: new THREE.Vector3(),
@@ -817,23 +802,25 @@ describe('Load3d', () => {
setupModelAnimations: vi.fn()
},
handleResize: vi.fn(),
retainViewOnReload: initialFlag,
hasLoadedModel: false
})
return { getCameraState, setCameraState, getCurrentCameraType }
}
it('first load uses default framing', async () => {
const mocks = setupLoadInternal()
it('first load uses default framing even with retain enabled', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
// hasLoadedModel started false, so retain shouldn't kick in yet.
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('subsequent load preserves the user-adjusted camera framing', async () => {
const mocks = setupLoadInternal()
it('subsequent load captures camera state, skips reset, and restores it', async () => {
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
@@ -847,8 +834,23 @@ describe('Load3d', () => {
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
it('does not retain when the flag is off, even after a prior load', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
expect(mocks.setCameraState).not.toHaveBeenCalled()
})
it('toggles to the saved camera type before restoring state when types differ', async () => {
const mocks = setupLoadInternal()
const mocks = setupLoadInternal(true)
mocks.getCameraState.mockImplementation(() => ({
position: new THREE.Vector3(0, 0, 5),
target: new THREE.Vector3(),
@@ -868,7 +870,7 @@ describe('Load3d', () => {
})
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
const mocks = setupLoadInternal()
const mocks = setupLoadInternal(true)
await ctx.load3d.loadModel('a.glb')
ctx.load3d.clearModel()
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
@@ -879,6 +881,22 @@ describe('Load3d', () => {
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
expect(mocks.getCameraState).not.toHaveBeenCalled()
})
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
const mocks = setupLoadInternal(false)
await ctx.load3d.loadModel('a.glb')
ctx.load3d.setRetainViewOnReload(true)
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
mocks.getCameraState.mockClear()
mocks.setCameraState.mockClear()
await ctx.load3d.loadModel('b.glb')
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
expect(mocks.getCameraState).toHaveBeenCalledOnce()
expect(mocks.setCameraState).toHaveBeenCalledOnce()
})
})
describe('captureScene', () => {

View File

@@ -105,6 +105,7 @@ class Load3d {
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private retainViewOnReload: boolean = false
private hasLoadedModel: boolean = false
constructor(
@@ -578,14 +579,17 @@ class Load3d {
}
}
public setRetainViewOnReload(value: boolean): void {
this.retainViewOnReload = value
}
private async _loadModelInternal(
url: string,
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
// First load always uses default framing; subsequent reloads preserve
// the user's framing.
const shouldRetainView = this.hasLoadedModel
// First load always uses default framing; retain only applies on reload.
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
const savedCameraState = shouldRetainView
? this.cameraManager.getCameraState()
: null
@@ -915,12 +919,6 @@ class Load3d {
this.forceRender()
}
public applyModelTransform(transform: Model3DTransform): void {
if (!this.getCurrentModelCapabilities().gizmoTransform) return
this.gizmoManager.applyModelTransform(transform)
this.forceRender()
}
public getGizmoTransform(): {
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number }

View File

@@ -80,6 +80,7 @@ export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
retainViewOnReload?: boolean
}
export interface LightConfig {

View File

@@ -2278,7 +2278,8 @@
"auth/invalid-credential": "Invalid login credentials. Please check your email and password.",
"auth/network-request-failed": "Network error. Please check your connection and try again.",
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again.",
"generic": "Something went wrong while signing you in. Please try again."
},
"deleteAccount": {
"contactSupport": "To delete your account, please contact {email}"
@@ -2949,6 +2950,29 @@
"cloudStart_learnAboutButton": "Learn about Cloud",
"cloudStart_wantToRun": "Want to run ComfyUI locally instead?",
"cloudStart_download": "Download ComfyUI",
"cloudHero": {
"previousSlide": "Previous slide",
"nextSlide": "Next slide",
"slidePagerLabel": "Go to slide {index}",
"slides": {
"cloud": {
"title": "Cloud",
"description": "Best for most users who want to work from anywhere with models verified for commercial license."
},
"workflows": {
"title": "Workflows",
"description": "From idea to output in minutes. Generate multiple variations side by side."
},
"team": {
"title": "Team",
"description": "Onboard your team today. Share workflows and assets across your organization."
},
"models": {
"title": "Models",
"description": "Curated, commercially licensed models ready to run with zero setup."
}
}
},
"cloudWaitlist_questionsText": "Questions? Contact us",
"cloudWaitlist_contactLink": "here",
"cloudSorryContactSupport_title": "Sorry, contact support",

View File

@@ -176,7 +176,7 @@ describe('AssetBrowserModal', () => {
): AssetItem => ({
id,
name,
asset_hash: `blake3:${id.padEnd(64, '0')}`,
hash: `blake3:${id.padEnd(64, '0')}`,
size: 1024000,
mime_type: 'application/octet-stream',
tags: ['models', category, 'test'],

View File

@@ -49,10 +49,10 @@ const ORIGINAL_FILENAME = 'sunset_photo.png'
function createDisplayAsset(
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem {
return {
const base = {
id: 'asset-1',
name: HASH,
asset_hash: HASH,
hash: HASH,
tags: ['input'],
preview_url: '/preview.png',
secondaryText: '',
@@ -62,6 +62,7 @@ function createDisplayAsset(
metadata: { filename: ORIGINAL_FILENAME },
...overrides
}
return base
}
function renderCard(asset: AssetDisplayItem) {
@@ -97,7 +98,7 @@ describe('AssetCard', () => {
})
describe('FE-228: filename rendering', () => {
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
it('renders the human-readable filename instead of hash when asset.name equals hash', () => {
const asset = createDisplayAsset()
renderCard(asset)
@@ -130,7 +131,7 @@ describe('AssetCard', () => {
const asset = createDisplayAsset({
id: 'model-1',
name: MODEL_FILENAME,
asset_hash: undefined,
hash: undefined,
tags: ['models', 'loras'],
user_metadata: { name: CURATED_NAME },
metadata: { filename: MODEL_FILENAME }
@@ -146,7 +147,7 @@ describe('AssetCard', () => {
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
const asset = createDisplayAsset({
name: HASH,
asset_hash: HASH,
hash: HASH,
user_metadata: { name: HASH },
metadata: { filename: ORIGINAL_FILENAME }
})

View File

@@ -32,7 +32,7 @@ function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
return {
id: 'asset-1',
name: 'mesh.glb',
asset_hash: null,
hash: null,
mime_type: 'model/gltf-binary',
tags: [],
kind: '3D',

View File

@@ -13,7 +13,7 @@ function createVideoAsset(
return {
id: 'video-1',
name: 'clip.mp4',
asset_hash: null,
hash: null,
mime_type: mimeType,
tags: [],
kind: 'video',

View File

@@ -28,7 +28,7 @@ describe('ModelInfoPanel', () => {
): AssetDisplayItem => ({
id: 'test-id',
name: 'test-model.safetensors',
asset_hash: 'hash123',
hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],

View File

@@ -26,7 +26,7 @@ function makeAsset(index: number): AssetItem {
return {
id: `asset-${index}`,
name: `asset-${index}.safetensors`,
asset_hash: `blake3:${index}`,
hash: `blake3:${index}`,
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', category],

View File

@@ -35,7 +35,7 @@ describe('useAssetBrowser', () => {
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
id: 'test-id',
name: 'test-asset.safetensors',
asset_hash: 'blake3:abc123',
hash: 'blake3:abc123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],

View File

@@ -296,7 +296,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
name: 'my-image.jpeg',
asset_hash: 'hash123.jpeg'
hash: 'hash123.jpeg'
})
await actions.addWorkflow(asset)
@@ -310,12 +310,12 @@ describe('useMediaAssetActions', () => {
mockIsCloud.value = true
})
it('should use asset_hash as filename when available', async () => {
it('should use hash as filename when available', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'original.jpeg',
asset_hash: 'abc123hash.jpeg'
hash: 'abc123hash.jpeg'
})
await actions.addWorkflow(asset)
@@ -323,12 +323,12 @@ describe('useMediaAssetActions', () => {
expect(capturedFilenames.values).toContain('abc123hash.jpeg')
})
it('should fall back to asset.name when asset_hash is not available', async () => {
it('should fall back to asset.name when hash is not available', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'fallback-name.jpeg',
asset_hash: undefined
hash: undefined
})
await actions.addWorkflow(asset)
@@ -336,12 +336,12 @@ describe('useMediaAssetActions', () => {
expect(capturedFilenames.values).toContain('fallback-name.jpeg')
})
it('should fall back to asset.name when asset_hash is null', async () => {
it('should fall back to asset.name when hash is null', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'fallback-null.jpeg',
asset_hash: null
hash: null
})
await actions.addWorkflow(asset)
@@ -357,19 +357,19 @@ describe('useMediaAssetActions', () => {
mockIsCloud.value = true
})
it('should use asset_hash for each asset', async () => {
it('should use hash for each asset', async () => {
const actions = useMediaAssetActions()
const assets = [
createMockAsset({
id: '1',
name: 'file1.jpeg',
asset_hash: 'hash1.jpeg'
hash: 'hash1.jpeg'
}),
createMockAsset({
id: '2',
name: 'file2.jpeg',
asset_hash: 'hash2.jpeg'
hash: 'hash2.jpeg'
})
]
@@ -973,7 +973,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
id: 'asset-match',
name: 'foo.png',
asset_hash: 'abc123.png',
hash: 'abc123.png',
tags: ['input']
})
@@ -1051,7 +1051,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
id: 'asset-failed',
name: 'failed.png',
asset_hash: 'failhash.png'
hash: 'failhash.png'
})
await actions.deleteAssets(asset)

View File

@@ -6,6 +6,7 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
import { downloadFile } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
@@ -42,8 +43,8 @@ const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
*
* Output assets emit `<name> [output]` (and the subfolder-prefixed form when
* present in metadata). Input/temp assets emit the bare name plus the explicit
* annotation. `asset_hash` is included whenever present, since cloud-stored
* assets can be referenced by hash.
* annotation. The content `hash` is included whenever present, since
* cloud-stored assets can be referenced by hash.
*/
function widgetValueVariantsForAsset(asset: AssetItem): string[] {
const variants: string[] = []
@@ -61,7 +62,8 @@ function widgetValueVariantsForAsset(asset: AssetItem): string[] {
variants.push(`${name} [input]`)
}
}
if (asset.asset_hash) variants.push(asset.asset_hash)
const hash = asset.hash
if (hash) variants.push(hash)
return variants
}
@@ -279,9 +281,11 @@ export function useMediaAssetActions() {
return
}
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
if (!node) {
toast.add({
@@ -296,12 +300,10 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const assetType = getAssetType(targetAsset, 'input')
// In Cloud mode, use asset_hash (the actual stored filename)
// In OSS mode, use the original name
const filename =
isCloud && targetAsset.asset_hash
? targetAsset.asset_hash
: targetAsset.name
// In Cloud mode, use the content hash (the actual stored filename).
// In OSS mode, use the original name.
const cloudHash = targetAsset.hash
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
// Create annotated path for the asset
const annotated = createAnnotatedPath(
@@ -425,12 +427,14 @@ export function useMediaAssetActions() {
}
const center = litegraphService.getCanvasCenter()
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: [
center[0] + nodeIndex * NODE_OFFSET,
center[1] + nodeIndex * NODE_OFFSET
]
})
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: [
center[0] + nodeIndex * NODE_OFFSET,
center[1] + nodeIndex * NODE_OFFSET
]
})
)
if (!node) {
failed++
@@ -440,10 +444,10 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const assetType = getAssetType(asset, 'input')
// In Cloud mode, use asset_hash (the actual stored filename)
// In OSS mode, use the original name
const filename =
isCloud && asset.asset_hash ? asset.asset_hash : asset.name
// In Cloud mode, use the content hash (the actual stored filename).
// In OSS mode, use the original name.
const cloudHash = asset.hash
const filename = isCloud && cloudHash ? cloudHash : asset.name
const annotated = createAnnotatedPath(
{

View File

@@ -97,11 +97,12 @@ export function createMockAssets(count: number = 20): AssetItem[] {
const lastAccessTime = getRandomISODate()
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
const fakeAssetHash = generateFakeAssetHash()
return {
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
name: fakeFileName,
asset_hash: generateFakeAssetHash(),
hash: fakeAssetHash,
size: sizeInBytes,
mime_type: mimeType,
tags: [

View File

@@ -5,7 +5,7 @@ import { z } from 'zod'
const zAsset = z.object({
id: z.string(),
name: z.string(),
asset_hash: z.string().nullish(),
hash: z.string().nullish(),
size: z.number().optional(), // TBD: Will be provided by history API in the future
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),

View File

@@ -21,7 +21,7 @@ describe('assetMetadataUtils', () => {
const mockAsset: AssetItem = {
id: 'test-id',
name: 'test-model',
asset_hash: 'hash123',
hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],

View File

@@ -201,10 +201,10 @@ export function getAssetCardTitle(asset: AssetItem): string {
/**
* Returns the filename component the cloud `/api/view` endpoint resolves
* for this asset — `asset_hash` when present (cloud assets are hash-keyed
* for this asset — `hash` when present (cloud assets are hash-keyed
* in storage), otherwise `asset.name`. Use this when constructing widget
* values or media URLs that must round-trip through the view endpoint.
*/
export function getAssetUrlFilename(asset: AssetItem): string {
return asset.asset_hash || asset.name
return asset.hash ?? asset.name
}

View File

@@ -56,7 +56,7 @@ function mockFetchError() {
const cloudAsset = {
id: '72d169cc-7f9a-40d2-9382-35eadcba0a6a',
name: 'mesh/ComfyUI_00003_.glb',
asset_hash: 'c6cadcee57dd.glb',
hash: 'c6cadcee57dd.glb',
preview_id: null,
preview_url: undefined
}
@@ -110,9 +110,7 @@ describe('findOutputAsset', () => {
const result = await findOutputAsset('c6cadcee57dd.glb')
expect(mockFetchApi).toHaveBeenCalledOnce()
expect(mockFetchApi.mock.calls[0][0]).toContain(
'asset_hash=c6cadcee57dd.glb'
)
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=c6cadcee57dd.glb')
expect(result).toEqual(cloudAsset)
})
@@ -123,7 +121,7 @@ describe('findOutputAsset', () => {
const result = await findOutputAsset('ComfyUI_00081_.glb')
expect(mockFetchApi).toHaveBeenCalledTimes(2)
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_hash=')
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=')
expect(mockFetchApi.mock.calls[1][0]).toContain('name_contains=')
expect(result).toEqual(localAsset)
})

View File

@@ -5,7 +5,7 @@ import { useAssetsStore } from '@/stores/assetsStore'
interface AssetRecord {
id: string
name: string
asset_hash?: string
hash?: string
preview_url?: string
preview_id?: string | null
}
@@ -35,14 +35,14 @@ function resolvePreviewUrl(asset: AssetRecord): string {
/**
* Find an output asset record by content hash, falling back to name.
* On cloud, output filenames are content-hashed; use asset_hash to match.
* On cloud, output filenames are content-hashed; use hash to match.
* On local, filenames are not hashed; use name_contains to match.
*/
export async function findOutputAsset(
name: string
): Promise<AssetRecord | undefined> {
const byHash = await fetchAssets({ asset_hash: name })
const hashMatch = byHash.find((a) => a.asset_hash === name)
const byHash = await fetchAssets({ hash: name })
const hashMatch = byHash.find((a) => a.hash === name)
if (hashMatch) return hashMatch
const byName = await fetchAssets({ name_contains: name })

View File

@@ -15,7 +15,7 @@ import { collectAllNodes } from '@/utils/graphTraversalUtil'
*
* Comparison is full-string against the widget value as stored — callers must
* provide the canonical widget-value variants for each deleted asset (e.g.
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<asset_hash>`). This
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<hash>`). This
* avoids false matches when two distinct assets share a basename across
* input/output sources.
*

View File

@@ -124,5 +124,11 @@ const handleSubmit = async () => {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
color: var(--color-primary-comfy-canvas) !important;
caret-color: var(--color-primary-comfy-canvas);
}
:deep(.p-inputtext::placeholder) {
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
}
</style>

View File

@@ -1,97 +1,83 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl/normal font-medium">
<div
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
>
<div class="flex w-full max-w-md flex-col items-start">
<div class="flex w-full flex-col gap-4">
<h1
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
>
{{ t('auth.login.title') }}
</h1>
<i18n-t
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
keypath="auth.login.signUpFreeTierPromo"
tag="p"
class="my-0 text-base text-muted"
:plural="freeTierCredits ?? undefined"
<p
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
>
<template #signUp>
<span
class="cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</template>
<template #credits>{{ freeTierCredits }}</template>
</i18n-t>
<p v-else class="my-0 text-base text-muted">
{{ t('auth.login.newUser') }}
<span
class="cursor-pointer text-blue-500"
class="cursor-pointer text-azure-600"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<template v-if="!showEmailForm">
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
<template v-if="!showEmailForm">
<Button
v-if="!googleSsoBlockedReason"
type="button"
class="h-10 w-full"
variant="secondary"
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
@click="signInWithGoogle"
>
<i class="pi pi-google mr-2"></i>
<i class="pi pi-google text-base" />
{{ t('auth.login.loginWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-charcoal-500"
variant="secondary"
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
<i class="pi pi-github text-base" />
{{ t('auth.login.loginWithGithub') }}
</Button>
</div>
<div class="mt-6 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
variant="link"
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</div>
</template>
</template>
<template v-else>
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<template v-else>
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
<div class="mt-4 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
variant="secondary"
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-primary-comfy-canvas/10"
@click="switchToSocialLogin"
>
{{ t('auth.login.backToSocialLogin') }}
</Button>
</div>
</template>
</template>
</div>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
<p
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
>
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
@@ -99,7 +85,7 @@
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
@@ -117,7 +103,6 @@ import { useRoute, useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import type { SignInData } from '@/schemas/signInSchema'
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
@@ -129,7 +114,6 @@ const authActions = useAuthActions()
const isSecureContext = globalThis.isSecureContext
const authError = ref('')
const showEmailForm = ref(false)
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const { onAuthSuccess } = usePostAuthRedirect({
authError,

View File

@@ -1,92 +1,81 @@
<template>
<div class="flex h-full items-center justify-center p-8">
<div class="max-w-screen p-2 lg:w-96">
<!-- Header -->
<div class="mb-8 flex flex-col gap-4">
<h1 class="my-0 text-xl/normal font-medium">
<div
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
>
<div class="flex w-full max-w-md flex-col items-start">
<div class="flex w-full flex-col gap-4">
<h1
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
>
{{ t('auth.signup.title') }}
</h1>
<p class="my-0 text-base">
<span class="text-muted">{{
<p
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
>
<span class="text-primary-comfy-canvas/70">{{
t('auth.signup.alreadyHaveAccount')
}}</span>
<span
class="ml-1 cursor-pointer text-blue-500"
class="ml-1 cursor-pointer text-azure-600"
@click="navigateToLogin"
>{{ t('auth.signup.signIn') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<template v-if="!showEmailForm">
<p
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
class="mb-4 text-sm text-muted-foreground"
>
{{
freeTierCredits
? t('auth.login.freeTierDescription', {
credits: freeTierCredits
})
: t('auth.login.freeTierDescriptionGeneric')
}}
</p>
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<div v-if="!googleSsoBlockedReason" class="relative">
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
<i class="pi pi-google mr-2"></i>
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<span
v-if="isFreeTierEnabled"
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-gray-900"
>
{{ t('auth.login.freeTierBadge') }}
</span>
</div>
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
<template v-if="!showEmailForm">
<Button
v-if="!googleSsoBlockedReason"
type="button"
variant="secondary"
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
@click="signInWithGoogle"
>
<i class="pi pi-google text-base" />
{{ t('auth.signup.signUpWithGoogle') }}
</Button>
<Button
type="button"
class="h-10 bg-charcoal-500"
variant="secondary"
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
@click="signInWithGithub"
>
<i class="pi pi-github mr-2"></i>
<i class="pi pi-github text-base" />
{{ t('auth.signup.signUpWithGithub') }}
</Button>
</div>
<div class="mt-6 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
variant="link"
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</div>
</template>
</template>
<template v-else>
<Message v-if="isFreeTierEnabled" severity="warn" class="mb-4">
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
</Message>
<template v-else>
<Message v-if="isFreeTierEnabled" severity="warn" class="w-full">
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
</Message>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<Message v-if="userIsInChina" severity="warn" class="w-full">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm
v-else
:auth-error="authError"
@submit="signUpWithEmail"
/>
<div class="mt-4 text-center">
<Button
variant="muted-textonly"
class="text-sm underline"
variant="secondary"
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-sand-300/10"
@click="switchToSocialLogin"
>
{{
@@ -95,16 +84,17 @@
: t('auth.login.backToSocialLogin')
}}
</Button>
</div>
</template>
</template>
</div>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
<p
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
>
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
@@ -112,16 +102,18 @@
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
</p>
<p class="mt-2 text-sm text-gray-600">
<p
class="mx-auto mt-2 mb-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
>
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
class="cursor-pointer text-azure-600 no-underline"
target="_blank"
rel="noopener noreferrer"
>
@@ -159,7 +151,6 @@ const userIsInChina = ref(false)
const telemetry = useTelemetry()
const {
showEmailForm,
freeTierCredits,
isFreeTierEnabled,
switchToEmailForm,
switchToSocialLogin
@@ -197,7 +188,6 @@ const signUpWithEmail = async (values: SignUpData) => {
}
onMounted(async () => {
// Track signup screen opened
if (isCloud) {
telemetry?.trackSignupOpened()
}
@@ -210,12 +200,22 @@ onMounted(async () => {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
color: var(--color-primary-comfy-canvas) !important;
caret-color: var(--color-primary-comfy-canvas);
}
:deep(.p-inputtext::placeholder) {
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
}
:deep(.p-password input) {
border: none !important;
box-shadow: none !important;
}
:deep(.p-password-toggle-mask-icon) {
cursor: pointer;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -0,0 +1,106 @@
<template>
<div
class="relative flex size-full flex-col items-center overflow-hidden rounded-lg bg-primary-comfy-canvas/4 pt-10 pb-4"
>
<div
class="flex w-full flex-1 flex-col items-center justify-center gap-6 md:gap-8 lg:gap-10"
>
<div
class="relative aspect-5/4 w-full max-w-xs md:max-w-sm lg:max-w-md xl:max-w-lg"
>
<div class="absolute inset-0">
<div
class="absolute top-1/2 left-1/2 aspect-3/2 w-3/4 -translate-1/2"
>
<div
class="absolute inset-0 rounded-3xl border border-white/20 bg-white/10 p-3.5 shadow-2xl"
>
<div
class="size-full overflow-hidden rounded-xl bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: `url(${centerImage})` }"
/>
</div>
<div
class="absolute -top-1/20 -right-1/50 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
>
<i class="icon-[comfy--gemini-mono] size-3.5" />
</div>
<div
class="absolute -bottom-2/25 left-1/2 flex aspect-square w-10 -translate-x-1/2 items-center justify-center rounded-xl border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
>
<i class="icon-[comfy--grok] size-6" />
</div>
</div>
<div class="absolute top-3/20 left-1/20 aspect-5/3 w-7/20">
<div
class="absolute inset-0 overflow-hidden rounded-2xl border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
:style="{ backgroundImage: `url(${topLeft})` }"
/>
<div
class="absolute -top-1/10 right-1/10 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
>
<i class="icon-[comfy--bytedance-mono] size-3.5" />
</div>
</div>
<div
class="absolute bottom-3/20 left-1/10 aspect-4/3 w-1/4 overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat shadow-2xl"
:style="{ backgroundImage: `url(${bottomLeft})` }"
/>
<div
class="absolute right-2/25 bottom-1/10 aspect-4/3 w-3/10 overflow-hidden rounded-lg border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
:style="{ backgroundImage: `url(${bottomRight})` }"
/>
</div>
</div>
<div
class="relative flex w-full max-w-md flex-col items-center gap-1 text-center"
>
<p
class="m-0 font-inter text-base font-semibold text-primary-comfy-canvas"
>
{{ t('cloudHero.slides.cloud.title') }}
</p>
<p class="m-0 font-inter text-sm text-primary-comfy-canvas/70">
{{ t('cloudHero.slides.cloud.description') }}
</p>
</div>
</div>
<div
v-if="isCloud"
class="flex w-full items-center justify-center gap-3 pt-6"
>
<p class="m-0 hidden text-sm text-primary-comfy-canvas/90 md:block">
{{ t('cloudStart_wantToRun') }}
</p>
<a
href="https://www.comfy.org/download"
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-8 items-center gap-2 rounded-lg border border-primary-comfy-canvas/20 bg-charcoal-500 p-2 text-xs font-medium text-primary-comfy-canvas no-underline hover:bg-charcoal-500/80"
>
<i class="pi pi-download text-xs text-primary-comfy-canvas/90" />
{{ t('cloudStart_download') }}
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import bottomLeft from '@/platform/cloud/onboarding/assets/hero/bottom-left.jpg'
import bottomRight from '@/platform/cloud/onboarding/assets/hero/bottom-right.jpg'
import centerImage from '@/platform/cloud/onboarding/assets/hero/center-image.jpg'
import topLeft from '@/platform/cloud/onboarding/assets/hero/top-left.jpg'
import { isCloud } from '@/platform/distribution/types'
const { t } = useI18n()
</script>

View File

@@ -1,9 +0,0 @@
<template>
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
<img
src="/assets/images/comfy-cloud-logo.svg"
:alt="$t('subscription.comfyCloudLogo')"
class="h-3/4 max-h-10 w-auto"
/>
</div>
</template>

View File

@@ -67,7 +67,8 @@
<Button
v-else
type="submit"
class="mt-4 h-10 font-medium text-white"
variant="secondary"
class="relative mt-4 h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
:disabled="!$form.valid"
>
{{ t('auth.login.loginButton') }}
@@ -117,12 +118,22 @@ const onSubmit = (event: FormSubmitEvent) => {
border: none !important;
box-shadow: none !important;
background: #2d2e32 !important;
color: var(--color-primary-comfy-canvas) !important;
caret-color: var(--color-primary-comfy-canvas);
}
:deep(.p-inputtext::placeholder) {
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
}
:deep(.p-password input) {
border: none !important;
box-shadow: none !important;
}
:deep(.p-password-toggle-mask-icon) {
cursor: pointer;
}
:deep(.p-checkbox-checked .p-checkbox-box) {
background-color: #f0ff41 !important;
border-color: #f0ff41 !important;

View File

@@ -1,81 +1,27 @@
<template>
<div class="flex">
<BaseViewTemplate dark class="flex-1">
<template #header>
<CloudLogo />
</template>
<slot />
<template #footer>
<CloudTemplateFooter />
</template>
</BaseViewTemplate>
<div class="relative hidden flex-1 overflow-hidden bg-black lg:block">
<!-- Video Background -->
<video
class="absolute inset-0 size-full object-cover"
autoplay
muted
loop
playsinline
:poster="videoPoster"
>
<source :src="videoSrc" type="video/mp4" />
</video>
<div class="absolute inset-0 size-full bg-black/30"></div>
<!-- Optional Overlay for better visual -->
<div
class="absolute inset-0 flex items-center justify-center text-center text-white"
>
<div>
<h1 class="font-abcrom hero-title font-black uppercase italic">
{{ t('cloudStart_title') }}
</h1>
<p class="m-2 text-center text-xl text-white">
{{ t('cloudStart_desc') }}
</p>
<p class="m-0 text-center text-xl text-white">
{{ t('cloudStart_explain') }}
</p>
</div>
<div
class="flex h-svh w-screen bg-primary-comfy-ink font-sans text-primary-comfy-canvas"
>
<div class="relative flex flex-1 flex-col">
<div class="flex h-16 shrink-0 items-center px-6">
<i
class="icon-[comfy--comfy-logo] h-5 w-22 text-brand-yellow md:h-6 md:w-26"
/>
</div>
<div class="absolute inset-0 flex flex-col justify-end px-14 pb-[64px]">
<div class="flex items-center justify-end">
<div class="flex items-center gap-3">
<p class="text-md text-white">
{{ t('cloudStart_wantToRun') }}
</p>
<Button
type="button"
class="h-10 bg-black font-bold text-white"
variant="secondary"
@click="handleDownloadClick"
>
{{ t('cloudStart_download') }}
</Button>
</div>
</div>
<div class="flex flex-1 items-center justify-center overflow-auto">
<slot />
</div>
<CloudTemplateFooter />
</div>
<div class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block">
<CloudHeroCarousel />
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import videoPoster from '@/platform/cloud/onboarding/assets/videos/thumbnail.png'
import videoSrc from '@/platform/cloud/onboarding/assets/videos/video.mp4'
import CloudLogo from '@/platform/cloud/onboarding/components/CloudLogo.vue'
import CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const { t } = useI18n()
const handleDownloadClick = () => {
window.open('https://www.comfy.org/download', '_blank')
}
</script>
<style>
@import '../assets/css/fonts.css';

View File

@@ -1,5 +1,7 @@
<template>
<footer class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start gap-2.5">
<footer
class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start justify-center gap-2.5"
>
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"

View File

@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { oauthConsentRedirect } from '@/platform/cloud/onboarding/onboardingCloudRoutes'
import {
captureOAuthRequestId,
clearOAuthRequestId
} from '@/platform/cloud/oauth/oauthState'
const VALID_REQUEST_ID = '550e8400-e29b-41d4-a716-446655440000'
const createSessionOrThrow = vi.fn().mockResolvedValue(undefined)
vi.mock('@/platform/auth/session/useSessionCookie', () => ({
useSessionCookie: () => ({ createSessionOrThrow })
}))
describe('oauthConsentRedirect', () => {
beforeEach(() => {
clearOAuthRequestId()
createSessionOrThrow.mockReset().mockResolvedValue(undefined)
})
it('routes to user-check and mints no session when no OAuth flow is pending', async () => {
const target = await oauthConsentRedirect()
expect(target).toEqual({ name: 'cloud-user-check' })
expect(createSessionOrThrow).not.toHaveBeenCalled()
})
it('mints the Cloud session cookie before redirecting to consent when resuming OAuth', async () => {
// Regression: an already-signed-in user (Firebase) carries no Cloud session
// cookie, so the consent challenge fetch fails unless the cookie is minted
// here, mirroring the post-login resume path.
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
const target = await oauthConsentRedirect()
expect(createSessionOrThrow).toHaveBeenCalledOnce()
expect(target).toEqual({
name: 'cloud-oauth-consent',
query: { oauth_request_id: VALID_REQUEST_ID }
})
})
it('still lands on consent when session minting fails so the view can surface the error', async () => {
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
createSessionOrThrow.mockRejectedValue(new Error('Unauthorized'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const target = await oauthConsentRedirect()
expect(target).toEqual({
name: 'cloud-oauth-consent',
query: { oauth_request_id: VALID_REQUEST_ID }
})
expect(warn).toHaveBeenCalledWith(
'Failed to establish Cloud session cookie before OAuth consent:',
expect.any(Error)
)
} finally {
warn.mockRestore()
}
})
})

View File

@@ -5,14 +5,39 @@ import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
// `oauth_request_id` capture lives in the global router.beforeEach guard
// (src/router.ts), which runs before any per-route beforeEnter. Per-route
// guards read it back via getOAuthRequestId().
function oauthConsentRedirect() {
//
// When an already-signed-in user is bounced to login/signup mid-OAuth, we skip
// the sign-in step and jump straight to consent. The consent challenge fetch
// (GET /oauth/authorize) is authenticated by the Cloud *session cookie*, which
// is a separate credential from the Firebase client login that `isLoggedIn`
// reflects. The post-login resume path mints that cookie via
// `createSessionOrThrow` (see useOAuthPostLoginRedirect); the already-signed-in
// path must do the same. Without it the consent fetch is unauthenticated, the
// backend 302s it to login, and the consent view fails with
// "OAuth request failed. Please restart from the client app."
export async function oauthConsentRedirect() {
const oauthRequestId = getOAuthRequestId()
return oauthRequestId
? {
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
}
: { name: 'cloud-user-check' }
if (!oauthRequestId) return { name: 'cloud-user-check' }
try {
const { useSessionCookie } =
await import('@/platform/auth/session/useSessionCookie')
await useSessionCookie().createSessionOrThrow()
} catch (error) {
// Best effort: if the cookie can't be minted (e.g. an expired Firebase
// token), still land on the consent view so it can surface the failure and
// prompt the user to restart from the client app, rather than silently
// dropping the OAuth flow.
console.warn(
'Failed to establish Cloud session cookie before OAuth consent:',
error
)
}
return {
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
}
}
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
@@ -34,7 +59,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next(oauthConsentRedirect())
return next(await oauthConsentRedirect())
}
}
next()
@@ -52,7 +77,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next(oauthConsentRedirect())
return next(await oauthConsentRedirect())
}
}
next()

View File

@@ -50,7 +50,7 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
return {
id: name,
name,
asset_hash: assetHash,
hash: assetHash,
mime_type: null,
tags: ['input']
}

View File

@@ -85,7 +85,7 @@ export function getAssetDetectionNames(
): string[] {
const names = new Set<string>()
// Treat names and hashes as opaque match keys because Cloud may use either in widget values.
addPathDetectionNames(names, asset.asset_hash, options)
addPathDetectionNames(names, asset.hash, options)
addPathDetectionNames(names, asset.name, options)
const subfolder = asset.user_metadata?.subfolder

View File

@@ -115,7 +115,7 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
return {
id: name,
name,
asset_hash: assetHash,
hash: assetHash,
mime_type: null,
tags: ['input']
}
@@ -532,7 +532,7 @@ describe('verifyMediaCandidates', () => {
})
})
it('matches asset names when asset_hash is null', async () => {
it('matches asset names when hash is null', async () => {
const candidates = [
makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
makeCandidate('2', 'missing-photo.png', { isMissing: undefined })

View File

@@ -140,8 +140,8 @@ interface MediaVerificationOptions {
* Verify media candidates against assets available to the current runtime.
*
* A candidate's `name` may be either a filename or an opaque asset hash.
* Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
* match against the union of `asset.name` and `asset.asset_hash`. Output
* Cloud-side `hash` is not guaranteed to follow a single shape, so we
* match against the union of `asset.name` and `asset.hash`. Output
* candidates are matched against Cloud output assets or Core generated-history
* assets because Core resolves those annotations against output folders, not
* input files.

View File

@@ -1445,13 +1445,13 @@ describe('verifyAssetSupportedCandidates', () => {
{
id: '1',
name: 'my_model.safetensors',
asset_hash: null,
hash: null,
metadata: { filename: 'my_model.safetensors' }
},
{
id: '2',
name: 'other_model.safetensors',
asset_hash: null,
hash: null,
metadata: { filename: 'other_model.safetensors' }
}
])
@@ -1465,7 +1465,7 @@ describe('verifyAssetSupportedCandidates', () => {
)
})
it('should resolve isMissing=false when asset with matching asset_hash exists', async () => {
it('should resolve isMissing=false when asset with matching hash exists', async () => {
const candidates = [
makeAssetCandidate('model.safetensors', {
hash: 'abc123',
@@ -1473,7 +1473,7 @@ describe('verifyAssetSupportedCandidates', () => {
})
]
mockGetAssets.mockReturnValue([
{ id: '1', name: 'model.safetensors', asset_hash: 'sha256:abc123' }
{ id: '1', name: 'model.safetensors', hash: 'sha256:abc123' }
])
await verifyAssetSupportedCandidates(candidates)
@@ -1487,7 +1487,7 @@ describe('verifyAssetSupportedCandidates', () => {
{
id: '1',
name: 'my_model.safetensors',
asset_hash: null,
hash: null,
metadata: { filename: 'my_model.safetensors' }
}
])
@@ -1578,7 +1578,7 @@ describe('verifyAssetSupportedCandidates', () => {
{
id: '1',
name: 'checkpoint.safetensors',
asset_hash: null,
hash: null,
metadata: { filename: 'checkpoint.safetensors' }
}
]
@@ -1601,7 +1601,7 @@ describe('verifyAssetSupportedCandidates', () => {
{
id: '1',
name: 'model.safetensors',
asset_hash: null,
hash: null,
metadata: { filename: 'model.safetensors' }
}
])
@@ -1617,7 +1617,7 @@ describe('verifyAssetSupportedCandidates', () => {
{
id: '1',
name: 'my_model.safetensors',
asset_hash: null,
hash: null,
metadata: { filename: 'subfolder/my_model.safetensors' }
}
])

View File

@@ -501,7 +501,7 @@ function isAssetInstalled(
): boolean {
if (candidate.hash && candidate.hashType) {
const candidateHash = `${candidate.hashType}:${candidate.hash}`
if (assets.some((a) => a.asset_hash === candidateHash)) return true
if (assets.some((a) => a.hash === candidateHash)) return true
}
const normalizedName = normalizePath(candidate.name)

View File

@@ -1,6 +1,10 @@
import { api } from '@/scripts/api'
import { remoteConfig, remoteConfigState } from './remoteConfig'
import {
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
} from './remoteConfig'
interface RefreshRemoteConfigOptions {
/**
@@ -34,6 +38,10 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = config
remoteConfig.value = config
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
if (useAuth)
cachedTeamWorkspacesEnabled.value = Boolean(
config.team_workspaces_enabled
)
return
}

View File

@@ -1,3 +1,7 @@
import { useStorage } from '@vueuse/core'
import type { ServerFeatureFlag } from '@/composables/useFeatureFlags'
/**
* Remote configuration service
*
@@ -50,3 +54,8 @@ export function configValueOrDefault<K extends keyof RemoteConfig>(
const configValue = remoteConfig[key]
return configValue || defaultValue
}
export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
undefined
)

View File

@@ -12,6 +12,7 @@ import type {
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
@@ -198,6 +199,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
)
}
trackNodeAdded(metadata: NodeAddedMetadata): void {
this.dispatch((provider) => provider.trackNodeAdded?.(metadata))
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.dispatch((provider) => provider.trackTemplateFilterChanged?.(metadata))
}

View File

@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ChangeTracker } from '@/scripts/changeTracker'
import { installNodeAddedTelemetry } from './installNodeAddedTelemetry'
import { withNodeAddSource } from './nodeAddSource'
const trackNodeAdded = vi.fn()
vi.mock('..', () => ({
useTelemetry: () => ({ trackNodeAdded })
}))
function fakeGraph(): LGraph {
return { onNodeAdded: undefined } as unknown as LGraph
}
function fakeNode(type: string): LGraphNode {
return { type } as unknown as LGraphNode
}
describe('installNodeAddedTelemetry', () => {
beforeEach(() => {
trackNodeAdded.mockClear()
ChangeTracker.isLoadingGraph = false
})
afterEach(() => {
ChangeTracker.isLoadingGraph = false
})
it('fires trackNodeAdded with the current source on add', () => {
const graph = fakeGraph()
installNodeAddedTelemetry(graph)
withNodeAddSource('sidebar_drag', () => {
graph.onNodeAdded?.(fakeNode('KSampler'))
})
expect(trackNodeAdded).toHaveBeenCalledExactlyOnceWith({
node_type: 'KSampler',
source: 'sidebar_drag'
})
})
it('defaults source to "unknown" outside withNodeAddSource', () => {
const graph = fakeGraph()
installNodeAddedTelemetry(graph)
graph.onNodeAdded?.(fakeNode('CheckpointLoader'))
expect(trackNodeAdded).toHaveBeenCalledWith({
node_type: 'CheckpointLoader',
source: 'unknown'
})
})
it('skips telemetry during workflow load', () => {
const graph = fakeGraph()
installNodeAddedTelemetry(graph)
ChangeTracker.isLoadingGraph = true
graph.onNodeAdded?.(fakeNode('VAEDecode'))
expect(trackNodeAdded).not.toHaveBeenCalled()
})
it('preserves an existing onNodeAdded subscriber', () => {
const graph = fakeGraph()
const previous = vi.fn()
graph.onNodeAdded = previous
installNodeAddedTelemetry(graph)
const node = fakeNode('LoadImage')
graph.onNodeAdded?.(node)
expect(previous).toHaveBeenCalledExactlyOnceWith(node)
expect(trackNodeAdded).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,23 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { ChangeTracker } from '@/scripts/changeTracker'
import { useTelemetry } from '..'
import { getCurrentNodeAddSource } from './nodeAddSource'
/**
* Wire `app:node_added_to_workflow` telemetry into a graph. Wraps any existing
* `onNodeAdded` callback so we don't displace other subscribers. Bulk
* additions during workflow load are skipped — `workflow_imported`
* already covers those.
*/
export function installNodeAddedTelemetry(graph: LGraph): void {
const previous = graph.onNodeAdded
graph.onNodeAdded = function (node) {
previous?.call(this, node)
if (ChangeTracker.isLoadingGraph) return
useTelemetry()?.trackNodeAdded({
node_type: node.type ?? 'unknown',
source: getCurrentNodeAddSource()
})
}
}

View File

@@ -0,0 +1,22 @@
import type { NodeAddSource } from '../types'
let currentSource: NodeAddSource = 'unknown'
export function getCurrentNodeAddSource(): NodeAddSource {
return currentSource
}
/**
* Set the node-add source for the duration of `fn`. Synchronous only —
* the source is read by the synchronous LGraph.onNodeAdded callback that
* fires inside `graph.add()`. Nesting restores the previous value on exit.
*/
export function withNodeAddSource<T>(source: NodeAddSource, fn: () => T): T {
const previous = currentSource
currentSource = source
try {
return fn()
} finally {
currentSource = previous
}
}

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TelemetryEvents } from '../../types'
@@ -7,20 +7,30 @@ const hoisted = vi.hoisted(() => {
const mockInit = vi.fn()
const mockIdentify = vi.fn()
const mockPeopleSet = vi.fn()
const mockPeopleSetOnce = vi.fn()
const mockRegister = vi.fn()
const mockReset = vi.fn()
const mockOnUserResolved = vi.fn()
const mockOnUserLogout = vi.fn()
return {
mockCapture,
mockInit,
mockIdentify,
mockPeopleSet,
mockPeopleSetOnce,
mockRegister,
mockReset,
mockOnUserResolved,
mockOnUserLogout,
mockPosthog: {
default: {
init: mockInit,
capture: mockCapture,
identify: mockIdentify,
people: { set: mockPeopleSet }
register: mockRegister,
people: { set: mockPeopleSet, set_once: mockPeopleSetOnce },
reset: mockReset
}
}
}
@@ -36,7 +46,8 @@ vi.mock('vue', async () => {
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: hoisted.mockOnUserResolved
onUserResolved: hoisted.mockOnUserResolved,
onUserLogout: hoisted.mockOnUserLogout
})
}))
@@ -130,7 +141,7 @@ describe('PostHogTelemetryProvider', () => {
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
})
it('identifies user when onUserResolved fires', async () => {
it('identifies user without setting first_auth_at when onUserResolved fires', async () => {
createProvider()
await vi.dynamicImportSettled()
@@ -141,6 +152,99 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('desktop entry capture', () => {
function setLocation(search: string): void {
Object.defineProperty(window.location, 'search', {
configurable: true,
value: search,
writable: true
})
}
afterEach(() => {
setLocation('')
})
it('does not register desktop props when utm_source is absent', async () => {
setLocation('')
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).not.toHaveBeenCalled()
})
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
setLocation('?utm_source=google&desktop_device_id=should-be-ignored')
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).not.toHaveBeenCalled()
})
it('registers source_app and desktop_device_id when arriving from desktop', async () => {
setLocation(
'?utm_source=comfy.desktop&utm_medium=app_feature&desktop_device_id=device-abc'
)
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).toHaveBeenCalledWith({
source_app: 'desktop',
desktop_device_id: 'device-abc'
})
})
it('registers source_app alone when desktop_device_id is missing', async () => {
setLocation('?utm_source=comfy.desktop')
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockRegister).toHaveBeenCalledWith({
source_app: 'desktop'
})
})
it('persists desktop props to the person on identify so backend events inherit them', async () => {
setLocation('?utm_source=comfy.desktop&desktop_device_id=device-xyz')
createProvider()
await vi.dynamicImportSettled()
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
callback({ id: 'user-456' })
const setCall = hoisted.mockPeopleSet.mock.calls.find(
([props]) => props && 'desktop_device_id' in props
)
expect(setCall?.[0]).toEqual(
expect.objectContaining({
source_app: 'desktop',
desktop_device_id: 'device-xyz',
last_seen_via_desktop: expect.any(String)
})
)
expect(hoisted.mockPeopleSetOnce).toHaveBeenCalledWith(
expect.objectContaining({ first_seen_via_desktop: expect.any(String) })
)
})
it('does not touch the person profile on identify for non-desktop visitors', async () => {
setLocation('')
createProvider()
await vi.dynamicImportSettled()
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
callback({ id: 'user-789' })
const desktopSetCall = hoisted.mockPeopleSet.mock.calls.find(
([props]) =>
props &&
('desktop_device_id' in props || 'last_seen_via_desktop' in props)
)
expect(desktopSetCall).toBeUndefined()
expect(hoisted.mockPeopleSetOnce).not.toHaveBeenCalled()
})
})
describe('event tracking', () => {
it('captures events after initialization', async () => {
const provider = createProvider()
@@ -166,6 +270,88 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('sets first_auth_at on new-user auth', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({
method: 'google',
is_new_user: true,
user_id: 'user-123'
})
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
'user-123',
undefined,
expect.objectContaining({
first_auth_at: expect.any(String)
})
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: true,
user_id: 'user-123'
}
)
})
it('does not set first_auth_at on returning-user auth', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({
method: 'google',
is_new_user: false,
user_id: 'user-123'
})
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: false,
user_id: 'user-123'
}
)
})
it('flushes queued first_auth_at before queued auth event', async () => {
const provider = createProvider()
provider.trackAuth({
method: 'google',
is_new_user: true,
user_id: 'user-123'
})
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
expect(hoisted.mockCapture).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
'user-123',
undefined,
expect.objectContaining({
first_auth_at: expect.any(String)
})
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: true,
user_id: 'user-123'
}
)
expect(hoisted.mockIdentify.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.mockCapture.mock.invocationCallOrder[0]
)
})
it('queues events before initialization and flushes after', async () => {
const provider = createProvider()
@@ -236,6 +422,32 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('logout', () => {
it('registers onUserLogout watcher after init', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockOnUserLogout).toHaveBeenCalledOnce()
})
it('calls posthog.reset(true) when the watcher fires', async () => {
createProvider()
await vi.dynamicImportSettled()
const callback = hoisted.mockOnUserLogout.mock.calls[0][0]
callback()
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
})
it('does not register the watcher before init resolves', () => {
createProvider()
expect(hoisted.mockOnUserLogout).not.toHaveBeenCalled()
expect(hoisted.mockReset).not.toHaveBeenCalled()
})
})
describe('page view', () => {
it('captures page view with page_name property', async () => {
const provider = createProvider()
@@ -263,4 +475,72 @@ describe('PostHogTelemetryProvider', () => {
)
})
})
describe('before_send', () => {
it('strips PII keys from event properties, $set, and $set_once', async () => {
createProvider()
await vi.dynamicImportSettled()
const { before_send } = hoisted.mockInit.mock.calls[0][1]
const event = {
event: 'test',
properties: {
email: 'props@example.com',
prompt: 'hello',
user_email: 'props_user@example.com',
$email: 'props_posthog@example.com',
method: 'google'
},
$set: {
email: 'set@example.com',
user_email: 'set_user@example.com',
$email: 'set_posthog@example.com',
name: 'keep me'
},
$set_once: {
email: 'set_once@example.com',
plan: 'free'
}
}
const result = before_send(event)
// event.properties — all four PII keys stripped, non-PII preserved
expect(result.properties).not.toHaveProperty('email')
expect(result.properties).not.toHaveProperty('prompt')
expect(result.properties).not.toHaveProperty('user_email')
expect(result.properties).not.toHaveProperty('$email')
expect(result.properties).toHaveProperty('method', 'google')
// event.$set — PII stripped, non-PII preserved
// posthog.identify(id, { email }) lands here, not in properties
expect(result.$set).not.toHaveProperty('email')
expect(result.$set).not.toHaveProperty('user_email')
expect(result.$set).not.toHaveProperty('$email')
expect(result.$set).toHaveProperty('name', 'keep me')
// event.$set_once — PII stripped, non-PII preserved
expect(result.$set_once).not.toHaveProperty('email')
expect(result.$set_once).toHaveProperty('plan', 'free')
})
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
const remoteBefore_send = vi.fn()
mockRemoteConfig.value = {
posthog_config: {
before_send: remoteBefore_send,
person_profiles: 'always'
}
}
createProvider()
await vi.dynamicImportSettled()
const initConfig = hoisted.mockInit.mock.calls[0][1]
expect(initConfig.before_send).not.toBe(remoteBefore_send)
expect(initConfig.person_profiles).toBe('identified_only')
})
})
})

View File

@@ -1,6 +1,8 @@
import type { PostHog } from 'posthog-js'
import { watch } from 'vue'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -19,6 +21,7 @@ import type {
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
@@ -69,6 +72,20 @@ interface QueuedEvent {
properties?: TelemetryEventProperties
}
interface DesktopEntryProps {
source_app: 'desktop'
desktop_device_id?: string
}
function readDesktopEntryProps(): DesktopEntryProps | null {
const params = new URLSearchParams(window.location.search)
if (params.get('utm_source') !== 'comfy.desktop') return null
const props: DesktopEntryProps = { source_app: 'desktop' }
const deviceId = params.get('desktop_device_id')
if (deviceId) props.desktop_device_id = deviceId
return props
}
/**
* PostHog Telemetry Provider - Cloud Build Implementation
*
@@ -82,9 +99,11 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private isEnabled = true
private posthog: PostHog | null = null
private eventQueue: QueuedEvent[] = []
private pendingFirstAuthAt = new Map<string, string>()
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
private desktopEntryProps: DesktopEntryProps | null = null
constructor() {
this.configureDisabledEvents(
@@ -114,17 +133,38 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
...serverConfig
...serverConfig,
person_profiles: 'identified_only',
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
// automatically when persistence includes 'cookie' (the default).
// Explicit override interacts badly with posthog-js#3578 where reset() fails
// to clear localStorage on other subdomains, causing identity bleed on logout.
before_send: createPostHogBeforeSend()
})
this.isInitialized = true
this.flushEventQueue()
this.registerDesktopEntryProps()
useCurrentUser().onUserResolved((user) => {
const currentUser = useCurrentUser()
currentUser.onUserResolved((user) => {
if (this.posthog && user.id) {
this.posthog.identify(user.id)
this.setDesktopEntryPersonProperties()
this.setSubscriptionProperties()
}
})
// Anchored to session state rather than the logout button so it
// also covers token revocation, account deletion, and cross-tab
// sign-out (browserLocalPersistence). A logout that lands during
// the posthog-js dynamic-import window will not be observed here:
// events buffered pre-init are intentionally NOT queue-cleared on
// logout, which leaves a narrow race where a logout + different
// login both inside the import window would flush pre-init events
// under the new identity. Accepted as a known edge — re-adding
// pre-init logout handling would defeat the simplification.
currentUser.onUserLogout(() => {
this.posthog?.reset(true)
})
})
.catch((error) => {
console.error('Failed to load PostHog:', error)
@@ -143,6 +183,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private flushEventQueue(): void {
if (!this.isInitialized || !this.posthog) return
this.flushPendingFirstAuthAt()
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift()!
try {
@@ -153,6 +195,33 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
}
private flushPendingFirstAuthAt(): void {
for (const [userId, firstAuthAt] of this.pendingFirstAuthAt) {
this.setFirstAuthAt(userId, firstAuthAt)
}
this.pendingFirstAuthAt.clear()
}
private setFirstAuthAt(
userId: string,
firstAuthAt = new Date().toISOString()
): void {
if (!this.isEnabled) return
if (this.isInitialized && this.posthog) {
try {
this.posthog.identify(userId, undefined, { first_auth_at: firstAuthAt })
} catch (error) {
console.error('Failed to set PostHog first auth timestamp:', error)
}
return
}
if (!this.pendingFirstAuthAt.has(userId)) {
this.pendingFirstAuthAt.set(userId, firstAuthAt)
}
}
private trackEvent(
eventName: TelemetryEventName,
properties?: TelemetryEventProperties
@@ -215,6 +284,34 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
)
}
private registerDesktopEntryProps(): void {
if (!this.posthog) return
const props = readDesktopEntryProps()
if (!props) return
this.desktopEntryProps = props
try {
this.posthog.register(props)
} catch (error) {
console.error('Failed to register desktop entry props:', error)
}
}
// Persisted onto the person so backend-fired billing events inherit
// desktop_device_id via person-on-events at ingest.
private setDesktopEntryPersonProperties(): void {
if (!this.posthog || !this.desktopEntryProps) return
const now = new Date().toISOString()
try {
this.posthog.people.set({
...this.desktopEntryProps,
last_seen_via_desktop: now
})
this.posthog.people.set_once({ first_seen_via_desktop: now })
} catch (error) {
console.error('Failed to set desktop entry person properties:', error)
}
}
private setSubscriptionProperties(): void {
const { subscriptionTier } = useSubscription()
watch(
@@ -233,6 +330,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
trackAuth(metadata: AuthMetadata): void {
if (metadata.is_new_user && metadata.user_id) {
this.setFirstAuthAt(metadata.user_id)
}
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
@@ -403,6 +503,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackNodeAdded(metadata: NodeAddedMetadata): void {
this.trackEvent(TelemetryEvents.NODE_ADDED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}

View File

@@ -139,9 +139,23 @@ export interface CreditTopupMetadata {
/**
* Workflow import metadata
*/
export interface MissingNodePack {
/**
* Custom node pack identifier (cnrId / aux_id from node properties).
* `'unknown'` when the workflow JSON has no pack hint for the node.
*/
pack_id: string
node_types: string[]
}
export interface WorkflowImportMetadata {
missing_node_count: number
missing_node_types: string[]
/**
* Missing nodes grouped by their custom node pack. Populated from the
* `cnr_id` / `aux_id` baked into node properties — no network lookups.
*/
missing_node_packs?: MissingNodePack[]
/**
* The source of the workflow open/import action
*/
@@ -232,6 +246,23 @@ export interface NodeSearchMetadata {
query: string
}
/**
* Node added metadata. `source` indicates how the user initiated the add.
* Bulk additions during workflow load are excluded — workflow_imported
* already covers that.
*/
export type NodeAddSource =
| 'sidebar_drag'
| 'search_modal'
| 'paste'
| 'programmatic'
| 'unknown'
export interface NodeAddedMetadata {
node_type: string
source: NodeAddSource
}
/**
* Node search result selection metadata
*/
@@ -325,6 +356,7 @@ export interface CheckoutAttributionMetadata {
ga_session_id?: string
ga_session_number?: string
im_ref?: string
rewardful_referral?: string
utm_source?: string
utm_medium?: string
utm_campaign?: string
@@ -437,6 +469,9 @@ export interface TelemetryProvider {
trackNodeSearch?(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
// Node-added-to-canvas analytics
trackNodeAdded?(metadata: NodeAddedMetadata): void
// Template filter tracking events
trackTemplateFilterChanged?(metadata: TemplateFilterMetadata): void
@@ -523,6 +558,7 @@ export const TelemetryEvents = {
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
NODE_ADDED: 'app:node_added_to_workflow',
// Template Filter Analytics
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
captureCheckoutAttributionFromSearch,
@@ -15,9 +15,15 @@ describe('getCheckoutAttribution', () => {
}
window.gtag = undefined
window.ire = undefined
window.rewardful = undefined
window.Rewardful = undefined
window.history.pushState({}, '', '/')
})
afterEach(() => {
vi.useRealTimers()
})
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
window.__CONFIG__ = {
...window.__CONFIG__,
@@ -228,4 +234,80 @@ describe('getCheckoutAttribution', () => {
expect(attribution.im_ref).toBeUndefined()
})
it('captures Rewardful referral from window.Rewardful', async () => {
window.Rewardful = {
referral: 'rwd-abc-123'
}
const attribution = await getCheckoutAttribution()
expect(attribution.rewardful_referral).toBe('rwd-abc-123')
})
it('returns undefined Rewardful referral when window.Rewardful is absent', async () => {
const attribution = await getCheckoutAttribution()
expect(attribution.rewardful_referral).toBeUndefined()
})
it('waits for Rewardful ready before reading the referral', async () => {
let readyCallback: (() => void) | undefined
window.rewardful = vi.fn((_method: 'ready', callback: () => void) => {
readyCallback = callback
}) as Window['rewardful']
const attributionPromise = getCheckoutAttribution()
await Promise.resolve()
expect(window.rewardful).toHaveBeenCalledWith('ready', expect.any(Function))
window.Rewardful = {
referral: 'rwd-ready-123'
}
readyCallback?.()
const attribution = await attributionPromise
expect(attribution.rewardful_referral).toBe('rwd-ready-123')
})
it('continues checkout attribution when Rewardful ready never runs', async () => {
vi.useFakeTimers()
window.rewardful = vi.fn() as Window['rewardful']
const attributionPromise = getCheckoutAttribution()
await vi.advanceTimersByTimeAsync(300)
const attribution = await attributionPromise
expect(window.rewardful).toHaveBeenCalledWith('ready', expect.any(Function))
expect(attribution.rewardful_referral).toBeUndefined()
})
it('returns undefined Rewardful referral when window.Rewardful.referral is empty', async () => {
window.Rewardful = { referral: '' }
const attribution = await getCheckoutAttribution()
expect(attribution.rewardful_referral).toBeUndefined()
})
it('captures Rewardful referral alongside Impact attribution', async () => {
window.history.pushState(
{},
'',
'/?im_ref=impact-url-id&utm_source=affiliate'
)
window.Rewardful = {
referral: 'rwd-xyz-789'
}
const attribution = await getCheckoutAttribution()
expect(attribution).toMatchObject({
im_ref: 'impact-url-id',
utm_source: 'affiliate',
rewardful_referral: 'rwd-xyz-789'
})
})
})

View File

@@ -31,6 +31,7 @@ type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
const GET_GA_IDENTITY_TIMEOUT_MS = 300
const GET_REWARDFUL_REFERRAL_TIMEOUT_MS = 300
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
if (typeof window === 'undefined') return {}
@@ -180,6 +181,30 @@ async function getGeneratedClickId(): Promise<string | undefined> {
}
}
async function getRewardfulReferral(): Promise<string | undefined> {
if (typeof window === 'undefined') return undefined
const referral = asNonEmptyString(window.Rewardful?.referral)
if (referral) return referral
const rewardful = window.rewardful
if (typeof rewardful !== 'function') return undefined
return withTimeout(
() =>
new Promise<string | undefined>((resolve, reject) => {
try {
rewardful('ready', () => {
resolve(asNonEmptyString(window.Rewardful?.referral))
})
} catch (error) {
reject(error)
}
}),
GET_REWARDFUL_REFERRAL_TIMEOUT_MS
).catch(() => undefined)
}
export function captureCheckoutAttributionFromSearch(search: string): void {
const fromUrl = readAttributionFromUrl(search)
const storedAttribution = readStoredAttribution()
@@ -198,6 +223,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
const storedAttribution = readStoredAttribution()
const fromUrl = readAttributionFromUrl(window.location.search)
const rewardfulReferralPromise = getRewardfulReferral()
const generatedClickId = await getGeneratedClickId()
const attribution: Partial<Record<AttributionQueryKey, string>> = {
...storedAttribution,
@@ -212,12 +238,16 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
persistAttribution(attribution)
}
const gaIdentity = await getGaIdentity()
const [gaIdentity, rewardfulReferral] = await Promise.all([
getGaIdentity(),
rewardfulReferralPromise
])
return {
...attribution,
ga_client_id: gaIdentity?.client_id,
ga_session_id: gaIdentity?.session_id,
ga_session_number: gaIdentity?.session_number
ga_session_number: gaIdentity?.session_number,
rewardful_referral: rewardfulReferral
}
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
import { groupMissingNodesByPack } from './groupMissingNodesByPack'
describe('groupMissingNodesByPack', () => {
it('returns an empty array when no missing nodes', () => {
expect(groupMissingNodesByPack([])).toEqual([])
})
it('groups multiple node types under the same pack', () => {
const missing: MissingNodeType[] = [
{ type: 'ImpactSampler', cnrId: 'impact-pack' },
{ type: 'ImpactDetailer', cnrId: 'impact-pack' },
{ type: 'WASImageBlend', cnrId: 'was-node-suite' }
]
const result = groupMissingNodesByPack(missing)
expect(result).toHaveLength(2)
expect(result).toEqual(
expect.arrayContaining([
{
pack_id: 'impact-pack',
node_types: ['ImpactSampler', 'ImpactDetailer']
},
{ pack_id: 'was-node-suite', node_types: ['WASImageBlend'] }
])
)
})
it('deduplicates the same node type within a pack', () => {
const missing: MissingNodeType[] = [
{ type: 'ImpactSampler', cnrId: 'impact-pack' },
{ type: 'ImpactSampler', cnrId: 'impact-pack' }
]
expect(groupMissingNodesByPack(missing)).toEqual([
{ pack_id: 'impact-pack', node_types: ['ImpactSampler'] }
])
})
it('falls back to "unknown" for nodes without a cnrId', () => {
const missing: MissingNodeType[] = [
{ type: 'MysteryNode' },
'LegacyStringNode'
]
expect(groupMissingNodesByPack(missing)).toEqual([
{ pack_id: 'unknown', node_types: ['MysteryNode', 'LegacyStringNode'] }
])
})
it('keeps "unknown" separate from identified packs', () => {
const missing: MissingNodeType[] = [
{ type: 'ImpactSampler', cnrId: 'impact-pack' },
{ type: 'MysteryNode' }
]
const result = groupMissingNodesByPack(missing)
expect(result).toHaveLength(2)
expect(result).toEqual(
expect.arrayContaining([
{ pack_id: 'impact-pack', node_types: ['ImpactSampler'] },
{ pack_id: 'unknown', node_types: ['MysteryNode'] }
])
)
})
})

View File

@@ -0,0 +1,29 @@
import type { MissingNodeType } from '@/types/comfy'
import type { MissingNodePack } from '../types'
const UNKNOWN_PACK_ID = 'unknown'
export function groupMissingNodesByPack(
missingNodes: readonly MissingNodeType[]
): MissingNodePack[] {
const byPack = new Map<string, Set<string>>()
for (const node of missingNodes) {
const type = typeof node === 'string' ? node : node.type
const packId =
typeof node === 'string' ? UNKNOWN_PACK_ID : node.cnrId || UNKNOWN_PACK_ID
const existing = byPack.get(packId)
if (existing) {
existing.add(type)
} else {
byPack.set(packId, new Set([type]))
}
}
return Array.from(byPack, ([pack_id, types]) => ({
pack_id,
node_types: Array.from(types)
}))
}

View File

@@ -13,7 +13,7 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'test-asset-id',
name: 'test-image.png',
asset_hash: 'hash123',
hash: 'hash123',
size: 1024,
mime_type: 'image/png',
tags: ['input'],
@@ -539,7 +539,7 @@ describe('useComboWidget', () => {
createMockAssetItem({
id: 'asset-123',
name: 'image1.png',
asset_hash: HASH_FILENAME
hash: HASH_FILENAME
})
]
mockAssetsStoreState.inputLoading = false

View File

@@ -183,7 +183,7 @@ const createInputMappingWidget = (
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.map((asset) => asset.hash)
.filter((hash): hash is string => !!hash)
)

View File

@@ -684,15 +684,14 @@ describe('useWidgetSelectItems', () => {
it('does not expand a hash-keyed asset even if its metadata reports outputCount > 1', async () => {
// Defense against future cloud-schema changes: if a flat output row
// ever ships with both asset_hash AND multi-output user_metadata, the
// ever ships with both hash AND multi-output user_metadata, the
// watcher must NOT replace it with synthesized AssetItems lacking the
// hash, or select+load reverts to the FE-227 broken state.
mockMediaAssets.media.value = [
{
id: 'asset-flat-1',
name: 'z-image-turbo_00093_.png',
asset_hash:
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
tags: ['output'],
user_metadata: {
jobId: 'job-future',
@@ -729,13 +728,12 @@ describe('useWidgetSelectItems', () => {
)
})
it('uses asset_hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
it('uses hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
mockMediaAssets.media.value = [
{
id: 'asset-out-1',
name: 'z-image-turbo_00093_.png',
asset_hash:
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
preview_url: '/api/view?filename=039b...0b13.png',
tags: ['output']
}
@@ -753,7 +751,7 @@ describe('useWidgetSelectItems', () => {
expect(dropdownItems.value).toHaveLength(1)
// The value (item.name) — what becomes modelValue on click — must be the
// hash-keyed path so /api/view resolves it. Cloud's hash is in
// asset_hash, not asset.name (which is the human filename).
// asset.hash, not asset.name (which is the human filename).
expect(dropdownItems.value[0].name).toBe(
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png [output]'
)
@@ -761,7 +759,7 @@ describe('useWidgetSelectItems', () => {
expect(dropdownItems.value[0].label).toContain('z-image-turbo_00093_.png')
})
it('falls back to asset.name when asset_hash is absent (local/history path)', async () => {
it('falls back to asset.name when hash is absent (local/history path)', async () => {
mockMediaAssets.media.value = [
{
id: 'local-1',
@@ -973,8 +971,7 @@ describe('useWidgetSelectItems', () => {
{
id: 'asset-hash-1',
name: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5.png',
asset_hash:
'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
hash: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
preview_url: '/preview.png',
tags: ['output'],
metadata: {

View File

@@ -131,8 +131,8 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
// Hash-keyed assets are leaf rows from the cloud `/assets` API and
// already carry their own URL-resolvable filename. Expanding them via
// resolveOutputAssetItems would synthesize sibling AssetItems without
// an asset_hash and reintroduce the FE-227 hash→name fallback bug.
if (asset.asset_hash) continue
// a hash and reintroduce the FE-227 hash→name fallback bug.
if (asset.hash) continue
const meta = getOutputAssetMetadata(asset.user_metadata)
if (!meta) continue

View File

@@ -25,6 +25,8 @@ import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { installNodeAddedTelemetry } from '@/platform/telemetry/nodeAdded/installNodeAddedTelemetry'
import { groupMissingNodesByPack } from '@/platform/telemetry/utils/groupMissingNodesByPack'
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
@@ -888,6 +890,7 @@ export class ComfyApp {
this.addAfterConfigureHandler(graph)
this.rootGraphInternal = graph
installNodeAddedTelemetry(graph)
this.canvas = new LGraphCanvas(canvasEl, graph)
// Make canvas states reactive so we can observe changes on them.
this.canvas.state = reactive(this.canvas.state)
@@ -1425,6 +1428,7 @@ export class ComfyApp {
missing_node_types: missingNodeTypes.map((node) =>
typeof node === 'string' ? node : node.type
),
missing_node_packs: groupMissingNodesByPack(missingNodeTypes),
open_source: openSource ?? 'unknown'
}
useTelemetry()?.trackWorkflowOpened(telemetryPayload)

View File

@@ -1463,7 +1463,7 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
{
id: 'input-1',
name: 'cute-puppy.png',
asset_hash: 'abc123def.png',
hash: 'abc123def.png',
tags: ['input']
}
])
@@ -1509,14 +1509,10 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
describe('assetsStore - Flat Output Assets (cloud-only)', () => {
const FLAT_OUTPUT_PAGE_SIZE = 200
const makeAsset = (
id: string,
name: string,
asset_hash?: string
): AssetItem => ({
const makeAsset = (id: string, name: string, hash?: string): AssetItem => ({
id,
name,
asset_hash,
hash,
size: 0,
tags: ['output']
})

View File

@@ -347,13 +347,14 @@ export const useAssetsStore = defineStore('assets', () => {
/**
* Map of asset hash filename to asset item for O(1) lookup
* Cloud assets use asset_hash for the hash-based filename
* Cloud assets use hash for the hash-based filename
*/
const inputAssetsByFilename = computed(() => {
const map = new Map<string, AssetItem>()
for (const asset of inputAssets.value) {
if (asset.asset_hash) {
map.set(asset.asset_hash, asset)
const hash = asset.hash
if (hash) {
map.set(hash, asset)
}
}
return map

View File

@@ -4,6 +4,7 @@ import type {
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
/**
* Serialises an array of nodes using a modified version of the old Litegraph copy (& paste) function
@@ -106,7 +107,7 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void {
node.pos[1] += graph_mouse[1] - topLeft[1]
// @ts-expect-error fixme ts strict error
graph.add(node, true)
withNodeAddSource('paste', () => graph.add(node, true))
nodes.push(node)
}