Compare commits
27 Commits
core/1.45
...
cloud/v1.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
383760e728 | ||
|
|
af8f0b60f5 | ||
|
|
434a1b1af1 | ||
|
|
bd6e5e2286 | ||
|
|
6a78e0b635 | ||
|
|
b751750f0b | ||
|
|
2fa47fa260 | ||
|
|
505728cc56 | ||
|
|
880af41f34 | ||
|
|
085bef657b | ||
|
|
f849e9be77 | ||
|
|
bd48bf1bbe | ||
|
|
75fb11785a | ||
|
|
5ff0b33295 | ||
|
|
e89778fc89 | ||
|
|
6f3ef2ed70 | ||
|
|
cd216d4db8 | ||
|
|
bccfc41f5d | ||
|
|
2bf1eb6e19 | ||
|
|
d2ad47634a | ||
|
|
daeae316b1 | ||
|
|
20cf8074a9 | ||
|
|
c87cb03024 | ||
|
|
28c4080134 | ||
|
|
1a6e77e955 | ||
|
|
e06d7a7b34 | ||
|
|
c76b7280af |
55
apps/website/src/scripts/posthog.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockInit: vi.fn(),
|
||||
mockCapture: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: hoisted.mockInit,
|
||||
capture: hoisted.mockCapture
|
||||
}
|
||||
}))
|
||||
|
||||
describe('initPostHog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
|
||||
const { initPostHog } = await import('./posthog')
|
||||
initPostHog()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledOnce()
|
||||
const initOptions = hoisted.mockInit.mock.calls[0][1]
|
||||
expect(initOptions.person_profiles).toBe('identified_only')
|
||||
expect(typeof initOptions.before_send).toBe('function')
|
||||
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: { email: 'd@example.com', name: 'keep me' },
|
||||
$set_once: { $email: 'e@example.com', plan: 'free' }
|
||||
}
|
||||
|
||||
const result = initOptions.before_send(event)
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
expect(result.$set_once).not.toHaveProperty('$email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -18,7 +20,9 @@ export function initPostHog() {
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only'
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createModelAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-model-001',
|
||||
name: 'model.safetensors',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
@@ -16,12 +17,13 @@ function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createInputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-input-001',
|
||||
name: 'input.png',
|
||||
asset_hash:
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
hash: 'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -32,12 +34,13 @@ function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
function createOutputAsset(
|
||||
overrides: Partial<Asset> = {}
|
||||
): Asset & { hash?: string } {
|
||||
return {
|
||||
id: 'test-output-001',
|
||||
name: 'output_00001.png',
|
||||
asset_hash:
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
hash: 'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
|
||||
@@ -43,10 +43,10 @@ const sharedWorkflowAsset: AssetInfo = {
|
||||
in_library: false
|
||||
}
|
||||
|
||||
const defaultInputAsset: Asset = {
|
||||
const defaultInputAsset: Asset & { hash?: string } = {
|
||||
id: 'default-input-asset',
|
||||
name: defaultInputFileName,
|
||||
asset_hash: defaultInputFileName,
|
||||
hash: defaultInputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -55,10 +55,10 @@ const defaultInputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const importedInputAsset: Asset = {
|
||||
const importedInputAsset: Asset & { hash?: string } = {
|
||||
id: 'imported-input-asset',
|
||||
name: sharedWorkflowImportScenario.inputFileName,
|
||||
asset_hash: sharedWorkflowImportScenario.inputFileName,
|
||||
hash: sharedWorkflowImportScenario.inputFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
@@ -12,11 +12,10 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset = {
|
||||
const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
name: LOTUS_MODEL_NAME,
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'diffusion_models'],
|
||||
|
||||
@@ -24,10 +24,10 @@ const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
|
||||
const cloudOutputAsset: Asset = {
|
||||
const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-output-hash-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
asset_hash: outputHash,
|
||||
hash: outputHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
@@ -36,10 +36,10 @@ const cloudOutputAsset: Asset = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset = {
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
asset_hash: plainVideoFileName,
|
||||
hash: plainVideoFileName,
|
||||
size: 1_024,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
@@ -50,10 +50,10 @@ const cloudUploadedVideoAsset: Asset = {
|
||||
|
||||
// The Cloud test app starts with a default LoadImage node. Keep that baseline
|
||||
// input resolvable so this spec only observes the media it creates.
|
||||
const cloudDefaultGraphInputAsset: Asset = {
|
||||
const cloudDefaultGraphInputAsset: Asset & { hash?: string } = {
|
||||
id: 'test-default-input-001',
|
||||
name: '00000000000000000000000Aexample.png',
|
||||
asset_hash: '00000000000000000000000Aexample.png',
|
||||
hash: '00000000000000000000000Aexample.png',
|
||||
size: 1_024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
|
||||
14
global.d.ts
vendored
@@ -11,6 +11,18 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
interface RewardfulGlobal {
|
||||
referral?: string
|
||||
affiliate?: { id?: string; token?: string; name?: string }
|
||||
campaign?: { id?: string; name?: string }
|
||||
}
|
||||
|
||||
interface RewardfulQueueFunction {
|
||||
(method: 'ready', callback: () => void): void
|
||||
(...args: unknown[]): void
|
||||
q?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
@@ -63,6 +75,8 @@ interface Window {
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
rewardful?: RewardfulQueueFunction
|
||||
Rewardful?: RewardfulGlobal
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,bytedance-mono,comfy-logo,credits,elevenlabs,extensions-blocks,file-output,gemini,gemini-mono,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
@theme {
|
||||
--shadow-interface: var(--interface-panel-box-shadow);
|
||||
--shadow-inset-highlight: inset 0 1px 0 0 rgb(from white r g b / 0.1);
|
||||
|
||||
--text-2xs: 0.625rem;
|
||||
--text-2xs--line-height: calc(1 / 0.625);
|
||||
@@ -65,6 +66,9 @@
|
||||
--color-ocean-600: #2f687a;
|
||||
--color-ocean-900: #253236;
|
||||
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-canvas: #c2bfb9;
|
||||
|
||||
--color-danger-100: #c02323;
|
||||
--color-danger-200: #d62952;
|
||||
|
||||
|
||||
6
packages/design-system/src/icons/bytedance-mono.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M324.094 389.858L284.667 379.567V191.5L326.871 180.816C350.01 174.941 369.446 170.154 370.371 170.339C371.112 170.339 371.667 222.027 371.667 285.326V400.334L367.594 400.15C365.189 400.15 345.566 395.361 324.094 389.835V389.857V389.858Z"/>
|
||||
<path d="M138.667 343.325C138.667 279.602 139.229 227.339 140.166 227.339C140.914 227.154 160.573 231.998 184.164 237.913L226.667 248.65L226.292 342.975L225.73 437.278L187.535 447.107C166.565 452.463 146.906 457.47 144.097 458.029L138.667 459.334V343.325Z"/>
|
||||
<path d="M423.667 248.299C423.667 38.7081 423.853 27.4506 427.037 28.3797C428.722 28.9368 445.386 33.1843 463.921 37.8029C482.458 42.6075 500.807 47.2031 504.739 48.1312L511.667 49.9884L511.293 248.67L510.731 447.539L472.722 457.148C451.939 462.486 432.279 467.291 429.284 468.057L423.667 469.334V248.299Z"/>
|
||||
<path d="M-0.333038 248.845C-0.333038 140.208 0.222275 51.334 1.14852 51.334C1.88822 51.334 21.3242 56.1412 44.4631 61.8769L86.667 72.583V248.66C86.667 345.267 86.296 424.55 85.9262 424.55C85.3709 424.55 65.7494 429.544 42.4262 435.466L-0.333038 446.334V248.823V248.844V248.845Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
packages/design-system/src/icons/comfy-logo.svg
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
6
packages/design-system/src/icons/gemini-mono.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.853 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
<path d="M439.808 232.147C404.368 217.06 372.144 195.328 344.875 168.125C306.899 130.074 279.806 82.5466 266.411 30.4827C265.823 28.1703 264.481 26.1197 262.598 24.6551C260.714 23.1905 258.397 22.3953 256.011 22.3953C253.625 22.3953 251.307 23.1905 249.423 24.6551C247.54 26.1197 246.198 28.1703 245.611 30.4827C232.187 82.5399 205.09 130.062 167.125 168.125C139.854 195.325 107.63 217.056 72.192 232.147C58.3253 238.12 44.0747 242.92 29.4827 246.611C27.1561 247.182 25.0884 248.518 23.6102 250.403C22.132 252.288 21.3287 254.615 21.3287 257.011C21.3287 259.406 22.132 261.733 23.6102 263.618C25.0884 265.504 27.1561 266.839 29.4827 267.411C44.0747 271.08 58.2827 275.88 72.192 281.853C107.632 296.94 139.856 318.672 167.125 345.875C205.111 383.93 232.212 431.465 245.611 483.539C246.182 485.865 247.518 487.933 249.403 489.411C251.288 490.889 253.615 491.693 256.011 491.693C258.406 491.693 260.733 490.889 262.618 489.411C264.504 487.933 265.839 485.865 266.411 483.539C270.08 468.925 274.88 454.717 280.853 440.808C295.939 405.368 317.671 373.143 344.875 345.875C382.934 307.897 430.468 280.804 482.539 267.411C484.851 266.823 486.902 265.481 488.366 263.598C489.831 261.714 490.626 259.397 490.626 257.011C490.626 254.625 489.831 252.307 488.366 250.423C486.902 248.54 484.851 247.198 482.539 246.611C467.932 242.936 453.643 238.099 439.808 232.147Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -7,7 +7,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
"./networkUtil": "./src/networkUtil.ts",
|
||||
"./piiUtil": "./src/piiUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
58
packages/shared-frontend-utils/src/piiUtil.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createPostHogBeforeSend } from './piiUtil'
|
||||
|
||||
describe('createPostHogBeforeSend', () => {
|
||||
const beforeSend = createPostHogBeforeSend()
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(beforeSend(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('strips all PII keys from properties, $set, and $set_once', () => {
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'd@example.com',
|
||||
user_email: 'e@example.com',
|
||||
$email: 'f@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'g@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = beforeSend(event)!
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('handles missing property bags gracefully', () => {
|
||||
const event = { properties: { email: 'a@example.com', safe: true } }
|
||||
const result = beforeSend(event)!
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).toHaveProperty('safe', true)
|
||||
expect(result.$set).toBeUndefined()
|
||||
expect(result.$set_once).toBeUndefined()
|
||||
})
|
||||
})
|
||||
35
packages/shared-frontend-utils/src/piiUtil.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
|
||||
|
||||
function stripPiiKeys(obj?: Record<string, unknown>): void {
|
||||
if (!obj) return
|
||||
for (const key of PII_KEYS) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog before_send hook that strips PII from all three property bags
|
||||
* an event can carry: properties, $set, and $set_once.
|
||||
*
|
||||
* posthog.identify(id, { email }) lands in $set, not properties, so all
|
||||
* three bags must be sanitized.
|
||||
*
|
||||
* Ref: posthog.com/tutorials/web-redact-properties
|
||||
*/
|
||||
interface PostHogEventLike {
|
||||
properties?: Record<string, unknown>
|
||||
$set?: Record<string, unknown>
|
||||
$set_once?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createPostHogBeforeSend() {
|
||||
return function beforeSend<E extends PostHogEventLike>(
|
||||
event: E | null
|
||||
): E | null {
|
||||
if (!event) return null
|
||||
stripPiiKeys(event.properties)
|
||||
stripPiiKeys(event.$set)
|
||||
stripPiiKeys(event.$set_once)
|
||||
return event
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -18,10 +18,32 @@
|
||||
v-model="fov"
|
||||
:tooltip-text="$t('load3d.fov')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.retainViewOnReload'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.retainViewOnReload')"
|
||||
:aria-pressed="retainViewOnReload"
|
||||
@click="retainViewOnReload = !retainViewOnReload"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi text-lg text-base-foreground',
|
||||
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
@@ -30,6 +52,9 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
|
||||
default: false
|
||||
})
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
|
||||
@@ -66,6 +66,7 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -130,10 +131,12 @@ const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')
|
||||
const node = litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
const node = withNodeAddSource('search_modal', () =>
|
||||
litegraphService.addNodeOnGraph(
|
||||
nodeDef,
|
||||
{ pos: getNewNodeLocation() },
|
||||
{ ghost: useSearchBoxV2.value && followCursor, dragEvent }
|
||||
)
|
||||
)
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
|
||||
@@ -155,8 +156,8 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
if (this.leaf && model) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
const graphNode = useLitegraphService().addNodeOnGraph(
|
||||
provider.nodeDef
|
||||
const graphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(provider.nodeDef)
|
||||
)
|
||||
const widget = graphNode?.widgets?.find(
|
||||
(widget) => widget.name === provider.key
|
||||
|
||||
@@ -189,6 +189,7 @@ import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import {
|
||||
DEFAULT_GROUPING_ID,
|
||||
@@ -321,8 +322,11 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
}
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeTreeFolder from '@/components/sidebar/tabs/nodeLibrary/NodeTreeFolder.vue'
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -183,8 +184,11 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
await nodeBookmarkStore.addBookmark(nodePath)
|
||||
},
|
||||
handleClick(e: MouseEvent) {
|
||||
if (this.leaf && this.data) {
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
const nodeDef = this.data
|
||||
if (this.leaf && nodeDef) {
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef)
|
||||
)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FirebaseError } from 'firebase/app'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -26,9 +27,20 @@ const mockDialogService = vi.hoisted(() => ({
|
||||
confirm: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
|
||||
const knownAuthErrorCodes = new Set([
|
||||
'auth/invalid-credential',
|
||||
'auth/email-already-in-use'
|
||||
])
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, values?: { workflow?: string }) =>
|
||||
values?.workflow ? `${key}:${values.workflow}` : key
|
||||
values?.workflow ? `${key}:${values.workflow}` : key,
|
||||
st: (key: string, fallback: string) => {
|
||||
const code = key.replace('auth.errors.', '')
|
||||
return knownAuthErrorCodes.has(code) ? key : fallback
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -72,7 +84,7 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
|
||||
action: (...args: TArgs) => Promise<TReturn> | TReturn
|
||||
) => action,
|
||||
toastErrorHandler: vi.fn()
|
||||
toastErrorHandler: mockToastErrorHandler
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -193,3 +205,46 @@ describe('useAuthActions.logout', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthActions.reportError', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the friendly message for a known Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/invalid-credential', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.auth/invalid-credential'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the generic fallback for an unknown Firebase auth code', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/some-new-code', 'raw firebase'))
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'auth.errors.generic'
|
||||
})
|
||||
expect(mockToastErrorHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates non-Firebase errors to toastErrorHandler', () => {
|
||||
const { reportError } = useAuthActions()
|
||||
const networkError = new TypeError('Failed to fetch')
|
||||
|
||||
reportError(networkError)
|
||||
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ref } from 'vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
||||
import { t } from '@/i18n'
|
||||
import { st, t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -47,6 +47,12 @@ export const useAuthActions = () => {
|
||||
email: 'support@comfy.org'
|
||||
})
|
||||
})
|
||||
} else if (error instanceof FirebaseError) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: st(`auth.errors.${error.code}`, t('auth.errors.generic'))
|
||||
})
|
||||
} else {
|
||||
toastErrorHandler(error)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -37,7 +38,8 @@ function isOverCanvas(clientX: number, clientY: number): boolean {
|
||||
}
|
||||
|
||||
function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
if (!draggedNode.value) return false
|
||||
const nodeDef = draggedNode.value
|
||||
if (!nodeDef) return false
|
||||
const canvas = useCanvasStore().canvas
|
||||
if (!canvas) return false
|
||||
if (!isOverCanvas(clientX, clientY)) return false
|
||||
@@ -46,7 +48,9 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
|
||||
clientX,
|
||||
clientY
|
||||
} as PointerEvent)
|
||||
const node = useLitegraphService().addNodeOnGraph(draggedNode.value, { pos })
|
||||
const node = withNodeAddSource('sidebar_drag', () =>
|
||||
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
if (node) canvas.selectItems([node])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/as
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
@@ -146,9 +147,11 @@ export function useJobMenu(
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) return
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
)
|
||||
|
||||
if (!node) return
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
|
||||
import { usePragmaticDroppable } from '@/composables/usePragmaticDragAndDrop'
|
||||
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -37,7 +38,9 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
// Add an offset on y to make sure after adding the node, the cursor
|
||||
// is on the node (top left corner)
|
||||
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, { pos })
|
||||
)
|
||||
} else if (node.data instanceof ComfyModelDef) {
|
||||
const model = node.data
|
||||
const pos = basePos
|
||||
@@ -58,11 +61,8 @@ export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
|
||||
if (!targetGraphNode) {
|
||||
const provider = modelToNodeStore.getNodeProvider(model.directory)
|
||||
if (provider) {
|
||||
targetGraphNode = litegraphService.addNodeOnGraph(
|
||||
provider.nodeDef,
|
||||
{
|
||||
pos
|
||||
}
|
||||
targetGraphNode = withNodeAddSource('sidebar_drag', () =>
|
||||
litegraphService.addNodeOnGraph(provider.nodeDef, { pos })
|
||||
)
|
||||
targetProvider = provider
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -107,7 +108,8 @@ export function useFeatureFlags() {
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('useLoad3d', () => {
|
||||
setMaterialMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setRetainViewOnReload: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -333,20 +334,6 @@ describe('useLoad3d', () => {
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should set preview mode when comfyClass starts with Preview, even with width/height widgets', async () => {
|
||||
Object.defineProperty(mockNode, 'constructor', {
|
||||
value: { comfyClass: 'Preview3DAdvanced' },
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
@@ -583,17 +570,21 @@ describe('useLoad3d', () => {
|
||||
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
|
||||
vi.mocked(mockLoad3d.setFOV!).mockClear()
|
||||
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
|
||||
|
||||
composable.cameraConfig.value.cameraType = 'orthographic'
|
||||
composable.cameraConfig.value.fov = 90
|
||||
composable.cameraConfig.value.retainViewOnReload = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
|
||||
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
|
||||
expect(mockNode.properties['Camera Config']).toEqual({
|
||||
cameraType: 'orthographic',
|
||||
fov: 90,
|
||||
state: null
|
||||
state: null,
|
||||
retainViewOnReload: true
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -152,10 +152,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (
|
||||
node.constructor.comfyClass?.startsWith('Preview') ||
|
||||
!(widthWidget && heightWidget)
|
||||
) {
|
||||
if (!(widthWidget && heightWidget)) {
|
||||
isPreview.value = true
|
||||
}
|
||||
|
||||
@@ -487,6 +484,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Camera Config'] = newValue
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CameraState } from '@/extensions/core/load3d/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
@@ -10,21 +9,17 @@ const {
|
||||
waitForLoad3dMock,
|
||||
onLoad3dReadyMock,
|
||||
configureMock,
|
||||
configureForSaveMeshMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock,
|
||||
getNodeByLocatorIdMock,
|
||||
nodeToLoad3dMap
|
||||
getNodeByLocatorIdMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
onLoad3dReadyMock: vi.fn(),
|
||||
configureMock: vi.fn(),
|
||||
configureForSaveMeshMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn(),
|
||||
getNodeByLocatorIdMock: vi.fn(),
|
||||
nodeToLoad3dMap: new Map<object, unknown>()
|
||||
getNodeByLocatorIdMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
@@ -43,13 +38,12 @@ vi.mock('@/composables/useLoad3d', () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap
|
||||
nodeToLoad3dMap: new Map()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configure = configureMock
|
||||
configureForSaveMesh = configureForSaveMeshMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -122,21 +116,18 @@ type ExtCreated = ComfyExtension & {
|
||||
onNodeOutputsUpdated: (
|
||||
nodeOutputs: Record<string, Record<string, unknown>>
|
||||
) => void
|
||||
getCustomWidgets: () => Record<string, (node: LGraphNode) => unknown>
|
||||
}
|
||||
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
preview3DAdvancedExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
|
||||
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,22 +153,6 @@ function makePreview3DNode(
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makePreview3DAdvancedNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeLoad3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
@@ -204,14 +179,7 @@ interface FakeLoad3d {
|
||||
whenLoadIdle: () => Promise<void>
|
||||
setCameraFromMatrices: ReturnType<typeof vi.fn>
|
||||
setBackgroundImage: ReturnType<typeof vi.fn>
|
||||
setCameraState: ReturnType<typeof vi.fn>
|
||||
getCameraState: ReturnType<typeof vi.fn>
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
getModelInfo: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
forceRender: ReturnType<typeof vi.fn>
|
||||
cameraManager: { perspectiveCamera: { fov: number } }
|
||||
currentLoadGeneration: number
|
||||
}
|
||||
|
||||
@@ -220,14 +188,7 @@ function makeLoad3dMock(): FakeLoad3d {
|
||||
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
|
||||
setCameraFromMatrices: vi.fn(),
|
||||
setBackgroundImage: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
getCameraState: vi.fn(() => ({ position: [0, 0, 5], target: [0, 0, 0] })),
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
getModelInfo: vi.fn(() => null),
|
||||
applyModelTransform: vi.fn(),
|
||||
isSplatModel: vi.fn(() => false),
|
||||
forceRender: vi.fn(),
|
||||
cameraManager: { perspectiveCamera: { fov: 35 } },
|
||||
currentLoadGeneration: 0
|
||||
}
|
||||
}
|
||||
@@ -238,7 +199,6 @@ async function flush() {
|
||||
|
||||
function setupBaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
@@ -250,14 +210,12 @@ function setupBaseMocks() {
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
|
||||
await loadExtensionsFresh()
|
||||
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -518,47 +476,6 @@ describe('Comfy.Load3D.nodeCreated', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D.getCustomWidgets LOAD_3D', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('adds upload and clear buttons when the node has a model_file widget', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode()
|
||||
const addWidget = node.addWidget as ReturnType<typeof vi.fn>
|
||||
|
||||
load3DExt.getCustomWidgets().LOAD_3D(node)
|
||||
|
||||
const buttonNames = addWidget.mock.calls
|
||||
.filter(([type]) => type === 'button')
|
||||
.map(([, name]) => name)
|
||||
expect(buttonNames).toEqual([
|
||||
'upload 3d model',
|
||||
'upload extra resources',
|
||||
'clear'
|
||||
])
|
||||
})
|
||||
|
||||
it('skips upload and clear buttons when the node has no model_file widget (e.g. Preview3DAdvanced)', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({
|
||||
comfyClass: 'Preview3DAdvanced',
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
})
|
||||
const addWidget = node.addWidget as ReturnType<typeof vi.fn>
|
||||
|
||||
load3DExt.getCustomWidgets().LOAD_3D(node)
|
||||
|
||||
const buttonCalls = addWidget.mock.calls.filter(
|
||||
([type]) => type === 'button'
|
||||
)
|
||||
expect(buttonCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
@@ -693,324 +610,3 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Preview3DAdvanced', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call configureForSaveMesh on creation when no Last Time Model File is persisted', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores via configureForSaveMesh when Last Time Model File is persisted', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
properties: { 'Last Time Model File': 'prev/model.glb' }
|
||||
})
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'prev/model.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the saved camera state after model load when reloading the page', async () => {
|
||||
const persistedCameraState = {
|
||||
position: [1, 2, 3],
|
||||
target: [0, 0, 0]
|
||||
} as unknown as CameraState
|
||||
const load3dInstance = makeLoad3dMock()
|
||||
onLoad3dReadyMock.mockImplementationOnce(
|
||||
(cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(load3dInstance)
|
||||
}
|
||||
)
|
||||
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
properties: {
|
||||
'Last Time Model File': 'prev/model.glb',
|
||||
'Camera Config': {
|
||||
cameraType: 'perspective',
|
||||
fov: 35,
|
||||
state: persistedCameraState
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
await flush()
|
||||
|
||||
expect(load3dInstance.setCameraState).toHaveBeenCalledWith(
|
||||
persistedCameraState
|
||||
)
|
||||
expect(load3dInstance.forceRender).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call setCameraState when no Camera Config state is persisted', async () => {
|
||||
const load3dInstance = makeLoad3dMock()
|
||||
onLoad3dReadyMock.mockImplementationOnce(
|
||||
(cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(load3dInstance)
|
||||
}
|
||||
)
|
||||
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
properties: { 'Last Time Model File': 'prev/model.glb' }
|
||||
})
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
await flush()
|
||||
|
||||
expect(load3dInstance.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches a camera-only serializeValue to the image widget', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(typeof widgets[0].serializeValue).toBe('function')
|
||||
})
|
||||
|
||||
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
const payload = await widgets[0].serializeValue!()
|
||||
|
||||
expect(payload).toEqual({
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: { position: [0, 0, 5], target: [0, 0, 0] },
|
||||
recording: '',
|
||||
model_3d_info: []
|
||||
})
|
||||
})
|
||||
|
||||
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
const modelInfo = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
load3d.getModelInfo = vi.fn(() => modelInfo)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
const payload = (await widgets[0].serializeValue!()) as {
|
||||
model_3d_info: unknown[]
|
||||
}
|
||||
|
||||
expect(payload.model_3d_info).toEqual([modelInfo])
|
||||
})
|
||||
|
||||
it('onExecuted persists Last Time Model File with normalized slashes and calls configureForSaveMesh', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'sub/nested/mesh.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('onExecuted applies the input cameraState when one is forwarded via PreviewUI3D', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const cameraState = { position: [1, 2, 3] }
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', cameraState] })
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).toHaveBeenCalledWith(cameraState)
|
||||
})
|
||||
|
||||
it('onExecuted applies the first model_3d_info entry to the viewport when present', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const transform = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, [transform]]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
})
|
||||
|
||||
it('onExecuted does not call applyModelTransform when model_3d_info is empty', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, []]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.applyModelTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted defensively skips cameraState apply when result[1] is missing', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb'] })
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted skips cameraState apply when load3d generation changes before whenLoadIdle resolves', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
let resolveIdle: () => void = () => {}
|
||||
load3d.whenLoadIdle = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveIdle = resolve
|
||||
})
|
||||
)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', { position: [1, 2, 3] }] })
|
||||
|
||||
load3d.currentLoadGeneration = 6
|
||||
resolveIdle()
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted shows an error toast when no file path is returned', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: [] })
|
||||
|
||||
expect(toastAddAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.unableToGetModelFilePath'
|
||||
)
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('returns [] for non-Preview3DAdvanced nodes', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when no load3d instance exists for the node', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue(null)
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for splat models', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns export menu items for non-splat models', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([
|
||||
{ content: 'Export' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,9 +29,6 @@ type Matrix = number[][]
|
||||
type Load3dPreviewOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, string?, Matrix?, Matrix?]
|
||||
}>
|
||||
type Preview3DAdvancedOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, Model3DInfo?]
|
||||
}>
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ComfyApp, app } from '@/scripts/app'
|
||||
@@ -272,10 +269,7 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
const hasModelFileWidget = node.widgets?.some(
|
||||
(w) => w.name === 'model_file'
|
||||
)
|
||||
if (hasModelFileWidget) {
|
||||
if (node.constructor.comfyClass === 'Load3D') {
|
||||
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
|
||||
|
||||
node.properties['Resource Folder'] = ''
|
||||
@@ -657,156 +651,3 @@ useExtensionService().registerExtension({
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAdvanced',
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to restore camera state for Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
|
||||
const currentLoad3d = resolveLoad3d()
|
||||
const config = new Load3DConfiguration(currentLoad3d, node.properties)
|
||||
config.configureForSaveMesh('output', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraState = result?.[1]
|
||||
const modelTransform = result?.[2]?.[0]
|
||||
if (cameraState || modelTransform) {
|
||||
const targetGeneration = currentLoad3d.currentLoadGeneration
|
||||
void currentLoad3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
|
||||
return
|
||||
if (cameraState) currentLoad3d.setCameraState(cameraState)
|
||||
if (modelTransform)
|
||||
currentLoad3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -287,41 +287,6 @@ describe('GizmoManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyModelTransform', () => {
|
||||
it('sets position, quaternion, and scale on target and notifies', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
manager.applyModelTransform({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.92 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
})
|
||||
|
||||
expect(model.position.x).toBeCloseTo(1)
|
||||
expect(model.position.y).toBeCloseTo(2)
|
||||
expect(model.position.z).toBeCloseTo(3)
|
||||
expect(model.quaternion.x).toBeCloseTo(0.1)
|
||||
expect(model.quaternion.y).toBeCloseTo(0.2)
|
||||
expect(model.quaternion.z).toBeCloseTo(0.3)
|
||||
expect(model.quaternion.w).toBeCloseTo(0.92)
|
||||
expect(model.scale.x).toBeCloseTo(2)
|
||||
expect(onTransformChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does nothing without a target', () => {
|
||||
manager.init()
|
||||
expect(() =>
|
||||
manager.applyModelTransform({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransform', () => {
|
||||
it('returns current target transform', () => {
|
||||
manager.init()
|
||||
|
||||
@@ -159,27 +159,6 @@ export class GizmoManager {
|
||||
}
|
||||
}
|
||||
|
||||
applyModelTransform(transform: Model3DTransform): void {
|
||||
if (!this.targetObject) return
|
||||
this.targetObject.position.set(
|
||||
transform.position.x,
|
||||
transform.position.y,
|
||||
transform.position.z
|
||||
)
|
||||
this.targetObject.quaternion.set(
|
||||
transform.quaternion.x,
|
||||
transform.quaternion.y,
|
||||
transform.quaternion.z,
|
||||
transform.quaternion.w
|
||||
)
|
||||
this.targetObject.scale.set(
|
||||
transform.scale.x,
|
||||
transform.scale.y,
|
||||
transform.scale.z
|
||||
)
|
||||
this.onTransformChange?.()
|
||||
}
|
||||
|
||||
getInitialTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
|
||||
@@ -39,7 +39,6 @@ type GizmoStub = {
|
||||
setMode: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
applyTransform: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
getTransform: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
updateCamera: ReturnType<typeof vi.fn>
|
||||
@@ -74,7 +73,6 @@ function makeGizmoStub(): GizmoStub {
|
||||
setMode: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
applyTransform: vi.fn(),
|
||||
applyModelTransform: vi.fn(),
|
||||
getTransform: vi.fn(() => ({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
@@ -205,19 +203,6 @@ describe('Load3d', () => {
|
||||
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
|
||||
})
|
||||
|
||||
it('applyModelTransform forwards the full position/quaternion/scale payload', () => {
|
||||
const transform = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.4 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
|
||||
ctx.load3d.applyModelTransform(transform)
|
||||
|
||||
expect(ctx.gizmo.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('getGizmoTransform returns the gizmoManager transform', () => {
|
||||
const transform = {
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
@@ -787,8 +772,8 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('camera framing across reloads', () => {
|
||||
function setupLoadInternal() {
|
||||
describe('retainViewOnReload', () => {
|
||||
function setupLoadInternal(initialFlag: boolean) {
|
||||
const getCameraState = vi.fn<() => CameraState>(() => ({
|
||||
position: new THREE.Vector3(1, 2, 3),
|
||||
target: new THREE.Vector3(),
|
||||
@@ -817,23 +802,25 @@ describe('Load3d', () => {
|
||||
setupModelAnimations: vi.fn()
|
||||
},
|
||||
handleResize: vi.fn(),
|
||||
retainViewOnReload: initialFlag,
|
||||
hasLoadedModel: false
|
||||
})
|
||||
return { getCameraState, setCameraState, getCurrentCameraType }
|
||||
}
|
||||
|
||||
it('first load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
it('first load uses default framing even with retain enabled', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
|
||||
// hasLoadedModel started false, so retain shouldn't kick in yet.
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('subsequent load preserves the user-adjusted camera framing', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
it('subsequent load captures camera state, skips reset, and restores it', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
@@ -847,8 +834,23 @@ describe('Load3d', () => {
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not retain when the flag is off, even after a prior load', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles to the saved camera type before restoring state when types differ', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
const mocks = setupLoadInternal(true)
|
||||
mocks.getCameraState.mockImplementation(() => ({
|
||||
position: new THREE.Vector3(0, 0, 5),
|
||||
target: new THREE.Vector3(),
|
||||
@@ -868,7 +870,7 @@ describe('Load3d', () => {
|
||||
})
|
||||
|
||||
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
const mocks = setupLoadInternal(true)
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.clearModel()
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
@@ -879,6 +881,22 @@ describe('Load3d', () => {
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.setRetainViewOnReload(true)
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
|
||||
expect(mocks.getCameraState).toHaveBeenCalledOnce()
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
|
||||
@@ -105,6 +105,7 @@ class Load3d {
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
private retainViewOnReload: boolean = false
|
||||
private hasLoadedModel: boolean = false
|
||||
|
||||
constructor(
|
||||
@@ -578,14 +579,17 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
public setRetainViewOnReload(value: boolean): void {
|
||||
this.retainViewOnReload = value
|
||||
}
|
||||
|
||||
private async _loadModelInternal(
|
||||
url: string,
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
// First load always uses default framing; subsequent reloads preserve
|
||||
// the user's framing.
|
||||
const shouldRetainView = this.hasLoadedModel
|
||||
// First load always uses default framing; retain only applies on reload.
|
||||
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
|
||||
const savedCameraState = shouldRetainView
|
||||
? this.cameraManager.getCameraState()
|
||||
: null
|
||||
@@ -915,12 +919,6 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public applyModelTransform(transform: Model3DTransform): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.applyModelTransform(transform)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public getGizmoTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
retainViewOnReload?: boolean
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
|
||||
@@ -2278,7 +2278,8 @@
|
||||
"auth/invalid-credential": "Invalid login credentials. Please check your email and password.",
|
||||
"auth/network-request-failed": "Network error. Please check your connection and try again.",
|
||||
"auth/popup-closed-by-user": "Sign-in was cancelled. Please try again.",
|
||||
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again."
|
||||
"auth/cancelled-popup-request": "Sign-in was cancelled. Please try again.",
|
||||
"generic": "Something went wrong while signing you in. Please try again."
|
||||
},
|
||||
"deleteAccount": {
|
||||
"contactSupport": "To delete your account, please contact {email}"
|
||||
@@ -2949,6 +2950,29 @@
|
||||
"cloudStart_learnAboutButton": "Learn about Cloud",
|
||||
"cloudStart_wantToRun": "Want to run ComfyUI locally instead?",
|
||||
"cloudStart_download": "Download ComfyUI",
|
||||
"cloudHero": {
|
||||
"previousSlide": "Previous slide",
|
||||
"nextSlide": "Next slide",
|
||||
"slidePagerLabel": "Go to slide {index}",
|
||||
"slides": {
|
||||
"cloud": {
|
||||
"title": "Cloud",
|
||||
"description": "Best for most users who want to work from anywhere with models verified for commercial license."
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Workflows",
|
||||
"description": "From idea to output in minutes. Generate multiple variations side by side."
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"description": "Onboard your team today. Share workflows and assets across your organization."
|
||||
},
|
||||
"models": {
|
||||
"title": "Models",
|
||||
"description": "Curated, commercially licensed models ready to run with zero setup."
|
||||
}
|
||||
}
|
||||
},
|
||||
"cloudWaitlist_questionsText": "Questions? Contact us",
|
||||
"cloudWaitlist_contactLink": "here",
|
||||
"cloudSorryContactSupport_title": "Sorry, contact support",
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('AssetBrowserModal', () => {
|
||||
): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
asset_hash: `blake3:${id.padEnd(64, '0')}`,
|
||||
hash: `blake3:${id.padEnd(64, '0')}`,
|
||||
size: 1024000,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', category, 'test'],
|
||||
|
||||
@@ -49,10 +49,10 @@ const ORIGINAL_FILENAME = 'sunset_photo.png'
|
||||
function createDisplayAsset(
|
||||
overrides: Partial<AssetDisplayItem> = {}
|
||||
): AssetDisplayItem {
|
||||
return {
|
||||
const base = {
|
||||
id: 'asset-1',
|
||||
name: HASH,
|
||||
asset_hash: HASH,
|
||||
hash: HASH,
|
||||
tags: ['input'],
|
||||
preview_url: '/preview.png',
|
||||
secondaryText: '',
|
||||
@@ -62,6 +62,7 @@ function createDisplayAsset(
|
||||
metadata: { filename: ORIGINAL_FILENAME },
|
||||
...overrides
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function renderCard(asset: AssetDisplayItem) {
|
||||
@@ -97,7 +98,7 @@ describe('AssetCard', () => {
|
||||
})
|
||||
|
||||
describe('FE-228: filename rendering', () => {
|
||||
it('renders the human-readable filename instead of asset_hash when asset.name equals asset_hash', () => {
|
||||
it('renders the human-readable filename instead of hash when asset.name equals hash', () => {
|
||||
const asset = createDisplayAsset()
|
||||
|
||||
renderCard(asset)
|
||||
@@ -130,7 +131,7 @@ describe('AssetCard', () => {
|
||||
const asset = createDisplayAsset({
|
||||
id: 'model-1',
|
||||
name: MODEL_FILENAME,
|
||||
asset_hash: undefined,
|
||||
hash: undefined,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: { name: CURATED_NAME },
|
||||
metadata: { filename: MODEL_FILENAME }
|
||||
@@ -146,7 +147,7 @@ describe('AssetCard', () => {
|
||||
it('ignores user_metadata.name that duplicates the hash and falls back to metadata.filename', () => {
|
||||
const asset = createDisplayAsset({
|
||||
name: HASH,
|
||||
asset_hash: HASH,
|
||||
hash: HASH,
|
||||
user_metadata: { name: HASH },
|
||||
metadata: { filename: ORIGINAL_FILENAME }
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ function makeAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
name: 'mesh.glb',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
mime_type: 'model/gltf-binary',
|
||||
tags: [],
|
||||
kind: '3D',
|
||||
|
||||
@@ -13,7 +13,7 @@ function createVideoAsset(
|
||||
return {
|
||||
id: 'video-1',
|
||||
name: 'clip.mp4',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
mime_type: mimeType,
|
||||
tags: [],
|
||||
kind: 'video',
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('ModelInfoPanel', () => {
|
||||
): AssetDisplayItem => ({
|
||||
id: 'test-id',
|
||||
name: 'test-model.safetensors',
|
||||
asset_hash: 'hash123',
|
||||
hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
|
||||
@@ -26,7 +26,7 @@ function makeAsset(index: number): AssetItem {
|
||||
return {
|
||||
id: `asset-${index}`,
|
||||
name: `asset-${index}.safetensors`,
|
||||
asset_hash: `blake3:${index}`,
|
||||
hash: `blake3:${index}`,
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', category],
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('useAssetBrowser', () => {
|
||||
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
|
||||
id: 'test-id',
|
||||
name: 'test-asset.safetensors',
|
||||
asset_hash: 'blake3:abc123',
|
||||
hash: 'blake3:abc123',
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
|
||||
@@ -296,7 +296,7 @@ describe('useMediaAssetActions', () => {
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'my-image.jpeg',
|
||||
asset_hash: 'hash123.jpeg'
|
||||
hash: 'hash123.jpeg'
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -310,12 +310,12 @@ describe('useMediaAssetActions', () => {
|
||||
mockIsCloud.value = true
|
||||
})
|
||||
|
||||
it('should use asset_hash as filename when available', async () => {
|
||||
it('should use hash as filename when available', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'original.jpeg',
|
||||
asset_hash: 'abc123hash.jpeg'
|
||||
hash: 'abc123hash.jpeg'
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -323,12 +323,12 @@ describe('useMediaAssetActions', () => {
|
||||
expect(capturedFilenames.values).toContain('abc123hash.jpeg')
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when asset_hash is not available', async () => {
|
||||
it('should fall back to asset.name when hash is not available', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'fallback-name.jpeg',
|
||||
asset_hash: undefined
|
||||
hash: undefined
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -336,12 +336,12 @@ describe('useMediaAssetActions', () => {
|
||||
expect(capturedFilenames.values).toContain('fallback-name.jpeg')
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when asset_hash is null', async () => {
|
||||
it('should fall back to asset.name when hash is null', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
name: 'fallback-null.jpeg',
|
||||
asset_hash: null
|
||||
hash: null
|
||||
})
|
||||
|
||||
await actions.addWorkflow(asset)
|
||||
@@ -357,19 +357,19 @@ describe('useMediaAssetActions', () => {
|
||||
mockIsCloud.value = true
|
||||
})
|
||||
|
||||
it('should use asset_hash for each asset', async () => {
|
||||
it('should use hash for each asset', async () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assets = [
|
||||
createMockAsset({
|
||||
id: '1',
|
||||
name: 'file1.jpeg',
|
||||
asset_hash: 'hash1.jpeg'
|
||||
hash: 'hash1.jpeg'
|
||||
}),
|
||||
createMockAsset({
|
||||
id: '2',
|
||||
name: 'file2.jpeg',
|
||||
asset_hash: 'hash2.jpeg'
|
||||
hash: 'hash2.jpeg'
|
||||
})
|
||||
]
|
||||
|
||||
@@ -973,7 +973,7 @@ describe('useMediaAssetActions', () => {
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-match',
|
||||
name: 'foo.png',
|
||||
asset_hash: 'abc123.png',
|
||||
hash: 'abc123.png',
|
||||
tags: ['input']
|
||||
})
|
||||
|
||||
@@ -1051,7 +1051,7 @@ describe('useMediaAssetActions', () => {
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-failed',
|
||||
name: 'failed.png',
|
||||
asset_hash: 'failhash.png'
|
||||
hash: 'failhash.png'
|
||||
})
|
||||
|
||||
await actions.deleteAssets(asset)
|
||||
|
||||
@@ -6,6 +6,7 @@ import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationD
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
@@ -42,8 +43,8 @@ const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
|
||||
*
|
||||
* Output assets emit `<name> [output]` (and the subfolder-prefixed form when
|
||||
* present in metadata). Input/temp assets emit the bare name plus the explicit
|
||||
* annotation. `asset_hash` is included whenever present, since cloud-stored
|
||||
* assets can be referenced by hash.
|
||||
* annotation. The content `hash` is included whenever present, since
|
||||
* cloud-stored assets can be referenced by hash.
|
||||
*/
|
||||
function widgetValueVariantsForAsset(asset: AssetItem): string[] {
|
||||
const variants: string[] = []
|
||||
@@ -61,7 +62,8 @@ function widgetValueVariantsForAsset(asset: AssetItem): string[] {
|
||||
variants.push(`${name} [input]`)
|
||||
}
|
||||
}
|
||||
if (asset.asset_hash) variants.push(asset.asset_hash)
|
||||
const hash = asset.hash
|
||||
if (hash) variants.push(hash)
|
||||
return variants
|
||||
}
|
||||
|
||||
@@ -279,9 +281,11 @@ export function useMediaAssetActions() {
|
||||
return
|
||||
}
|
||||
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
)
|
||||
|
||||
if (!node) {
|
||||
toast.add({
|
||||
@@ -296,12 +300,10 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||
const assetType = getAssetType(targetAsset, 'input')
|
||||
|
||||
// In Cloud mode, use asset_hash (the actual stored filename)
|
||||
// In OSS mode, use the original name
|
||||
const filename =
|
||||
isCloud && targetAsset.asset_hash
|
||||
? targetAsset.asset_hash
|
||||
: targetAsset.name
|
||||
// In Cloud mode, use the content hash (the actual stored filename).
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = targetAsset.hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
|
||||
|
||||
// Create annotated path for the asset
|
||||
const annotated = createAnnotatedPath(
|
||||
@@ -425,12 +427,14 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
|
||||
const center = litegraphService.getCanvasCenter()
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: [
|
||||
center[0] + nodeIndex * NODE_OFFSET,
|
||||
center[1] + nodeIndex * NODE_OFFSET
|
||||
]
|
||||
})
|
||||
const node = withNodeAddSource('programmatic', () =>
|
||||
litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: [
|
||||
center[0] + nodeIndex * NODE_OFFSET,
|
||||
center[1] + nodeIndex * NODE_OFFSET
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
if (!node) {
|
||||
failed++
|
||||
@@ -440,10 +444,10 @@ export function useMediaAssetActions() {
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const assetType = getAssetType(asset, 'input')
|
||||
|
||||
// In Cloud mode, use asset_hash (the actual stored filename)
|
||||
// In OSS mode, use the original name
|
||||
const filename =
|
||||
isCloud && asset.asset_hash ? asset.asset_hash : asset.name
|
||||
// In Cloud mode, use the content hash (the actual stored filename).
|
||||
// In OSS mode, use the original name.
|
||||
const cloudHash = asset.hash
|
||||
const filename = isCloud && cloudHash ? cloudHash : asset.name
|
||||
|
||||
const annotated = createAnnotatedPath(
|
||||
{
|
||||
|
||||
@@ -97,11 +97,12 @@ export function createMockAssets(count: number = 20): AssetItem[] {
|
||||
const lastAccessTime = getRandomISODate()
|
||||
|
||||
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
|
||||
const fakeAssetHash = generateFakeAssetHash()
|
||||
|
||||
return {
|
||||
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
|
||||
name: fakeFileName,
|
||||
asset_hash: generateFakeAssetHash(),
|
||||
hash: fakeAssetHash,
|
||||
size: sizeInBytes,
|
||||
mime_type: mimeType,
|
||||
tags: [
|
||||
|
||||
@@ -5,7 +5,7 @@ import { z } from 'zod'
|
||||
const zAsset = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
asset_hash: z.string().nullish(),
|
||||
hash: z.string().nullish(),
|
||||
size: z.number().optional(), // TBD: Will be provided by history API in the future
|
||||
mime_type: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('assetMetadataUtils', () => {
|
||||
const mockAsset: AssetItem = {
|
||||
id: 'test-id',
|
||||
name: 'test-model',
|
||||
asset_hash: 'hash123',
|
||||
hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
|
||||
@@ -201,10 +201,10 @@ export function getAssetCardTitle(asset: AssetItem): string {
|
||||
|
||||
/**
|
||||
* Returns the filename component the cloud `/api/view` endpoint resolves
|
||||
* for this asset — `asset_hash` when present (cloud assets are hash-keyed
|
||||
* for this asset — `hash` when present (cloud assets are hash-keyed
|
||||
* in storage), otherwise `asset.name`. Use this when constructing widget
|
||||
* values or media URLs that must round-trip through the view endpoint.
|
||||
*/
|
||||
export function getAssetUrlFilename(asset: AssetItem): string {
|
||||
return asset.asset_hash || asset.name
|
||||
return asset.hash ?? asset.name
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ function mockFetchError() {
|
||||
const cloudAsset = {
|
||||
id: '72d169cc-7f9a-40d2-9382-35eadcba0a6a',
|
||||
name: 'mesh/ComfyUI_00003_.glb',
|
||||
asset_hash: 'c6cadcee57dd.glb',
|
||||
hash: 'c6cadcee57dd.glb',
|
||||
preview_id: null,
|
||||
preview_url: undefined
|
||||
}
|
||||
@@ -110,9 +110,7 @@ describe('findOutputAsset', () => {
|
||||
const result = await findOutputAsset('c6cadcee57dd.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledOnce()
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain(
|
||||
'asset_hash=c6cadcee57dd.glb'
|
||||
)
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=c6cadcee57dd.glb')
|
||||
expect(result).toEqual(cloudAsset)
|
||||
})
|
||||
|
||||
@@ -123,7 +121,7 @@ describe('findOutputAsset', () => {
|
||||
const result = await findOutputAsset('ComfyUI_00081_.glb')
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('asset_hash=')
|
||||
expect(mockFetchApi.mock.calls[0][0]).toContain('hash=')
|
||||
expect(mockFetchApi.mock.calls[1][0]).toContain('name_contains=')
|
||||
expect(result).toEqual(localAsset)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAssetsStore } from '@/stores/assetsStore'
|
||||
interface AssetRecord {
|
||||
id: string
|
||||
name: string
|
||||
asset_hash?: string
|
||||
hash?: string
|
||||
preview_url?: string
|
||||
preview_id?: string | null
|
||||
}
|
||||
@@ -35,14 +35,14 @@ function resolvePreviewUrl(asset: AssetRecord): string {
|
||||
|
||||
/**
|
||||
* Find an output asset record by content hash, falling back to name.
|
||||
* On cloud, output filenames are content-hashed; use asset_hash to match.
|
||||
* On cloud, output filenames are content-hashed; use hash to match.
|
||||
* On local, filenames are not hashed; use name_contains to match.
|
||||
*/
|
||||
export async function findOutputAsset(
|
||||
name: string
|
||||
): Promise<AssetRecord | undefined> {
|
||||
const byHash = await fetchAssets({ asset_hash: name })
|
||||
const hashMatch = byHash.find((a) => a.asset_hash === name)
|
||||
const byHash = await fetchAssets({ hash: name })
|
||||
const hashMatch = byHash.find((a) => a.hash === name)
|
||||
if (hashMatch) return hashMatch
|
||||
|
||||
const byName = await fetchAssets({ name_contains: name })
|
||||
|
||||
@@ -15,7 +15,7 @@ import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
*
|
||||
* Comparison is full-string against the widget value as stored — callers must
|
||||
* provide the canonical widget-value variants for each deleted asset (e.g.
|
||||
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<asset_hash>`). This
|
||||
* `foo.png`, `foo.png [output]`, `sub/foo.png [output]`, `<hash>`). This
|
||||
* avoids false matches when two distinct assets share a basename across
|
||||
* input/output sources.
|
||||
*
|
||||
|
||||
@@ -124,5 +124,11 @@ const handleSubmit = async () => {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: #2d2e32 !important;
|
||||
color: var(--color-primary-comfy-canvas) !important;
|
||||
caret-color: var(--color-primary-comfy-canvas);
|
||||
}
|
||||
|
||||
:deep(.p-inputtext::placeholder) {
|
||||
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,97 +1,83 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-center p-8">
|
||||
<div class="max-w-screen p-2 lg:w-96">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex flex-col gap-4">
|
||||
<h1 class="my-0 text-xl/normal font-medium">
|
||||
<div
|
||||
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
|
||||
>
|
||||
<div class="flex w-full max-w-md flex-col items-start">
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<h1
|
||||
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
{{ t('auth.login.title') }}
|
||||
</h1>
|
||||
<i18n-t
|
||||
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
|
||||
keypath="auth.login.signUpFreeTierPromo"
|
||||
tag="p"
|
||||
class="my-0 text-base text-muted"
|
||||
:plural="freeTierCredits ?? undefined"
|
||||
<p
|
||||
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
|
||||
>
|
||||
<template #signUp>
|
||||
<span
|
||||
class="cursor-pointer text-blue-500"
|
||||
@click="navigateToSignup"
|
||||
>{{ t('auth.login.signUp') }}</span
|
||||
>
|
||||
</template>
|
||||
<template #credits>{{ freeTierCredits }}</template>
|
||||
</i18n-t>
|
||||
<p v-else class="my-0 text-base text-muted">
|
||||
{{ t('auth.login.newUser') }}
|
||||
<span
|
||||
class="cursor-pointer text-blue-500"
|
||||
class="cursor-pointer text-azure-600"
|
||||
@click="navigateToSignup"
|
||||
>{{ t('auth.login.signUp') }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
|
||||
<template v-if="!showEmailForm">
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
|
||||
<template v-if="!showEmailForm">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
class="h-10 w-full"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
<i class="pi pi-google text-base" />
|
||||
{{ t('auth.login.loginWithGoogle') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10 bg-charcoal-500"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
<i class="pi pi-github text-base" />
|
||||
{{ t('auth.login.loginWithGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
variant="link"
|
||||
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
|
||||
@click="switchToEmailForm"
|
||||
>
|
||||
{{ t('auth.login.useEmailInstead') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
|
||||
<template v-else>
|
||||
<CloudSignInForm :auth-error="authError" @submit="signInWithEmail" />
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
variant="secondary"
|
||||
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-primary-comfy-canvas/10"
|
||||
@click="switchToSocialLogin"
|
||||
>
|
||||
{{ t('auth.login.backToSocialLogin') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
<p class="mt-5 text-sm text-gray-600">
|
||||
<p
|
||||
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
@@ -99,7 +85,7 @@
|
||||
<a
|
||||
href="https://www.comfy.org/privacy-policy"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
@@ -117,7 +103,6 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import CloudSignInForm from '@/platform/cloud/onboarding/components/CloudSignInForm.vue'
|
||||
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
|
||||
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
|
||||
@@ -129,7 +114,6 @@ const authActions = useAuthActions()
|
||||
const isSecureContext = globalThis.isSecureContext
|
||||
const authError = ref('')
|
||||
const showEmailForm = ref(false)
|
||||
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
|
||||
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
|
||||
const { onAuthSuccess } = usePostAuthRedirect({
|
||||
authError,
|
||||
|
||||
@@ -1,92 +1,81 @@
|
||||
<template>
|
||||
<div class="flex h-full items-center justify-center p-8">
|
||||
<div class="max-w-screen p-2 lg:w-96">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex flex-col gap-4">
|
||||
<h1 class="my-0 text-xl/normal font-medium">
|
||||
<div
|
||||
class="flex size-full items-center justify-center px-4 py-8 sm:px-6 md:px-8 lg:py-12"
|
||||
>
|
||||
<div class="flex w-full max-w-md flex-col items-start">
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<h1
|
||||
class="my-0 font-inter text-xl/8 font-extrabold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
{{ t('auth.signup.title') }}
|
||||
</h1>
|
||||
<p class="my-0 text-base">
|
||||
<span class="text-muted">{{
|
||||
<p
|
||||
class="my-0 text-base/6 tracking-[-0.02em] text-primary-comfy-canvas"
|
||||
>
|
||||
<span class="text-primary-comfy-canvas/70">{{
|
||||
t('auth.signup.alreadyHaveAccount')
|
||||
}}</span>
|
||||
<span
|
||||
class="ml-1 cursor-pointer text-blue-500"
|
||||
class="ml-1 cursor-pointer text-azure-600"
|
||||
@click="navigateToLogin"
|
||||
>{{ t('auth.signup.signIn') }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mb-4">
|
||||
<Message v-if="!isSecureContext" severity="warn" class="mt-4 w-full">
|
||||
{{ t('auth.login.insecureContextWarning') }}
|
||||
</Message>
|
||||
|
||||
<template v-if="!showEmailForm">
|
||||
<p
|
||||
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
|
||||
class="mb-4 text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
freeTierCredits
|
||||
? t('auth.login.freeTierDescription', {
|
||||
credits: freeTierCredits
|
||||
})
|
||||
: t('auth.login.freeTierDescriptionGeneric')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="!googleSsoBlockedReason" class="relative">
|
||||
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.signup.signUpWithGoogle') }}
|
||||
</Button>
|
||||
<span
|
||||
v-if="isFreeTierEnabled"
|
||||
class="absolute -top-2.5 -right-2.5 rounded-full bg-yellow-400 px-2 py-0.5 text-2xs font-bold whitespace-nowrap text-gray-900"
|
||||
>
|
||||
{{ t('auth.login.freeTierBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-4 pt-5 pb-2">
|
||||
<template v-if="!showEmailForm">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google text-base" />
|
||||
{{ t('auth.signup.signUpWithGoogle') }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10 bg-charcoal-500"
|
||||
variant="secondary"
|
||||
class="relative h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 font-inter text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
<i class="pi pi-github text-base" />
|
||||
{{ t('auth.signup.signUpWithGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
variant="link"
|
||||
class="text-sm/4 text-primary-comfy-canvas/70 hover:text-primary-comfy-canvas"
|
||||
@click="switchToEmailForm"
|
||||
>
|
||||
{{ t('auth.login.useEmailInstead') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Message v-if="isFreeTierEnabled" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
|
||||
</Message>
|
||||
<template v-else>
|
||||
<Message v-if="isFreeTierEnabled" severity="warn" class="w-full">
|
||||
{{ t('auth.signup.emailNotEligibleForFreeTier') }}
|
||||
</Message>
|
||||
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else :auth-error="authError" @submit="signUpWithEmail" />
|
||||
<Message v-if="userIsInChina" severity="warn" class="w-full">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm
|
||||
v-else
|
||||
:auth-error="authError"
|
||||
@submit="signUpWithEmail"
|
||||
/>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="text-sm underline"
|
||||
variant="secondary"
|
||||
class="mt-1 h-10 w-full rounded-md border-none bg-smoke-800/5 text-sm/5 font-normal tracking-[-0.011em] text-primary-comfy-canvas/55 hover:bg-sand-300/10"
|
||||
@click="switchToSocialLogin"
|
||||
>
|
||||
{{
|
||||
@@ -95,16 +84,17 @@
|
||||
: t('auth.login.backToSocialLogin')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
<p class="mt-5 text-sm text-gray-600">
|
||||
<p
|
||||
class="mx-auto my-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 py-4 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
@@ -112,16 +102,18 @@
|
||||
<a
|
||||
href="https://www.comfy.org/privacy-policy"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
<p
|
||||
class="mx-auto mt-2 mb-0 flex w-full max-w-10/12 flex-wrap items-center justify-center gap-x-1 text-center text-sm/5 tracking-[-0.011em] text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('cloudWaitlist_questionsText') }}
|
||||
<a
|
||||
href="https://support.comfy.org"
|
||||
class="cursor-pointer text-blue-400 no-underline"
|
||||
class="cursor-pointer text-azure-600 no-underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -159,7 +151,6 @@ const userIsInChina = ref(false)
|
||||
const telemetry = useTelemetry()
|
||||
const {
|
||||
showEmailForm,
|
||||
freeTierCredits,
|
||||
isFreeTierEnabled,
|
||||
switchToEmailForm,
|
||||
switchToSocialLogin
|
||||
@@ -197,7 +188,6 @@ const signUpWithEmail = async (values: SignUpData) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Track signup screen opened
|
||||
if (isCloud) {
|
||||
telemetry?.trackSignupOpened()
|
||||
}
|
||||
@@ -210,12 +200,22 @@ onMounted(async () => {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: #2d2e32 !important;
|
||||
color: var(--color-primary-comfy-canvas) !important;
|
||||
caret-color: var(--color-primary-comfy-canvas);
|
||||
}
|
||||
|
||||
:deep(.p-inputtext::placeholder) {
|
||||
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
|
||||
}
|
||||
|
||||
:deep(.p-password input) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:deep(.p-password-toggle-mask-icon) {
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.p-checkbox-checked .p-checkbox-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
|
||||
BIN
src/platform/cloud/onboarding/assets/hero/bottom-left.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
src/platform/cloud/onboarding/assets/hero/bottom-right.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
src/platform/cloud/onboarding/assets/hero/center-image.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
src/platform/cloud/onboarding/assets/hero/top-left.jpg
Normal file
|
After Width: | Height: | Size: 121 KiB |
106
src/platform/cloud/onboarding/components/CloudHeroCarousel.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex size-full flex-col items-center overflow-hidden rounded-lg bg-primary-comfy-canvas/4 pt-10 pb-4"
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-1 flex-col items-center justify-center gap-6 md:gap-8 lg:gap-10"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-5/4 w-full max-w-xs md:max-w-sm lg:max-w-md xl:max-w-lg"
|
||||
>
|
||||
<div class="absolute inset-0">
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 aspect-3/2 w-3/4 -translate-1/2"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-3xl border border-white/20 bg-white/10 p-3.5 shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="size-full overflow-hidden rounded-xl bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: `url(${centerImage})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute -top-1/20 -right-1/50 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<i class="icon-[comfy--gemini-mono] size-3.5" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute -bottom-2/25 left-1/2 flex aspect-square w-10 -translate-x-1/2 items-center justify-center rounded-xl border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<i class="icon-[comfy--grok] size-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-3/20 left-1/20 aspect-5/3 w-7/20">
|
||||
<div
|
||||
class="absolute inset-0 overflow-hidden rounded-2xl border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
|
||||
:style="{ backgroundImage: `url(${topLeft})` }"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute -top-1/10 right-1/10 flex aspect-square w-7 items-center justify-center rounded-lg border border-primary-comfy-canvas/30 bg-white/20 text-primary-comfy-canvas shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<i class="icon-[comfy--bytedance-mono] size-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-3/20 left-1/10 aspect-4/3 w-1/4 overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat shadow-2xl"
|
||||
:style="{ backgroundImage: `url(${bottomLeft})` }"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute right-2/25 bottom-1/10 aspect-4/3 w-3/10 overflow-hidden rounded-lg border border-primary-comfy-canvas/50 bg-cover bg-center bg-no-repeat shadow-2xl"
|
||||
:style="{ backgroundImage: `url(${bottomRight})` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex w-full max-w-md flex-col items-center gap-1 text-center"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-inter text-base font-semibold text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('cloudHero.slides.cloud.title') }}
|
||||
</p>
|
||||
<p class="m-0 font-inter text-sm text-primary-comfy-canvas/70">
|
||||
{{ t('cloudHero.slides.cloud.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCloud"
|
||||
class="flex w-full items-center justify-center gap-3 pt-6"
|
||||
>
|
||||
<p class="m-0 hidden text-sm text-primary-comfy-canvas/90 md:block">
|
||||
{{ t('cloudStart_wantToRun') }}
|
||||
</p>
|
||||
<a
|
||||
href="https://www.comfy.org/download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex h-8 items-center gap-2 rounded-lg border border-primary-comfy-canvas/20 bg-charcoal-500 p-2 text-xs font-medium text-primary-comfy-canvas no-underline hover:bg-charcoal-500/80"
|
||||
>
|
||||
<i class="pi pi-download text-xs text-primary-comfy-canvas/90" />
|
||||
{{ t('cloudStart_download') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import bottomLeft from '@/platform/cloud/onboarding/assets/hero/bottom-left.jpg'
|
||||
import bottomRight from '@/platform/cloud/onboarding/assets/hero/bottom-right.jpg'
|
||||
import centerImage from '@/platform/cloud/onboarding/assets/hero/center-image.jpg'
|
||||
import topLeft from '@/platform/cloud/onboarding/assets/hero/top-left.jpg'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<div class="mx-auto flex h-[7%] max-h-[70px] w-5/6 items-end">
|
||||
<img
|
||||
src="/assets/images/comfy-cloud-logo.svg"
|
||||
:alt="$t('subscription.comfyCloudLogo')"
|
||||
class="h-3/4 max-h-10 w-auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -67,7 +67,8 @@
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium text-white"
|
||||
variant="secondary"
|
||||
class="relative mt-4 h-10 w-full gap-4 rounded-md border border-solid border-smoke-800/10 bg-smoke-800/10 text-sm/4 font-medium text-primary-comfy-canvas shadow-inset-highlight hover:bg-sand-300/20"
|
||||
:disabled="!$form.valid"
|
||||
>
|
||||
{{ t('auth.login.loginButton') }}
|
||||
@@ -117,12 +118,22 @@ const onSubmit = (event: FormSubmitEvent) => {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: #2d2e32 !important;
|
||||
color: var(--color-primary-comfy-canvas) !important;
|
||||
caret-color: var(--color-primary-comfy-canvas);
|
||||
}
|
||||
|
||||
:deep(.p-inputtext::placeholder) {
|
||||
color: rgb(from var(--color-primary-comfy-canvas) r g b / 0.5);
|
||||
}
|
||||
|
||||
:deep(.p-password input) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:deep(.p-password-toggle-mask-icon) {
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.p-checkbox-checked .p-checkbox-box) {
|
||||
background-color: #f0ff41 !important;
|
||||
border-color: #f0ff41 !important;
|
||||
|
||||
@@ -1,81 +1,27 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<BaseViewTemplate dark class="flex-1">
|
||||
<template #header>
|
||||
<CloudLogo />
|
||||
</template>
|
||||
<slot />
|
||||
<template #footer>
|
||||
<CloudTemplateFooter />
|
||||
</template>
|
||||
</BaseViewTemplate>
|
||||
<div class="relative hidden flex-1 overflow-hidden bg-black lg:block">
|
||||
<!-- Video Background -->
|
||||
<video
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
:poster="videoPoster"
|
||||
>
|
||||
<source :src="videoSrc" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<div class="absolute inset-0 size-full bg-black/30"></div>
|
||||
|
||||
<!-- Optional Overlay for better visual -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center text-center text-white"
|
||||
>
|
||||
<div>
|
||||
<h1 class="font-abcrom hero-title font-black uppercase italic">
|
||||
{{ t('cloudStart_title') }}
|
||||
</h1>
|
||||
<p class="m-2 text-center text-xl text-white">
|
||||
{{ t('cloudStart_desc') }}
|
||||
</p>
|
||||
<p class="m-0 text-center text-xl text-white">
|
||||
{{ t('cloudStart_explain') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-svh w-screen bg-primary-comfy-ink font-sans text-primary-comfy-canvas"
|
||||
>
|
||||
<div class="relative flex flex-1 flex-col">
|
||||
<div class="flex h-16 shrink-0 items-center px-6">
|
||||
<i
|
||||
class="icon-[comfy--comfy-logo] h-5 w-22 text-brand-yellow md:h-6 md:w-26"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-0 flex flex-col justify-end px-14 pb-[64px]">
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-md text-white">
|
||||
{{ t('cloudStart_wantToRun') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10 bg-black font-bold text-white"
|
||||
variant="secondary"
|
||||
@click="handleDownloadClick"
|
||||
>
|
||||
{{ t('cloudStart_download') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-center overflow-auto">
|
||||
<slot />
|
||||
</div>
|
||||
<CloudTemplateFooter />
|
||||
</div>
|
||||
<div class="relative hidden flex-1 overflow-hidden py-2 pr-2 lg:block">
|
||||
<CloudHeroCarousel />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import videoPoster from '@/platform/cloud/onboarding/assets/videos/thumbnail.png'
|
||||
import videoSrc from '@/platform/cloud/onboarding/assets/videos/video.mp4'
|
||||
import CloudLogo from '@/platform/cloud/onboarding/components/CloudLogo.vue'
|
||||
import CloudHeroCarousel from '@/platform/cloud/onboarding/components/CloudHeroCarousel.vue'
|
||||
import CloudTemplateFooter from '@/platform/cloud/onboarding/components/CloudTemplateFooter.vue'
|
||||
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const handleDownloadClick = () => {
|
||||
window.open('https://www.comfy.org/download', '_blank')
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@import '../assets/css/fonts.css';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<footer class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start gap-2.5">
|
||||
<footer
|
||||
class="mx-auto flex h-[5%] max-h-[60px] w-5/6 items-start justify-center gap-2.5"
|
||||
>
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
|
||||
65
src/platform/cloud/onboarding/onboardingCloudRoutes.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { oauthConsentRedirect } from '@/platform/cloud/onboarding/onboardingCloudRoutes'
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
clearOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
|
||||
const VALID_REQUEST_ID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
const createSessionOrThrow = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@/platform/auth/session/useSessionCookie', () => ({
|
||||
useSessionCookie: () => ({ createSessionOrThrow })
|
||||
}))
|
||||
|
||||
describe('oauthConsentRedirect', () => {
|
||||
beforeEach(() => {
|
||||
clearOAuthRequestId()
|
||||
createSessionOrThrow.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('routes to user-check and mints no session when no OAuth flow is pending', async () => {
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(target).toEqual({ name: 'cloud-user-check' })
|
||||
expect(createSessionOrThrow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mints the Cloud session cookie before redirecting to consent when resuming OAuth', async () => {
|
||||
// Regression: an already-signed-in user (Firebase) carries no Cloud session
|
||||
// cookie, so the consent challenge fetch fails unless the cookie is minted
|
||||
// here, mirroring the post-login resume path.
|
||||
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
|
||||
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(createSessionOrThrow).toHaveBeenCalledOnce()
|
||||
expect(target).toEqual({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
})
|
||||
|
||||
it('still lands on consent when session minting fails so the view can surface the error', async () => {
|
||||
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
|
||||
createSessionOrThrow.mockRejectedValue(new Error('Unauthorized'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(target).toEqual({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Failed to establish Cloud session cookie before OAuth consent:',
|
||||
expect.any(Error)
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,14 +5,39 @@ import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
|
||||
// `oauth_request_id` capture lives in the global router.beforeEach guard
|
||||
// (src/router.ts), which runs before any per-route beforeEnter. Per-route
|
||||
// guards read it back via getOAuthRequestId().
|
||||
function oauthConsentRedirect() {
|
||||
//
|
||||
// When an already-signed-in user is bounced to login/signup mid-OAuth, we skip
|
||||
// the sign-in step and jump straight to consent. The consent challenge fetch
|
||||
// (GET /oauth/authorize) is authenticated by the Cloud *session cookie*, which
|
||||
// is a separate credential from the Firebase client login that `isLoggedIn`
|
||||
// reflects. The post-login resume path mints that cookie via
|
||||
// `createSessionOrThrow` (see useOAuthPostLoginRedirect); the already-signed-in
|
||||
// path must do the same. Without it the consent fetch is unauthenticated, the
|
||||
// backend 302s it to login, and the consent view fails with
|
||||
// "OAuth request failed. Please restart from the client app."
|
||||
export async function oauthConsentRedirect() {
|
||||
const oauthRequestId = getOAuthRequestId()
|
||||
return oauthRequestId
|
||||
? {
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
}
|
||||
: { name: 'cloud-user-check' }
|
||||
if (!oauthRequestId) return { name: 'cloud-user-check' }
|
||||
|
||||
try {
|
||||
const { useSessionCookie } =
|
||||
await import('@/platform/auth/session/useSessionCookie')
|
||||
await useSessionCookie().createSessionOrThrow()
|
||||
} catch (error) {
|
||||
// Best effort: if the cookie can't be minted (e.g. an expired Firebase
|
||||
// token), still land on the consent view so it can surface the failure and
|
||||
// prompt the user to restart from the client app, rather than silently
|
||||
// dropping the OAuth flow.
|
||||
console.warn(
|
||||
'Failed to establish Cloud session cookie before OAuth consent:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
}
|
||||
}
|
||||
|
||||
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
@@ -34,7 +59,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next(oauthConsentRedirect())
|
||||
return next(await oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
@@ -52,7 +77,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next(oauthConsentRedirect())
|
||||
return next(await oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
|
||||
@@ -50,7 +50,7 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
asset_hash: assetHash,
|
||||
hash: assetHash,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function getAssetDetectionNames(
|
||||
): string[] {
|
||||
const names = new Set<string>()
|
||||
// Treat names and hashes as opaque match keys because Cloud may use either in widget values.
|
||||
addPathDetectionNames(names, asset.asset_hash, options)
|
||||
addPathDetectionNames(names, asset.hash, options)
|
||||
addPathDetectionNames(names, asset.name, options)
|
||||
|
||||
const subfolder = asset.user_metadata?.subfolder
|
||||
|
||||
@@ -115,7 +115,7 @@ function makeAsset(name: string, assetHash: string | null = null): AssetItem {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
asset_hash: assetHash,
|
||||
hash: assetHash,
|
||||
mime_type: null,
|
||||
tags: ['input']
|
||||
}
|
||||
@@ -532,7 +532,7 @@ describe('verifyMediaCandidates', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('matches asset names when asset_hash is null', async () => {
|
||||
it('matches asset names when hash is null', async () => {
|
||||
const candidates = [
|
||||
makeCandidate('1', 'legacy-photo.png', { isMissing: undefined }),
|
||||
makeCandidate('2', 'missing-photo.png', { isMissing: undefined })
|
||||
|
||||
@@ -140,8 +140,8 @@ interface MediaVerificationOptions {
|
||||
* Verify media candidates against assets available to the current runtime.
|
||||
*
|
||||
* A candidate's `name` may be either a filename or an opaque asset hash.
|
||||
* Cloud-side `asset_hash` is not guaranteed to follow a single shape, so we
|
||||
* match against the union of `asset.name` and `asset.asset_hash`. Output
|
||||
* Cloud-side `hash` is not guaranteed to follow a single shape, so we
|
||||
* match against the union of `asset.name` and `asset.hash`. Output
|
||||
* candidates are matched against Cloud output assets or Core generated-history
|
||||
* assets because Core resolves those annotations against output folders, not
|
||||
* input files.
|
||||
|
||||
@@ -1445,13 +1445,13 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'other_model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'other_model.safetensors' }
|
||||
}
|
||||
])
|
||||
@@ -1465,7 +1465,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve isMissing=false when asset with matching asset_hash exists', async () => {
|
||||
it('should resolve isMissing=false when asset with matching hash exists', async () => {
|
||||
const candidates = [
|
||||
makeAssetCandidate('model.safetensors', {
|
||||
hash: 'abc123',
|
||||
@@ -1473,7 +1473,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
})
|
||||
]
|
||||
mockGetAssets.mockReturnValue([
|
||||
{ id: '1', name: 'model.safetensors', asset_hash: 'sha256:abc123' }
|
||||
{ id: '1', name: 'model.safetensors', hash: 'sha256:abc123' }
|
||||
])
|
||||
|
||||
await verifyAssetSupportedCandidates(candidates)
|
||||
@@ -1487,7 +1487,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
@@ -1578,7 +1578,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'checkpoint.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'checkpoint.safetensors' }
|
||||
}
|
||||
]
|
||||
@@ -1601,7 +1601,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'model.safetensors' }
|
||||
}
|
||||
])
|
||||
@@ -1617,7 +1617,7 @@ describe('verifyAssetSupportedCandidates', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'my_model.safetensors',
|
||||
asset_hash: null,
|
||||
hash: null,
|
||||
metadata: { filename: 'subfolder/my_model.safetensors' }
|
||||
}
|
||||
])
|
||||
|
||||
@@ -501,7 +501,7 @@ function isAssetInstalled(
|
||||
): boolean {
|
||||
if (candidate.hash && candidate.hashType) {
|
||||
const candidateHash = `${candidate.hashType}:${candidate.hash}`
|
||||
if (assets.some((a) => a.asset_hash === candidateHash)) return true
|
||||
if (assets.some((a) => a.hash === candidateHash)) return true
|
||||
}
|
||||
|
||||
const normalizedName = normalizePath(candidate.name)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { remoteConfig, remoteConfigState } from './remoteConfig'
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
} from './remoteConfig'
|
||||
|
||||
interface RefreshRemoteConfigOptions {
|
||||
/**
|
||||
@@ -34,6 +38,10 @@ export async function refreshRemoteConfig(
|
||||
window.__CONFIG__ = config
|
||||
remoteConfig.value = config
|
||||
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
|
||||
if (useAuth)
|
||||
cachedTeamWorkspacesEnabled.value = Boolean(
|
||||
config.team_workspaces_enabled
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
import type { ServerFeatureFlag } from '@/composables/useFeatureFlags'
|
||||
|
||||
/**
|
||||
* Remote configuration service
|
||||
*
|
||||
@@ -50,3 +54,8 @@ export function configValueOrDefault<K extends keyof RemoteConfig>(
|
||||
const configValue = remoteConfig[key]
|
||||
return configValue || defaultValue
|
||||
}
|
||||
|
||||
export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
|
||||
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
@@ -198,6 +199,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
)
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackNodeAdded?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplateFilterChanged?.(metadata))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
import { installNodeAddedTelemetry } from './installNodeAddedTelemetry'
|
||||
import { withNodeAddSource } from './nodeAddSource'
|
||||
|
||||
const trackNodeAdded = vi.fn()
|
||||
|
||||
vi.mock('..', () => ({
|
||||
useTelemetry: () => ({ trackNodeAdded })
|
||||
}))
|
||||
|
||||
function fakeGraph(): LGraph {
|
||||
return { onNodeAdded: undefined } as unknown as LGraph
|
||||
}
|
||||
|
||||
function fakeNode(type: string): LGraphNode {
|
||||
return { type } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('installNodeAddedTelemetry', () => {
|
||||
beforeEach(() => {
|
||||
trackNodeAdded.mockClear()
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
})
|
||||
|
||||
it('fires trackNodeAdded with the current source on add', () => {
|
||||
const graph = fakeGraph()
|
||||
installNodeAddedTelemetry(graph)
|
||||
|
||||
withNodeAddSource('sidebar_drag', () => {
|
||||
graph.onNodeAdded?.(fakeNode('KSampler'))
|
||||
})
|
||||
|
||||
expect(trackNodeAdded).toHaveBeenCalledExactlyOnceWith({
|
||||
node_type: 'KSampler',
|
||||
source: 'sidebar_drag'
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults source to "unknown" outside withNodeAddSource', () => {
|
||||
const graph = fakeGraph()
|
||||
installNodeAddedTelemetry(graph)
|
||||
|
||||
graph.onNodeAdded?.(fakeNode('CheckpointLoader'))
|
||||
|
||||
expect(trackNodeAdded).toHaveBeenCalledWith({
|
||||
node_type: 'CheckpointLoader',
|
||||
source: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips telemetry during workflow load', () => {
|
||||
const graph = fakeGraph()
|
||||
installNodeAddedTelemetry(graph)
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
|
||||
graph.onNodeAdded?.(fakeNode('VAEDecode'))
|
||||
|
||||
expect(trackNodeAdded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves an existing onNodeAdded subscriber', () => {
|
||||
const graph = fakeGraph()
|
||||
const previous = vi.fn()
|
||||
graph.onNodeAdded = previous
|
||||
installNodeAddedTelemetry(graph)
|
||||
|
||||
const node = fakeNode('LoadImage')
|
||||
graph.onNodeAdded?.(node)
|
||||
|
||||
expect(previous).toHaveBeenCalledExactlyOnceWith(node)
|
||||
expect(trackNodeAdded).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
import { useTelemetry } from '..'
|
||||
import { getCurrentNodeAddSource } from './nodeAddSource'
|
||||
|
||||
/**
|
||||
* Wire `app:node_added_to_workflow` telemetry into a graph. Wraps any existing
|
||||
* `onNodeAdded` callback so we don't displace other subscribers. Bulk
|
||||
* additions during workflow load are skipped — `workflow_imported`
|
||||
* already covers those.
|
||||
*/
|
||||
export function installNodeAddedTelemetry(graph: LGraph): void {
|
||||
const previous = graph.onNodeAdded
|
||||
graph.onNodeAdded = function (node) {
|
||||
previous?.call(this, node)
|
||||
if (ChangeTracker.isLoadingGraph) return
|
||||
useTelemetry()?.trackNodeAdded({
|
||||
node_type: node.type ?? 'unknown',
|
||||
source: getCurrentNodeAddSource()
|
||||
})
|
||||
}
|
||||
}
|
||||
22
src/platform/telemetry/nodeAdded/nodeAddSource.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { NodeAddSource } from '../types'
|
||||
|
||||
let currentSource: NodeAddSource = 'unknown'
|
||||
|
||||
export function getCurrentNodeAddSource(): NodeAddSource {
|
||||
return currentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the node-add source for the duration of `fn`. Synchronous only —
|
||||
* the source is read by the synchronous LGraph.onNodeAdded callback that
|
||||
* fires inside `graph.add()`. Nesting restores the previous value on exit.
|
||||
*/
|
||||
export function withNodeAddSource<T>(source: NodeAddSource, fn: () => T): T {
|
||||
const previous = currentSource
|
||||
currentSource = source
|
||||
try {
|
||||
return fn()
|
||||
} finally {
|
||||
currentSource = previous
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
@@ -7,20 +7,30 @@ const hoisted = vi.hoisted(() => {
|
||||
const mockInit = vi.fn()
|
||||
const mockIdentify = vi.fn()
|
||||
const mockPeopleSet = vi.fn()
|
||||
const mockPeopleSetOnce = vi.fn()
|
||||
const mockRegister = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockOnUserResolved = vi.fn()
|
||||
const mockOnUserLogout = vi.fn()
|
||||
|
||||
return {
|
||||
mockCapture,
|
||||
mockInit,
|
||||
mockIdentify,
|
||||
mockPeopleSet,
|
||||
mockPeopleSetOnce,
|
||||
mockRegister,
|
||||
mockReset,
|
||||
mockOnUserResolved,
|
||||
mockOnUserLogout,
|
||||
mockPosthog: {
|
||||
default: {
|
||||
init: mockInit,
|
||||
capture: mockCapture,
|
||||
identify: mockIdentify,
|
||||
people: { set: mockPeopleSet }
|
||||
register: mockRegister,
|
||||
people: { set: mockPeopleSet, set_once: mockPeopleSetOnce },
|
||||
reset: mockReset
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +46,8 @@ vi.mock('vue', async () => {
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: hoisted.mockOnUserResolved
|
||||
onUserResolved: hoisted.mockOnUserResolved,
|
||||
onUserLogout: hoisted.mockOnUserLogout
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -130,7 +141,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies user when onUserResolved fires', async () => {
|
||||
it('identifies user without setting first_auth_at when onUserResolved fires', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -141,6 +152,99 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop entry capture', () => {
|
||||
function setLocation(search: string): void {
|
||||
Object.defineProperty(window.location, 'search', {
|
||||
configurable: true,
|
||||
value: search,
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setLocation('')
|
||||
})
|
||||
|
||||
it('does not register desktop props when utm_source is absent', async () => {
|
||||
setLocation('')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
|
||||
setLocation('?utm_source=google&desktop_device_id=should-be-ignored')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers source_app and desktop_device_id when arriving from desktop', async () => {
|
||||
setLocation(
|
||||
'?utm_source=comfy.desktop&utm_medium=app_feature&desktop_device_id=device-abc'
|
||||
)
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
source_app: 'desktop',
|
||||
desktop_device_id: 'device-abc'
|
||||
})
|
||||
})
|
||||
|
||||
it('registers source_app alone when desktop_device_id is missing', async () => {
|
||||
setLocation('?utm_source=comfy.desktop')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
source_app: 'desktop'
|
||||
})
|
||||
})
|
||||
|
||||
it('persists desktop props to the person on identify so backend events inherit them', async () => {
|
||||
setLocation('?utm_source=comfy.desktop&desktop_device_id=device-xyz')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
|
||||
callback({ id: 'user-456' })
|
||||
|
||||
const setCall = hoisted.mockPeopleSet.mock.calls.find(
|
||||
([props]) => props && 'desktop_device_id' in props
|
||||
)
|
||||
expect(setCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
source_app: 'desktop',
|
||||
desktop_device_id: 'device-xyz',
|
||||
last_seen_via_desktop: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockPeopleSetOnce).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ first_seen_via_desktop: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not touch the person profile on identify for non-desktop visitors', async () => {
|
||||
setLocation('')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
|
||||
callback({ id: 'user-789' })
|
||||
|
||||
const desktopSetCall = hoisted.mockPeopleSet.mock.calls.find(
|
||||
([props]) =>
|
||||
props &&
|
||||
('desktop_device_id' in props || 'last_seen_via_desktop' in props)
|
||||
)
|
||||
expect(desktopSetCall).toBeUndefined()
|
||||
expect(hoisted.mockPeopleSetOnce).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('event tracking', () => {
|
||||
it('captures events after initialization', async () => {
|
||||
const provider = createProvider()
|
||||
@@ -166,6 +270,88 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sets first_auth_at on new-user auth', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
first_auth_at: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not set first_auth_at on returning-user auth', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: false,
|
||||
user_id: 'user-123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{
|
||||
method: 'google',
|
||||
is_new_user: false,
|
||||
user_id: 'user-123'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes queued first_auth_at before queued auth event', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
|
||||
'user-123',
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
first_auth_at: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'user-123'
|
||||
}
|
||||
)
|
||||
expect(hoisted.mockIdentify.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.mockCapture.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('queues events before initialization and flushes after', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
@@ -236,6 +422,32 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('registers onUserLogout watcher after init', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockOnUserLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls posthog.reset(true) when the watcher fires', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserLogout.mock.calls[0][0]
|
||||
callback()
|
||||
|
||||
expect(hoisted.mockReset).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('does not register the watcher before init resolves', () => {
|
||||
createProvider()
|
||||
|
||||
expect(hoisted.mockOnUserLogout).not.toHaveBeenCalled()
|
||||
expect(hoisted.mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('page view', () => {
|
||||
it('captures page view with page_name property', async () => {
|
||||
const provider = createProvider()
|
||||
@@ -263,4 +475,72 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('before_send', () => {
|
||||
it('strips PII keys from event properties, $set, and $set_once', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const { before_send } = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
const event = {
|
||||
event: 'test',
|
||||
properties: {
|
||||
email: 'props@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'props_user@example.com',
|
||||
$email: 'props_posthog@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'set@example.com',
|
||||
user_email: 'set_user@example.com',
|
||||
$email: 'set_posthog@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'set_once@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = before_send(event)
|
||||
|
||||
// event.properties — all four PII keys stripped, non-PII preserved
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
// event.$set — PII stripped, non-PII preserved
|
||||
// posthog.identify(id, { email }) lands here, not in properties
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
// event.$set_once — PII stripped, non-PII preserved
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
|
||||
const remoteBefore_send = vi.fn()
|
||||
mockRemoteConfig.value = {
|
||||
posthog_config: {
|
||||
before_send: remoteBefore_send,
|
||||
person_profiles: 'always'
|
||||
}
|
||||
}
|
||||
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const initConfig = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
expect(initConfig.before_send).not.toBe(remoteBefore_send)
|
||||
expect(initConfig.person_profiles).toBe('identified_only')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
@@ -19,6 +21,7 @@ import type {
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeAddedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
@@ -69,6 +72,20 @@ interface QueuedEvent {
|
||||
properties?: TelemetryEventProperties
|
||||
}
|
||||
|
||||
interface DesktopEntryProps {
|
||||
source_app: 'desktop'
|
||||
desktop_device_id?: string
|
||||
}
|
||||
|
||||
function readDesktopEntryProps(): DesktopEntryProps | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('utm_source') !== 'comfy.desktop') return null
|
||||
const props: DesktopEntryProps = { source_app: 'desktop' }
|
||||
const deviceId = params.get('desktop_device_id')
|
||||
if (deviceId) props.desktop_device_id = deviceId
|
||||
return props
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog Telemetry Provider - Cloud Build Implementation
|
||||
*
|
||||
@@ -82,9 +99,11 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private isEnabled = true
|
||||
private posthog: PostHog | null = null
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private pendingFirstAuthAt = new Map<string, string>()
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
private desktopEntryProps: DesktopEntryProps | null = null
|
||||
|
||||
constructor() {
|
||||
this.configureDisabledEvents(
|
||||
@@ -114,17 +133,38 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
...serverConfig
|
||||
...serverConfig,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
|
||||
// automatically when persistence includes 'cookie' (the default).
|
||||
// Explicit override interacts badly with posthog-js#3578 where reset() fails
|
||||
// to clear localStorage on other subdomains, causing identity bleed on logout.
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
this.registerDesktopEntryProps()
|
||||
|
||||
useCurrentUser().onUserResolved((user) => {
|
||||
const currentUser = useCurrentUser()
|
||||
currentUser.onUserResolved((user) => {
|
||||
if (this.posthog && user.id) {
|
||||
this.posthog.identify(user.id)
|
||||
this.setDesktopEntryPersonProperties()
|
||||
this.setSubscriptionProperties()
|
||||
}
|
||||
})
|
||||
// Anchored to session state rather than the logout button so it
|
||||
// also covers token revocation, account deletion, and cross-tab
|
||||
// sign-out (browserLocalPersistence). A logout that lands during
|
||||
// the posthog-js dynamic-import window will not be observed here:
|
||||
// events buffered pre-init are intentionally NOT queue-cleared on
|
||||
// logout, which leaves a narrow race where a logout + different
|
||||
// login both inside the import window would flush pre-init events
|
||||
// under the new identity. Accepted as a known edge — re-adding
|
||||
// pre-init logout handling would defeat the simplification.
|
||||
currentUser.onUserLogout(() => {
|
||||
this.posthog?.reset(true)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load PostHog:', error)
|
||||
@@ -143,6 +183,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private flushEventQueue(): void {
|
||||
if (!this.isInitialized || !this.posthog) return
|
||||
|
||||
this.flushPendingFirstAuthAt()
|
||||
|
||||
while (this.eventQueue.length > 0) {
|
||||
const event = this.eventQueue.shift()!
|
||||
try {
|
||||
@@ -153,6 +195,33 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private flushPendingFirstAuthAt(): void {
|
||||
for (const [userId, firstAuthAt] of this.pendingFirstAuthAt) {
|
||||
this.setFirstAuthAt(userId, firstAuthAt)
|
||||
}
|
||||
this.pendingFirstAuthAt.clear()
|
||||
}
|
||||
|
||||
private setFirstAuthAt(
|
||||
userId: string,
|
||||
firstAuthAt = new Date().toISOString()
|
||||
): void {
|
||||
if (!this.isEnabled) return
|
||||
|
||||
if (this.isInitialized && this.posthog) {
|
||||
try {
|
||||
this.posthog.identify(userId, undefined, { first_auth_at: firstAuthAt })
|
||||
} catch (error) {
|
||||
console.error('Failed to set PostHog first auth timestamp:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.pendingFirstAuthAt.has(userId)) {
|
||||
this.pendingFirstAuthAt.set(userId, firstAuthAt)
|
||||
}
|
||||
}
|
||||
|
||||
private trackEvent(
|
||||
eventName: TelemetryEventName,
|
||||
properties?: TelemetryEventProperties
|
||||
@@ -215,6 +284,34 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private registerDesktopEntryProps(): void {
|
||||
if (!this.posthog) return
|
||||
const props = readDesktopEntryProps()
|
||||
if (!props) return
|
||||
this.desktopEntryProps = props
|
||||
try {
|
||||
this.posthog.register(props)
|
||||
} catch (error) {
|
||||
console.error('Failed to register desktop entry props:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Persisted onto the person so backend-fired billing events inherit
|
||||
// desktop_device_id via person-on-events at ingest.
|
||||
private setDesktopEntryPersonProperties(): void {
|
||||
if (!this.posthog || !this.desktopEntryProps) return
|
||||
const now = new Date().toISOString()
|
||||
try {
|
||||
this.posthog.people.set({
|
||||
...this.desktopEntryProps,
|
||||
last_seen_via_desktop: now
|
||||
})
|
||||
this.posthog.people.set_once({ first_seen_via_desktop: now })
|
||||
} catch (error) {
|
||||
console.error('Failed to set desktop entry person properties:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private setSubscriptionProperties(): void {
|
||||
const { subscriptionTier } = useSubscription()
|
||||
watch(
|
||||
@@ -233,6 +330,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
if (metadata.is_new_user && metadata.user_id) {
|
||||
this.setFirstAuthAt(metadata.user_id)
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
}
|
||||
|
||||
@@ -403,6 +503,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
|
||||
}
|
||||
|
||||
trackNodeAdded(metadata: NodeAddedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.NODE_ADDED, metadata)
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
|
||||
}
|
||||
|
||||
@@ -139,9 +139,23 @@ export interface CreditTopupMetadata {
|
||||
/**
|
||||
* Workflow import metadata
|
||||
*/
|
||||
export interface MissingNodePack {
|
||||
/**
|
||||
* Custom node pack identifier (cnrId / aux_id from node properties).
|
||||
* `'unknown'` when the workflow JSON has no pack hint for the node.
|
||||
*/
|
||||
pack_id: string
|
||||
node_types: string[]
|
||||
}
|
||||
|
||||
export interface WorkflowImportMetadata {
|
||||
missing_node_count: number
|
||||
missing_node_types: string[]
|
||||
/**
|
||||
* Missing nodes grouped by their custom node pack. Populated from the
|
||||
* `cnr_id` / `aux_id` baked into node properties — no network lookups.
|
||||
*/
|
||||
missing_node_packs?: MissingNodePack[]
|
||||
/**
|
||||
* The source of the workflow open/import action
|
||||
*/
|
||||
@@ -232,6 +246,23 @@ export interface NodeSearchMetadata {
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Node added metadata. `source` indicates how the user initiated the add.
|
||||
* Bulk additions during workflow load are excluded — workflow_imported
|
||||
* already covers that.
|
||||
*/
|
||||
export type NodeAddSource =
|
||||
| 'sidebar_drag'
|
||||
| 'search_modal'
|
||||
| 'paste'
|
||||
| 'programmatic'
|
||||
| 'unknown'
|
||||
|
||||
export interface NodeAddedMetadata {
|
||||
node_type: string
|
||||
source: NodeAddSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Node search result selection metadata
|
||||
*/
|
||||
@@ -325,6 +356,7 @@ export interface CheckoutAttributionMetadata {
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
im_ref?: string
|
||||
rewardful_referral?: string
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
utm_campaign?: string
|
||||
@@ -437,6 +469,9 @@ export interface TelemetryProvider {
|
||||
trackNodeSearch?(metadata: NodeSearchMetadata): void
|
||||
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
|
||||
|
||||
// Node-added-to-canvas analytics
|
||||
trackNodeAdded?(metadata: NodeAddedMetadata): void
|
||||
|
||||
// Template filter tracking events
|
||||
trackTemplateFilterChanged?(metadata: TemplateFilterMetadata): void
|
||||
|
||||
@@ -523,6 +558,7 @@ export const TelemetryEvents = {
|
||||
// Node Search Analytics
|
||||
NODE_SEARCH: 'app:node_search',
|
||||
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
|
||||
NODE_ADDED: 'app:node_added_to_workflow',
|
||||
|
||||
// Template Filter Analytics
|
||||
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
captureCheckoutAttributionFromSearch,
|
||||
@@ -15,9 +15,15 @@ describe('getCheckoutAttribution', () => {
|
||||
}
|
||||
window.gtag = undefined
|
||||
window.ire = undefined
|
||||
window.rewardful = undefined
|
||||
window.Rewardful = undefined
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
@@ -228,4 +234,80 @@ describe('getCheckoutAttribution', () => {
|
||||
|
||||
expect(attribution.im_ref).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures Rewardful referral from window.Rewardful', async () => {
|
||||
window.Rewardful = {
|
||||
referral: 'rwd-abc-123'
|
||||
}
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.rewardful_referral).toBe('rwd-abc-123')
|
||||
})
|
||||
|
||||
it('returns undefined Rewardful referral when window.Rewardful is absent', async () => {
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.rewardful_referral).toBeUndefined()
|
||||
})
|
||||
|
||||
it('waits for Rewardful ready before reading the referral', async () => {
|
||||
let readyCallback: (() => void) | undefined
|
||||
window.rewardful = vi.fn((_method: 'ready', callback: () => void) => {
|
||||
readyCallback = callback
|
||||
}) as Window['rewardful']
|
||||
|
||||
const attributionPromise = getCheckoutAttribution()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(window.rewardful).toHaveBeenCalledWith('ready', expect.any(Function))
|
||||
|
||||
window.Rewardful = {
|
||||
referral: 'rwd-ready-123'
|
||||
}
|
||||
readyCallback?.()
|
||||
|
||||
const attribution = await attributionPromise
|
||||
|
||||
expect(attribution.rewardful_referral).toBe('rwd-ready-123')
|
||||
})
|
||||
|
||||
it('continues checkout attribution when Rewardful ready never runs', async () => {
|
||||
vi.useFakeTimers()
|
||||
window.rewardful = vi.fn() as Window['rewardful']
|
||||
|
||||
const attributionPromise = getCheckoutAttribution()
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
const attribution = await attributionPromise
|
||||
|
||||
expect(window.rewardful).toHaveBeenCalledWith('ready', expect.any(Function))
|
||||
expect(attribution.rewardful_referral).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined Rewardful referral when window.Rewardful.referral is empty', async () => {
|
||||
window.Rewardful = { referral: '' }
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution.rewardful_referral).toBeUndefined()
|
||||
})
|
||||
|
||||
it('captures Rewardful referral alongside Impact attribution', async () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
'/?im_ref=impact-url-id&utm_source=affiliate'
|
||||
)
|
||||
window.Rewardful = {
|
||||
referral: 'rwd-xyz-789'
|
||||
}
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
im_ref: 'impact-url-id',
|
||||
utm_source: 'affiliate',
|
||||
rewardful_referral: 'rwd-xyz-789'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
|
||||
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
|
||||
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
|
||||
const GET_GA_IDENTITY_TIMEOUT_MS = 300
|
||||
const GET_REWARDFUL_REFERRAL_TIMEOUT_MS = 300
|
||||
|
||||
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
@@ -180,6 +181,30 @@ async function getGeneratedClickId(): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getRewardfulReferral(): Promise<string | undefined> {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
|
||||
const referral = asNonEmptyString(window.Rewardful?.referral)
|
||||
if (referral) return referral
|
||||
|
||||
const rewardful = window.rewardful
|
||||
if (typeof rewardful !== 'function') return undefined
|
||||
|
||||
return withTimeout(
|
||||
() =>
|
||||
new Promise<string | undefined>((resolve, reject) => {
|
||||
try {
|
||||
rewardful('ready', () => {
|
||||
resolve(asNonEmptyString(window.Rewardful?.referral))
|
||||
})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}),
|
||||
GET_REWARDFUL_REFERRAL_TIMEOUT_MS
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function captureCheckoutAttributionFromSearch(search: string): void {
|
||||
const fromUrl = readAttributionFromUrl(search)
|
||||
const storedAttribution = readStoredAttribution()
|
||||
@@ -198,6 +223,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
|
||||
|
||||
const storedAttribution = readStoredAttribution()
|
||||
const fromUrl = readAttributionFromUrl(window.location.search)
|
||||
const rewardfulReferralPromise = getRewardfulReferral()
|
||||
const generatedClickId = await getGeneratedClickId()
|
||||
const attribution: Partial<Record<AttributionQueryKey, string>> = {
|
||||
...storedAttribution,
|
||||
@@ -212,12 +238,16 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
|
||||
persistAttribution(attribution)
|
||||
}
|
||||
|
||||
const gaIdentity = await getGaIdentity()
|
||||
const [gaIdentity, rewardfulReferral] = await Promise.all([
|
||||
getGaIdentity(),
|
||||
rewardfulReferralPromise
|
||||
])
|
||||
|
||||
return {
|
||||
...attribution,
|
||||
ga_client_id: gaIdentity?.client_id,
|
||||
ga_session_id: gaIdentity?.session_id,
|
||||
ga_session_number: gaIdentity?.session_number
|
||||
ga_session_number: gaIdentity?.session_number,
|
||||
rewardful_referral: rewardfulReferral
|
||||
}
|
||||
}
|
||||
|
||||
71
src/platform/telemetry/utils/groupMissingNodesByPack.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
import { groupMissingNodesByPack } from './groupMissingNodesByPack'
|
||||
|
||||
describe('groupMissingNodesByPack', () => {
|
||||
it('returns an empty array when no missing nodes', () => {
|
||||
expect(groupMissingNodesByPack([])).toEqual([])
|
||||
})
|
||||
|
||||
it('groups multiple node types under the same pack', () => {
|
||||
const missing: MissingNodeType[] = [
|
||||
{ type: 'ImpactSampler', cnrId: 'impact-pack' },
|
||||
{ type: 'ImpactDetailer', cnrId: 'impact-pack' },
|
||||
{ type: 'WASImageBlend', cnrId: 'was-node-suite' }
|
||||
]
|
||||
|
||||
const result = groupMissingNodesByPack(missing)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
pack_id: 'impact-pack',
|
||||
node_types: ['ImpactSampler', 'ImpactDetailer']
|
||||
},
|
||||
{ pack_id: 'was-node-suite', node_types: ['WASImageBlend'] }
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplicates the same node type within a pack', () => {
|
||||
const missing: MissingNodeType[] = [
|
||||
{ type: 'ImpactSampler', cnrId: 'impact-pack' },
|
||||
{ type: 'ImpactSampler', cnrId: 'impact-pack' }
|
||||
]
|
||||
|
||||
expect(groupMissingNodesByPack(missing)).toEqual([
|
||||
{ pack_id: 'impact-pack', node_types: ['ImpactSampler'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to "unknown" for nodes without a cnrId', () => {
|
||||
const missing: MissingNodeType[] = [
|
||||
{ type: 'MysteryNode' },
|
||||
'LegacyStringNode'
|
||||
]
|
||||
|
||||
expect(groupMissingNodesByPack(missing)).toEqual([
|
||||
{ pack_id: 'unknown', node_types: ['MysteryNode', 'LegacyStringNode'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps "unknown" separate from identified packs', () => {
|
||||
const missing: MissingNodeType[] = [
|
||||
{ type: 'ImpactSampler', cnrId: 'impact-pack' },
|
||||
{ type: 'MysteryNode' }
|
||||
]
|
||||
|
||||
const result = groupMissingNodesByPack(missing)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ pack_id: 'impact-pack', node_types: ['ImpactSampler'] },
|
||||
{ pack_id: 'unknown', node_types: ['MysteryNode'] }
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
29
src/platform/telemetry/utils/groupMissingNodesByPack.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
import type { MissingNodePack } from '../types'
|
||||
|
||||
const UNKNOWN_PACK_ID = 'unknown'
|
||||
|
||||
export function groupMissingNodesByPack(
|
||||
missingNodes: readonly MissingNodeType[]
|
||||
): MissingNodePack[] {
|
||||
const byPack = new Map<string, Set<string>>()
|
||||
|
||||
for (const node of missingNodes) {
|
||||
const type = typeof node === 'string' ? node : node.type
|
||||
const packId =
|
||||
typeof node === 'string' ? UNKNOWN_PACK_ID : node.cnrId || UNKNOWN_PACK_ID
|
||||
|
||||
const existing = byPack.get(packId)
|
||||
if (existing) {
|
||||
existing.add(type)
|
||||
} else {
|
||||
byPack.set(packId, new Set([type]))
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPack, ([pack_id, types]) => ({
|
||||
pack_id,
|
||||
node_types: Array.from(types)
|
||||
}))
|
||||
}
|
||||
@@ -13,7 +13,7 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
name: 'test-image.png',
|
||||
asset_hash: 'hash123',
|
||||
hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
@@ -539,7 +539,7 @@ describe('useComboWidget', () => {
|
||||
createMockAssetItem({
|
||||
id: 'asset-123',
|
||||
name: 'image1.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
hash: HASH_FILENAME
|
||||
})
|
||||
]
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
|
||||
@@ -183,7 +183,7 @@ const createInputMappingWidget = (
|
||||
getMediaTypeFromFilename(asset.name) ===
|
||||
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
|
||||
)
|
||||
.map((asset) => asset.asset_hash)
|
||||
.map((asset) => asset.hash)
|
||||
.filter((hash): hash is string => !!hash)
|
||||
)
|
||||
|
||||
|
||||
@@ -684,15 +684,14 @@ describe('useWidgetSelectItems', () => {
|
||||
|
||||
it('does not expand a hash-keyed asset even if its metadata reports outputCount > 1', async () => {
|
||||
// Defense against future cloud-schema changes: if a flat output row
|
||||
// ever ships with both asset_hash AND multi-output user_metadata, the
|
||||
// ever ships with both hash AND multi-output user_metadata, the
|
||||
// watcher must NOT replace it with synthesized AssetItems lacking the
|
||||
// hash, or select+load reverts to the FE-227 broken state.
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-flat-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
tags: ['output'],
|
||||
user_metadata: {
|
||||
jobId: 'job-future',
|
||||
@@ -729,13 +728,12 @@ describe('useWidgetSelectItems', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses asset_hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
|
||||
it('uses hash (not human filename) as the dropdown value when present, so cloud /view can resolve by hash', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'asset-out-1',
|
||||
name: 'z-image-turbo_00093_.png',
|
||||
asset_hash:
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
hash: '039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png',
|
||||
preview_url: '/api/view?filename=039b...0b13.png',
|
||||
tags: ['output']
|
||||
}
|
||||
@@ -753,7 +751,7 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(dropdownItems.value).toHaveLength(1)
|
||||
// The value (item.name) — what becomes modelValue on click — must be the
|
||||
// hash-keyed path so /api/view resolves it. Cloud's hash is in
|
||||
// asset_hash, not asset.name (which is the human filename).
|
||||
// asset.hash, not asset.name (which is the human filename).
|
||||
expect(dropdownItems.value[0].name).toBe(
|
||||
'039b051670f08941649419dcecea41cb9057f2895388f2e8165ec99df3af0b13.png [output]'
|
||||
)
|
||||
@@ -761,7 +759,7 @@ describe('useWidgetSelectItems', () => {
|
||||
expect(dropdownItems.value[0].label).toContain('z-image-turbo_00093_.png')
|
||||
})
|
||||
|
||||
it('falls back to asset.name when asset_hash is absent (local/history path)', async () => {
|
||||
it('falls back to asset.name when hash is absent (local/history path)', async () => {
|
||||
mockMediaAssets.media.value = [
|
||||
{
|
||||
id: 'local-1',
|
||||
@@ -973,8 +971,7 @@ describe('useWidgetSelectItems', () => {
|
||||
{
|
||||
id: 'asset-hash-1',
|
||||
name: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5.png',
|
||||
asset_hash:
|
||||
'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
|
||||
hash: 'a1ef7d292026e89ce9bbbd8093e2d0ed6a8850361a0c22e49522ac7baa5494e5',
|
||||
preview_url: '/preview.png',
|
||||
tags: ['output'],
|
||||
metadata: {
|
||||
|
||||
@@ -131,8 +131,8 @@ export function useWidgetSelectItems(options: UseWidgetSelectItemsOptions) {
|
||||
// Hash-keyed assets are leaf rows from the cloud `/assets` API and
|
||||
// already carry their own URL-resolvable filename. Expanding them via
|
||||
// resolveOutputAssetItems would synthesize sibling AssetItems without
|
||||
// an asset_hash and reintroduce the FE-227 hash→name fallback bug.
|
||||
if (asset.asset_hash) continue
|
||||
// a hash and reintroduce the FE-227 hash→name fallback bug.
|
||||
if (asset.hash) continue
|
||||
|
||||
const meta = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (!meta) continue
|
||||
|
||||
@@ -25,6 +25,8 @@ import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { installNodeAddedTelemetry } from '@/platform/telemetry/nodeAdded/installNodeAddedTelemetry'
|
||||
import { groupMissingNodesByPack } from '@/platform/telemetry/utils/groupMissingNodesByPack'
|
||||
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { updatePendingWarnings } from '@/platform/workflow/core/utils/pendingWarnings'
|
||||
@@ -888,6 +890,7 @@ export class ComfyApp {
|
||||
this.addAfterConfigureHandler(graph)
|
||||
|
||||
this.rootGraphInternal = graph
|
||||
installNodeAddedTelemetry(graph)
|
||||
this.canvas = new LGraphCanvas(canvasEl, graph)
|
||||
// Make canvas states reactive so we can observe changes on them.
|
||||
this.canvas.state = reactive(this.canvas.state)
|
||||
@@ -1425,6 +1428,7 @@ export class ComfyApp {
|
||||
missing_node_types: missingNodeTypes.map((node) =>
|
||||
typeof node === 'string' ? node : node.type
|
||||
),
|
||||
missing_node_packs: groupMissingNodesByPack(missingNodeTypes),
|
||||
open_source: openSource ?? 'unknown'
|
||||
}
|
||||
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
|
||||
|
||||
@@ -1463,7 +1463,7 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
{
|
||||
id: 'input-1',
|
||||
name: 'cute-puppy.png',
|
||||
asset_hash: 'abc123def.png',
|
||||
hash: 'abc123def.png',
|
||||
tags: ['input']
|
||||
}
|
||||
])
|
||||
@@ -1509,14 +1509,10 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
describe('assetsStore - Flat Output Assets (cloud-only)', () => {
|
||||
const FLAT_OUTPUT_PAGE_SIZE = 200
|
||||
|
||||
const makeAsset = (
|
||||
id: string,
|
||||
name: string,
|
||||
asset_hash?: string
|
||||
): AssetItem => ({
|
||||
const makeAsset = (id: string, name: string, hash?: string): AssetItem => ({
|
||||
id,
|
||||
name,
|
||||
asset_hash,
|
||||
hash,
|
||||
size: 0,
|
||||
tags: ['output']
|
||||
})
|
||||
|
||||
@@ -347,13 +347,14 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
/**
|
||||
* Map of asset hash filename to asset item for O(1) lookup
|
||||
* Cloud assets use asset_hash for the hash-based filename
|
||||
* Cloud assets use hash for the hash-based filename
|
||||
*/
|
||||
const inputAssetsByFilename = computed(() => {
|
||||
const map = new Map<string, AssetItem>()
|
||||
for (const asset of inputAssets.value) {
|
||||
if (asset.asset_hash) {
|
||||
map.set(asset.asset_hash, asset)
|
||||
const hash = asset.hash
|
||||
if (hash) {
|
||||
map.set(hash, asset)
|
||||
}
|
||||
}
|
||||
return map
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
|
||||
/**
|
||||
* Serialises an array of nodes using a modified version of the old Litegraph copy (& paste) function
|
||||
@@ -106,7 +107,7 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void {
|
||||
node.pos[1] += graph_mouse[1] - topLeft[1]
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
graph.add(node, true)
|
||||
withNodeAddSource('paste', () => graph.add(node, true))
|
||||
nodes.push(node)
|
||||
}
|
||||
|
||||
|
||||