Compare commits

..

15 Commits

Author SHA1 Message Date
Comfy Org PR Bot
2d9b1fed64 [backport core/1.45] fix(assets): dedupe outputs by composite key to prevent media asset panel scroll-duplication (#12639)
Backport of #11716 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-06-04 11:30:51 +09:00
Comfy Org PR Bot
deb4045f18 1.45.15 (#12608)
Patch version increment to 1.45.15

**Base branch:** `core/1.45`

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
2026-06-02 14:41:53 -07:00
Terry Jia
0b3927d8d5 [backport core/1.45] feat: add PreviewGaussianSplat + PreviewPointCloud extensions (#12596)
## Summary
Backport https://github.com/Comfy-Org/ComfyUI_frontend/pull/12545 to
core/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:44:06 -07:00
Comfy Org PR Bot
955472dab5 [backport core/1.45] fix: dedupe Bypass context-menu items via state-aware legacy label (FE-720) (#12586)
Backport of #12500 to `core/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:03 +09:00
Comfy Org PR Bot
4ad242181b [backport core/1.45] Track undo state on subgraph conversion (#12584)
Backport of #12575 to `core/1.45`

Automatically created by backport workflow.

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

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-01 18:57:12 -07:00
Comfy Org PR Bot
1a8bf498ef [backport core/1.45] fix: preserve validation errors on execution start (#12547)
Backport of #12493 to `core/1.45`

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

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

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 12:00:30 -07:00
Comfy Org PR Bot
c5c916f80e [backport core/1.45] Fix mask editor sometimes showing wrong image (#12483)
Backport of #12413 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 00:52:46 -07:00
Comfy Org PR Bot
badc97b982 [backport core/1.45] fix(widgets): collapse duplicate COLOR widget rendering on Color to RGB Int (FE-842) (#12453)
Backport of #12447 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: Dante <bunggl@naver.com>
2026-05-25 22:12:03 -07:00
Comfy Org PR Bot
67affd2075 [backport core/1.45] Fix missing value control on 'Primitive Int' (#12461)
Backport of #12431 to `core/1.45`

Automatically created by backport workflow.

Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-25 21:23:57 -07:00
82 changed files with 366 additions and 1579 deletions

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
import type { Asset } from '@comfyorg/ingest-types'
function createModelAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-model-001',
name: 'model.safetensors',
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
asset_hash:
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
size: 2_147_483_648,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
@@ -17,13 +16,12 @@ function createModelAsset(
}
}
function createInputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-input-001',
name: 'input.png',
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
asset_hash:
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
size: 2_048_576,
mime_type: 'image/png',
tags: ['input'],
@@ -34,13 +32,12 @@ function createInputAsset(
}
}
function createOutputAsset(
overrides: Partial<Asset> = {}
): Asset & { hash?: string } {
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'test-output-001',
name: 'output_00001.png',
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
asset_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 & { hash?: string } = {
const defaultInputAsset: Asset = {
id: 'default-input-asset',
name: defaultInputFileName,
hash: defaultInputFileName,
asset_hash: defaultInputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset & { hash?: string } = {
last_access_time: '2026-05-01T00:00:00Z'
}
const importedInputAsset: Asset & { hash?: string } = {
const importedInputAsset: Asset = {
id: 'imported-input-asset',
name: sharedWorkflowImportScenario.inputFileName,
hash: sharedWorkflowImportScenario.inputFileName,
asset_hash: sharedWorkflowImportScenario.inputFileName,
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

View File

@@ -12,10 +12,11 @@ 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 & { hash?: string } = {
const LOTUS_DIFFUSION_MODEL: Asset = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
asset_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 & { hash?: string } = {
const cloudOutputAsset: Asset = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
hash: outputHash,
asset_hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
@@ -36,10 +36,10 @@ const cloudOutputAsset: Asset & { hash?: string } = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
const cloudUploadedVideoAsset: Asset = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
hash: plainVideoFileName,
asset_hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
@@ -50,10 +50,10 @@ const cloudUploadedVideoAsset: Asset & { hash?: string } = {
// 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 & { hash?: string } = {
const cloudDefaultGraphInputAsset: Asset = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
hash: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],

14
global.d.ts vendored
View File

@@ -11,18 +11,6 @@ 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 {
@@ -75,8 +63,6 @@ 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,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}]");
@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}]");
/* 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,7 +25,6 @@
@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);
@@ -66,9 +65,6 @@
--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

@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File

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

View File

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

View File

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

View File

@@ -66,7 +66,6 @@ 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'
@@ -131,12 +130,10 @@ const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
const node = withNodeAddSource('search_modal', () =>
litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
const node = litegraphService.addNodeOnGraph(
nodeDef,
{ pos: getNewNodeLocation() },
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
)
if (!node) return

View File

@@ -65,7 +65,6 @@ 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'
@@ -156,8 +155,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
const graphNode = useLitegraphService().addNodeOnGraph(
provider.nodeDef
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key

View File

@@ -189,7 +189,6 @@ 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,
@@ -322,11 +321,8 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
}
},
handleClick(e: MouseEvent) {
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
} else {
toggleNodeOnEvent(e, this)
}

View File

@@ -39,7 +39,6 @@ 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'
@@ -184,11 +183,8 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
await nodeBookmarkStore.addBookmark(nodePath)
},
handleClick(e: MouseEvent) {
const nodeDef = this.data
if (this.leaf && nodeDef) {
withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef)
)
if (this.leaf && this.data) {
useLitegraphService().addNodeOnGraph(this.data)
} else {
toggleNodeOnEvent(e, node)
}

View File

@@ -1,4 +1,3 @@
import { FirebaseError } from 'firebase/app'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -27,20 +26,9 @@ 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,
st: (key: string, fallback: string) => {
const code = key.replace('auth.errors.', '')
return knownAuthErrorCodes.has(code) ? key : fallback
}
values?.workflow ? `${key}:${values.workflow}` : key
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -84,7 +72,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
toastErrorHandler: mockToastErrorHandler
toastErrorHandler: vi.fn()
})
}))
@@ -205,46 +193,3 @@ 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 { st, t } from '@/i18n'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -47,12 +47,6 @@ 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,6 +1,5 @@
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'
@@ -38,8 +37,7 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
const nodeDef = draggedNode.value
if (!nodeDef) return false
if (!draggedNode.value) return false
const canvas = useCanvasStore().canvas
if (!canvas) return false
if (!isOverCanvas(clientX, clientY)) return false
@@ -48,9 +46,7 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
clientX,
clientY
} as PointerEvent)
const node = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
if (node) canvas.selectItems([node])
return true
}

View File

@@ -8,7 +8,6 @@ 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'
@@ -147,11 +146,9 @@ export function useJobMenu(
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
if (!node) return

View File

@@ -4,7 +4,6 @@ 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'
@@ -38,9 +37,7 @@ 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
withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(nodeDef, { pos })
)
litegraphService.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = basePos
@@ -61,8 +58,11 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
targetGraphNode = litegraphService.addNodeOnGraph(
provider.nodeDef,
{
pos
}
)
targetProvider = provider
}

View File

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

View File

@@ -2278,8 +2278,7 @@
"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.",
"generic": "Something went wrong while signing you in. Please try again."
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
},
"deleteAccount": {
"contactSupport": "To delete your account, please contact {email}"
@@ -2950,29 +2949,6 @@
"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,
hash: `blake3:${id.padEnd(64, '0')}`,
asset_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 {
const base = {
return {
id: 'asset-1',
name: HASH,
hash: HASH,
asset_hash: HASH,
tags: ['input'],
preview_url: '/preview.png',
secondaryText: '',
@@ -62,7 +62,6 @@ function createDisplayAsset(
metadata: { filename: ORIGINAL_FILENAME },
...overrides
}
return base
}
function renderCard(asset: AssetDisplayItem) {
@@ -98,7 +97,7 @@ describe('AssetCard', () => {
})
describe('FE-228: filename rendering', () => {
it('renders the human-readable filename instead of hash when asset.name equals hash', () => {
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
const asset = createDisplayAsset()
renderCard(asset)
@@ -131,7 +130,7 @@ describe('AssetCard', () => {
const asset = createDisplayAsset({
id: 'model-1',
name: MODEL_FILENAME,
hash: undefined,
asset_hash: undefined,
tags: ['models', 'loras'],
user_metadata: { name: CURATED_NAME },
metadata: { filename: MODEL_FILENAME }
@@ -147,7 +146,7 @@ describe('AssetCard', () => {
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
const asset = createDisplayAsset({
name: HASH,
hash: HASH,
asset_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',
hash: null,
asset_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',
hash: null,
asset_hash: null,
mime_type: mimeType,
tags: [],
kind: 'video',

View File

@@ -28,7 +28,7 @@ describe('ModelInfoPanel', () => {
): AssetDisplayItem => ({
id: 'test-id',
name: 'test-model.safetensors',
hash: 'hash123',
asset_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`,
hash: `blake3:${index}`,
asset_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',
hash: 'blake3:abc123',
asset_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',
hash: 'hash123.jpeg'
asset_hash: 'hash123.jpeg'
})
await actions.addWorkflow(asset)
@@ -310,12 +310,12 @@ describe('useMediaAssetActions', () => {
mockIsCloud.value = true
})
it('should use hash as filename when available', async () => {
it('should use asset_hash as filename when available', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'original.jpeg',
hash: 'abc123hash.jpeg'
asset_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 hash is not available', async () => {
it('should fall back to asset.name when asset_hash is not available', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'fallback-name.jpeg',
hash: undefined
asset_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 hash is null', async () => {
it('should fall back to asset.name when asset_hash is null', async () => {
const actions = useMediaAssetActions()
const asset = createMockAsset({
name: 'fallback-null.jpeg',
hash: null
asset_hash: null
})
await actions.addWorkflow(asset)
@@ -357,19 +357,19 @@ describe('useMediaAssetActions', () => {
mockIsCloud.value = true
})
it('should use hash for each asset', async () => {
it('should use asset_hash for each asset', async () => {
const actions = useMediaAssetActions()
const assets = [
createMockAsset({
id: '1',
name: 'file1.jpeg',
hash: 'hash1.jpeg'
asset_hash: 'hash1.jpeg'
}),
createMockAsset({
id: '2',
name: 'file2.jpeg',
hash: 'hash2.jpeg'
asset_hash: 'hash2.jpeg'
})
]
@@ -973,7 +973,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
id: 'asset-match',
name: 'foo.png',
hash: 'abc123.png',
asset_hash: 'abc123.png',
tags: ['input']
})
@@ -1051,7 +1051,7 @@ describe('useMediaAssetActions', () => {
const asset = createMockAsset({
id: 'asset-failed',
name: 'failed.png',
hash: 'failhash.png'
asset_hash: 'failhash.png'
})
await actions.deleteAssets(asset)

View File

@@ -6,7 +6,6 @@ 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'
@@ -43,8 +42,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. The content `hash` is included whenever present, since
* cloud-stored assets can be referenced by hash.
* annotation. `asset_hash` is included whenever present, since cloud-stored
* assets can be referenced by hash.
*/
function widgetValueVariantsForAsset(asset: AssetItem): string[] {
const variants: string[] = []
@@ -62,8 +61,7 @@ function widgetValueVariantsForAsset(asset: AssetItem): string[] {
variants.push(`${name} [input]`)
}
}
const hash = asset.hash
if (hash) variants.push(hash)
if (asset.asset_hash) variants.push(asset.asset_hash)
return variants
}
@@ -281,11 +279,9 @@ export function useMediaAssetActions() {
return
}
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
)
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: litegraphService.getCanvasCenter()
})
if (!node) {
toast.add({
@@ -300,10 +296,12 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const assetType = getAssetType(targetAsset, 'input')
// 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
// 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
// Create annotated path for the asset
const annotated = createAnnotatedPath(
@@ -427,14 +425,12 @@ export function useMediaAssetActions() {
}
const center = litegraphService.getCanvasCenter()
const node = withNodeAddSource('programmatic', () =>
litegraphService.addNodeOnGraph(nodeDef, {
pos: [
center[0] + nodeIndex * NODE_OFFSET,
center[1] + nodeIndex * NODE_OFFSET
]
})
)
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: [
center[0] + nodeIndex * NODE_OFFSET,
center[1] + nodeIndex * NODE_OFFSET
]
})
if (!node) {
failed++
@@ -444,10 +440,10 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const assetType = getAssetType(asset, 'input')
// 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
// 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
const annotated = createAnnotatedPath(
{

View File

@@ -97,12 +97,11 @@ 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,
hash: fakeAssetHash,
asset_hash: generateFakeAssetHash(),
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(),
hash: z.string().nullish(),
asset_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',
hash: 'hash123',
asset_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 — `hash` when present (cloud assets are hash-keyed
* for this asset — `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.hash ?? asset.name
return asset.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',
hash: 'c6cadcee57dd.glb',
asset_hash: 'c6cadcee57dd.glb',
preview_id: null,
preview_url: undefined
}
@@ -110,7 +110,9 @@ describe('findOutputAsset', () => {
const result = await findOutputAsset('c6cadcee57dd.glb')
expect(mockFetchApi).toHaveBeenCalledOnce()
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=c6cadcee57dd.glb')
expect(mockFetchApi.mock.calls[0][0]).toContain(
'asset_hash=c6cadcee57dd.glb'
)
expect(result).toEqual(cloudAsset)
})
@@ -121,7 +123,7 @@ describe('findOutputAsset', () => {
const result = await findOutputAsset('ComfyUI_00081_.glb')
expect(mockFetchApi).toHaveBeenCalledTimes(2)
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=')
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_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
hash?: string
asset_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 hash to match.
* On cloud, output filenames are content-hashed; use asset_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({ hash: name })
const hashMatch = byHash.find((a) => a.hash === name)
const byHash = await fetchAssets({ asset_hash: name })
const hashMatch = byHash.find((a) => a.asset_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]`, `<hash>`). This
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<asset_hash>`). This
* avoids false matches when two distinct assets share a basename across
* input/output sources.
*

View File

@@ -124,11 +124,5 @@ 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,83 +1,97 @@
<template>
<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"
>
<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">
{{ t('auth.login.title') }}
</h1>
<p
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
<i18n-t
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
keypath="auth.login.signUpFreeTierPromo"
tag="p"
class="my-0 text-base text-muted"
:plural="freeTierCredits ?? undefined"
>
<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-azure-600"
class="cursor-pointer text-blue-500"
@click="navigateToSignup"
>{{ t('auth.login.signUp') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
<template v-if="!showEmailForm">
<template v-if="!showEmailForm">
<!-- OAuth Buttons (primary) -->
<div class="flex flex-col gap-4">
<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"
class="h-10 w-full"
@click="signInWithGoogle"
>
<i class="pi pi-google text-base" />
<i class="pi pi-google mr-2"></i>
{{ 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 text-base" />
<i class="pi pi-github mr-2"></i>
{{ t('auth.login.loginWithGithub') }}
</Button>
</div>
<div class="mt-6 text-center">
<Button
variant="link"
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
variant="muted-textonly"
class="text-sm underline"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</template>
</div>
</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="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"
variant="muted-textonly"
class="text-sm underline"
@click="switchToSocialLogin"
>
{{ t('auth.login.backToSocialLogin') }}
</Button>
</template>
</div>
</div>
</template>
<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"
>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-azure-600 no-underline"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
@@ -85,7 +99,7 @@
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-azure-600 no-underline"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
@@ -103,6 +117,7 @@ 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'
@@ -114,6 +129,7 @@ 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,81 +1,92 @@
<template>
<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"
>
<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">
{{ t('auth.signup.title') }}
</h1>
<p
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
>
<span class="text-primary-comfy-canvas/70">{{
<p class="my-0 text-base">
<span class="text-muted">{{
t('auth.signup.alreadyHaveAccount')
}}</span>
<span
class="ml-1 cursor-pointer text-azure-600"
class="ml-1 cursor-pointer text-blue-500"
@click="navigateToLogin"
>{{ t('auth.signup.signIn') }}</span
>
</p>
</div>
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
{{ t('auth.login.insecureContextWarning') }}
</Message>
<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>
<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>
<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 text-base" />
<i class="pi pi-github mr-2"></i>
{{ t('auth.signup.signUpWithGithub') }}
</Button>
</div>
<div class="mt-6 text-center">
<Button
variant="link"
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
variant="muted-textonly"
class="text-sm underline"
@click="switchToEmailForm"
>
{{ t('auth.login.useEmailInstead') }}
</Button>
</template>
</div>
</template>
<template v-else>
<Message v-if="isFreeTierEnabled" severity="warn" class="w-full">
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
</Message>
<template v-else>
<Message v-if="isFreeTierEnabled" severity="warn" class="mb-4">
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
</Message>
<Message v-if="userIsInChina" severity="warn" class="w-full">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm
v-else
:auth-error="authError"
@submit="signUpWithEmail"
/>
<Message v-if="userIsInChina" severity="warn" class="mb-4">
{{ t('auth.signup.regionRestrictionChina') }}
</Message>
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
<div class="mt-4 text-center">
<Button
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"
variant="muted-textonly"
class="text-sm underline"
@click="switchToSocialLogin"
>
{{
@@ -84,17 +95,16 @@
: t('auth.login.backToSocialLogin')
}}
</Button>
</template>
</div>
</div>
</template>
<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"
>
<!-- Terms & Contact -->
<p class="mt-5 text-sm text-gray-600">
{{ t('auth.login.termsText') }}
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"
class="cursor-pointer text-azure-600 no-underline"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.termsLink') }}
</a>
@@ -102,18 +112,16 @@
<a
href="https://www.comfy.org/privacy-policy"
target="_blank"
class="cursor-pointer text-azure-600 no-underline"
class="cursor-pointer text-blue-400 no-underline"
>
{{ t('auth.login.privacyLink') }} </a
>.
</p>
<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"
>
<p class="mt-2 text-sm text-gray-600">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
class="cursor-pointer text-azure-600 no-underline"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
>
@@ -151,6 +159,7 @@ const userIsInChina = ref(false)
const telemetry = useTelemetry()
const {
showEmailForm,
freeTierCredits,
isFreeTierEnabled,
switchToEmailForm,
switchToSocialLogin
@@ -188,6 +197,7 @@ const signUpWithEmail = async (values: SignUpData) => {
}
onMounted(async () => {
// Track signup screen opened
if (isCloud) {
telemetry?.trackSignupOpened()
}
@@ -200,22 +210,12 @@ 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.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -1,106 +0,0 @@
<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

@@ -0,0 +1,9 @@
<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,8 +67,7 @@
<Button
v-else
type="submit"
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"
class="mt-4 h-10 font-medium text-white"
:disabled="!$form.valid"
>
{{ t('auth.login.loginButton') }}
@@ -118,22 +117,12 @@ 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,27 +1,81 @@
<template>
<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 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>
<div class="flex flex-1 items-center justify-center overflow-auto">
<slot />
<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>
<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 CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
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 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,7 +1,5 @@
<template>
<footer
class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start justify-center gap-2.5"
>
<footer class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start gap-2.5">
<a
href="https://www.comfy.org/terms-of-service"
target="_blank"

View File

@@ -1,65 +0,0 @@
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,39 +5,14 @@ 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().
//
// 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() {
function oauthConsentRedirect() {
const oauthRequestId = getOAuthRequestId()
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 }
}
return oauthRequestId
? {
name: 'cloud-oauth-consent',
query: { oauth_request_id: oauthRequestId }
}
: { name: 'cloud-user-check' }
}
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
@@ -59,7 +34,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next(await oauthConsentRedirect())
return next(oauthConsentRedirect())
}
}
next()
@@ -77,7 +52,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next(await oauthConsentRedirect())
return next(oauthConsentRedirect())
}
}
next()

View File

@@ -50,7 +50,7 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
return {
id: name,
name,
hash: assetHash,
asset_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.hash, options)
addPathDetectionNames(names, asset.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,
hash: assetHash,
asset_hash: assetHash,
mime_type: null,
tags: ['input']
}
@@ -532,7 +532,7 @@ describe('verifyMediaCandidates', () => {
})
})
it('matches asset names when hash is null', async () => {
it('matches asset names when asset_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 `hash` is not guaranteed to follow a single shape, so we
* match against the union of `asset.name` and `asset.hash`. Output
* 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
* 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',
hash: null,
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
},
{
id: '2',
name: 'other_model.safetensors',
hash: null,
asset_hash: null,
metadata: { filename: 'other_model.safetensors' }
}
])
@@ -1465,7 +1465,7 @@ describe('verifyAssetSupportedCandidates', () => {
)
})
it('should resolve isMissing=false when asset with matching hash exists', async () => {
it('should resolve isMissing=false when asset with matching asset_hash exists', async () => {
const candidates = [
makeAssetCandidate('model.safetensors', {
hash: 'abc123',
@@ -1473,7 +1473,7 @@ describe('verifyAssetSupportedCandidates', () => {
})
]
mockGetAssets.mockReturnValue([
{ id: '1', name: 'model.safetensors', hash: 'sha256:abc123' }
{ id: '1', name: 'model.safetensors', asset_hash: 'sha256:abc123' }
])
await verifyAssetSupportedCandidates(candidates)
@@ -1487,7 +1487,7 @@ describe('verifyAssetSupportedCandidates', () => {
{
id: '1',
name: 'my_model.safetensors',
hash: null,
asset_hash: null,
metadata: { filename: 'my_model.safetensors' }
}
])
@@ -1578,7 +1578,7 @@ describe('verifyAssetSupportedCandidates', () => {
{
id: '1',
name: 'checkpoint.safetensors',
hash: null,
asset_hash: null,
metadata: { filename: 'checkpoint.safetensors' }
}
]
@@ -1601,7 +1601,7 @@ describe('verifyAssetSupportedCandidates', () => {
{
id: '1',
name: 'model.safetensors',
hash: null,
asset_hash: null,
metadata: { filename: 'model.safetensors' }
}
])
@@ -1617,7 +1617,7 @@ describe('verifyAssetSupportedCandidates', () => {
{
id: '1',
name: 'my_model.safetensors',
hash: null,
asset_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.hash === candidateHash)) return true
if (assets.some((a) => a.asset_hash === candidateHash)) return true
}
const normalizedName = normalizePath(candidate.name)

View File

@@ -1,10 +1,6 @@
import { api } from '@/scripts/api'
import {
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
} from './remoteConfig'
import { remoteConfig, remoteConfigState } from './remoteConfig'
interface RefreshRemoteConfigOptions {
/**
@@ -38,10 +34,6 @@ 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,7 +1,3 @@
import { useStorage } from '@vueuse/core'
import type { ServerFeatureFlag } from '@/composables/useFeatureFlags'
/**
* Remote configuration service
*
@@ -54,8 +50,3 @@ 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,7 +12,6 @@ import type {
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
@@ -199,10 +198,6 @@ 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

@@ -1,81 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,22 +0,0 @@
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TelemetryEvents } from '../../types'
@@ -7,30 +7,20 @@ 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,
register: mockRegister,
people: { set: mockPeopleSet, set_once: mockPeopleSetOnce },
reset: mockReset
people: { set: mockPeopleSet }
}
}
}
@@ -46,8 +36,7 @@ vi.mock('vue', async () => {
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: hoisted.mockOnUserResolved,
onUserLogout: hoisted.mockOnUserLogout
onUserResolved: hoisted.mockOnUserResolved
})
}))
@@ -141,7 +130,7 @@ describe('PostHogTelemetryProvider', () => {
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
})
it('identifies user without setting first_auth_at when onUserResolved fires', async () => {
it('identifies user when onUserResolved fires', async () => {
createProvider()
await vi.dynamicImportSettled()
@@ -152,99 +141,6 @@ 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()
@@ -270,88 +166,6 @@ 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()
@@ -422,32 +236,6 @@ 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()
@@ -475,72 +263,4 @@ 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,8 +1,6 @@
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'
@@ -21,7 +19,6 @@ import type {
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageViewMetadata,
@@ -72,20 +69,6 @@ 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
*
@@ -99,11 +82,9 @@ 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(
@@ -133,38 +114,17 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
...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()
...serverConfig
})
this.isInitialized = true
this.flushEventQueue()
this.registerDesktopEntryProps()
const currentUser = useCurrentUser()
currentUser.onUserResolved((user) => {
useCurrentUser().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)
@@ -183,8 +143,6 @@ 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 {
@@ -195,33 +153,6 @@ 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
@@ -284,34 +215,6 @@ 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(
@@ -330,9 +233,6 @@ 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)
}
@@ -503,10 +403,6 @@ 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,23 +139,9 @@ 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
*/
@@ -246,23 +232,6 @@ 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
*/
@@ -356,7 +325,6 @@ 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
@@ -469,9 +437,6 @@ 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
@@ -558,7 +523,6 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
captureCheckoutAttributionFromSearch,
@@ -15,15 +15,9 @@ 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__,
@@ -234,80 +228,4 @@ 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,7 +31,6 @@ 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 {}
@@ -181,30 +180,6 @@ 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()
@@ -223,7 +198,6 @@ 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,
@@ -238,16 +212,12 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
persistAttribution(attribution)
}
const [gaIdentity, rewardfulReferral] = await Promise.all([
getGaIdentity(),
rewardfulReferralPromise
])
const gaIdentity = await getGaIdentity()
return {
...attribution,
ga_client_id: gaIdentity?.client_id,
ga_session_id: gaIdentity?.session_id,
ga_session_number: gaIdentity?.session_number,
rewardful_referral: rewardfulReferral
ga_session_number: gaIdentity?.session_number
}
}

View File

@@ -1,71 +0,0 @@
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

@@ -1,29 +0,0 @@
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',
hash: 'hash123',
asset_hash: 'hash123',
size: 1024,
mime_type: 'image/png',
tags: ['input'],
@@ -539,7 +539,7 @@ describe('useComboWidget', () => {
createMockAssetItem({
id: 'asset-123',
name: 'image1.png',
hash: HASH_FILENAME
asset_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.hash)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
)

View File

@@ -684,14 +684,15 @@ 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 hash AND multi-output user_metadata, the
// ever ships with both asset_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',
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
asset_hash:
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
tags: ['output'],
user_metadata: {
jobId: 'job-future',
@@ -728,12 +729,13 @@ describe('useWidgetSelectItems', () => {
)
})
it('uses hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
it('uses asset_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',
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
asset_hash:
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
preview_url: '/api/view?filename=039b...0b13.png',
tags: ['output']
}
@@ -751,7 +753,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]'
)
@@ -759,7 +761,7 @@ describe('useWidgetSelectItems', () => {
expect(dropdownItems.value[0].label).toContain('z-image-turbo_00093_.png')
})
it('falls back to asset.name when hash is absent (local/history path)', async () => {
it('falls back to asset.name when asset_hash is absent (local/history path)', async () => {
mockMediaAssets.media.value = [
{
id: 'local-1',
@@ -971,7 +973,8 @@ describe('useWidgetSelectItems', () => {
{
id: 'asset-hash-1',
name: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5.png',
hash: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
asset_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
// a hash and reintroduce the FE-227 hash→name fallback bug.
if (asset.hash) continue
// an asset_hash and reintroduce the FE-227 hash→name fallback bug.
if (asset.asset_hash) continue
const meta = getOutputAssetMetadata(asset.user_metadata)
if (!meta) continue

View File

@@ -25,8 +25,6 @@ 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'
@@ -890,7 +888,6 @@ 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)
@@ -1428,7 +1425,6 @@ 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',
hash: 'abc123def.png',
asset_hash: 'abc123def.png',
tags: ['input']
}
])
@@ -1509,10 +1509,14 @@ 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, hash?: string): AssetItem => ({
const makeAsset = (
id: string,
name: string,
asset_hash?: string
): AssetItem => ({
id,
name,
hash,
asset_hash,
size: 0,
tags: ['output']
})

View File

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

View File

@@ -4,7 +4,6 @@ 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
@@ -107,7 +106,7 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void {
node.pos[1] += graph_mouse[1] - topLeft[1]
// @ts-expect-error fixme ts strict error
withNodeAddSource('paste', () => graph.add(node, true))
graph.add(node, true)
nodes.push(node)
}